diff --git a/Directory.Packages.props b/Directory.Packages.props
index 3d2772e..04a58a5 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -11,9 +11,18 @@
all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
diff --git a/Tiku.Api/Program.cs b/Tiku.Api/Program.cs
index ee9d65d..8000192 100644
--- a/Tiku.Api/Program.cs
+++ b/Tiku.Api/Program.cs
@@ -21,7 +21,7 @@ var summaries = new[]
app.MapGet("/weatherforecast", () =>
{
- var forecast = Enumerable.Range(1, 5).Select(index =>
+ var forecast = Enumerable.Range(1, 5).Select(index =>
new WeatherForecast
(
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
diff --git a/Tiku.Application/Class1.cs b/Tiku.Application/Class1.cs
deleted file mode 100644
index d5a5d22..0000000
--- a/Tiku.Application/Class1.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace Tiku.Application;
-
-public class Class1
-{
-
-}
diff --git a/Tiku.DbMigrator/DesignTimeTikuDbContextFactory.cs b/Tiku.DbMigrator/DesignTimeTikuDbContextFactory.cs
new file mode 100644
index 0000000..d3ad663
--- /dev/null
+++ b/Tiku.DbMigrator/DesignTimeTikuDbContextFactory.cs
@@ -0,0 +1,22 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+using Tiku.Infrastructure.Persistence;
+
+namespace Tiku.DbMigrator;
+
+public sealed class DesignTimeTikuDbContextFactory : IDesignTimeDbContextFactory
+{
+ public TikuDbContext CreateDbContext(string[] args)
+ {
+ var connectionString =
+ Environment.GetEnvironmentVariable("DATABASE_URL") ??
+ "Host=localhost;Database=tiku;Username=postgres";
+
+ var options = new DbContextOptionsBuilder()
+ .UseNpgsql(connectionString, npgsql =>
+ npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName))
+ .Options;
+
+ return new TikuDbContext(options);
+ }
+}
diff --git a/Tiku.DbMigrator/Program.cs b/Tiku.DbMigrator/Program.cs
index 3751555..346f94c 100644
--- a/Tiku.DbMigrator/Program.cs
+++ b/Tiku.DbMigrator/Program.cs
@@ -1,2 +1,20 @@
-// See https://aka.ms/new-console-template for more information
-Console.WriteLine("Hello, World!");
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Tiku.Infrastructure;
+using Tiku.Infrastructure.Persistence;
+
+var builder = Host.CreateApplicationBuilder(args);
+var connectionString =
+ builder.Configuration.GetConnectionString("Database") ??
+ Environment.GetEnvironmentVariable("DATABASE_URL") ??
+ throw new InvalidOperationException(
+ "Database connection is required. Configure ConnectionStrings:Database or DATABASE_URL.");
+
+builder.Services.AddInfrastructure(connectionString);
+
+using var host = builder.Build();
+await using var scope = host.Services.CreateAsyncScope();
+var dbContext = scope.ServiceProvider.GetRequiredService();
+await dbContext.Database.MigrateAsync();
diff --git a/Tiku.DbMigrator/Tiku.DbMigrator.csproj b/Tiku.DbMigrator/Tiku.DbMigrator.csproj
index a868c9e..7463c78 100644
--- a/Tiku.DbMigrator/Tiku.DbMigrator.csproj
+++ b/Tiku.DbMigrator/Tiku.DbMigrator.csproj
@@ -4,6 +4,14 @@
+
+
+ all
+ all
+
+
+
+
Exe
net10.0
diff --git a/Tiku.Domain/Catalog/CatalogEntities.cs b/Tiku.Domain/Catalog/CatalogEntities.cs
new file mode 100644
index 0000000..0c6375b
--- /dev/null
+++ b/Tiku.Domain/Catalog/CatalogEntities.cs
@@ -0,0 +1,126 @@
+using System.Text.Json;
+using Tiku.Domain.Common;
+
+namespace Tiku.Domain.Catalog;
+
+public sealed class Region : AuditableTenantEntity
+{
+ public string? LegacyId { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public string? Code { get; set; }
+ public string? ShortName { get; set; }
+ public string? FullName { get; set; }
+ public string? Icon { get; set; }
+ public string? Pinyin { get; set; }
+ public int SortOrder { get; set; }
+ public bool IsHot { get; set; }
+ public bool IsActive { get; set; } = true;
+ public JsonElement Config { get; set; } = JsonDefaults.Object();
+}
+
+public sealed class RegionModule : AuditableTenantEntity
+{
+ public Guid? RegionId { get; set; }
+ public string? LegacyId { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public string? Type { get; set; }
+ public string? Icon { get; set; }
+ public string? Color { get; set; }
+ public string? TextColor { get; set; }
+ public string? Description { get; set; }
+ public string? Route { get; set; }
+ public int SortOrder { get; set; }
+ public bool IsPrimarySchoolModule { get; set; }
+ public bool IsActive { get; set; } = true;
+}
+
+public sealed class ModuleNode : AuditableTenantEntity
+{
+ public Guid? RegionId { get; set; }
+ public Guid? ModuleId { get; set; }
+ public Guid? ParentId { get; set; }
+ public string? LegacyId { get; set; }
+ public string? LegacyParentId { get; set; }
+ public string? LegacyModuleId { get; set; }
+ public ModuleNodeType Type { get; set; } = ModuleNodeType.Custom;
+ public string Name { get; set; } = string.Empty;
+ public string? Path { get; set; }
+ public int SortOrder { get; set; }
+ public bool IsActive { get; set; } = true;
+ public JsonElement Metadata { get; set; } = JsonDefaults.Object();
+}
+
+public enum ModuleNodeType
+{
+ Category,
+ Subject,
+ Chapter,
+ Paper,
+ School,
+ Major,
+ Custom
+}
+
+public sealed class School : AuditableTenantEntity
+{
+ public Guid? RegionId { get; set; }
+ public Guid? ModuleId { get; set; }
+ public string? LegacyId { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public string? ProfessionalExamDate { get; set; }
+ public JsonElement Metadata { get; set; } = JsonDefaults.Object();
+}
+
+public sealed class Major : AuditableTenantEntity
+{
+ public Guid? RegionId { get; set; }
+ public Guid? SchoolId { get; set; }
+ public string? LegacyId { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public string? Description { get; set; }
+ public string? StudyTips { get; set; }
+ public int SortOrder { get; set; }
+ public bool IsActive { get; set; } = true;
+}
+
+public sealed class Subject : AuditableTenantEntity
+{
+ public Guid? RegionId { get; set; }
+ public Guid? ModuleId { get; set; }
+ public Guid? SchoolId { get; set; }
+ public Guid? MajorId { get; set; }
+ public Guid? NodeId { get; set; }
+ public string? LegacyId { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public SubjectType? Type { get; set; }
+ public JsonElement MajorLegacyIds { get; set; } = JsonDefaults.Array();
+ public string? Icon { get; set; }
+ public string? Description { get; set; }
+ public JsonElement Stats { get; set; } = JsonDefaults.Object();
+ public int SortOrder { get; set; }
+ public bool IsActive { get; set; } = true;
+}
+
+public enum SubjectType
+{
+ Cultural,
+ Professional
+}
+
+public sealed class Category : AuditableTenantEntity
+{
+ public Guid? SubjectId { get; set; }
+ public Guid? NodeId { get; set; }
+ public string? LegacyId { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public CategoryType? CategoryType { get; set; }
+ public int SortOrder { get; set; }
+ public int? SvipQuestionLimit { get; set; }
+ public bool IsActive { get; set; } = true;
+}
+
+public enum CategoryType
+{
+ Chapter,
+ Paper
+}
diff --git a/Tiku.Domain/Class1.cs b/Tiku.Domain/Class1.cs
deleted file mode 100644
index 4a5d7aa..0000000
--- a/Tiku.Domain/Class1.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace Tiku.Domain;
-
-public class Class1
-{
-
-}
diff --git a/Tiku.Domain/Common/Entity.cs b/Tiku.Domain/Common/Entity.cs
new file mode 100644
index 0000000..dac48d5
--- /dev/null
+++ b/Tiku.Domain/Common/Entity.cs
@@ -0,0 +1,46 @@
+using System.Text.Json;
+
+namespace Tiku.Domain.Common;
+
+public abstract class Entity
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+}
+
+public interface IHasTimestamps
+{
+ DateTimeOffset CreatedAt { get; set; }
+ DateTimeOffset UpdatedAt { get; set; }
+}
+
+public abstract class AuditableEntity : Entity, IHasTimestamps
+{
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+ public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
+}
+
+public abstract class TenantEntity : Entity
+{
+ public Guid TenantId { get; set; }
+}
+
+public abstract class AuditableTenantEntity : TenantEntity, IHasTimestamps
+{
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+ public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
+}
+
+public static class JsonDefaults
+{
+ public static JsonElement Object()
+ {
+ using var document = JsonDocument.Parse("{}");
+ return document.RootElement.Clone();
+ }
+
+ public static JsonElement Array()
+ {
+ using var document = JsonDocument.Parse("[]");
+ return document.RootElement.Clone();
+ }
+}
diff --git a/Tiku.Domain/Identity/StudentProfile.cs b/Tiku.Domain/Identity/StudentProfile.cs
new file mode 100644
index 0000000..ef2803b
--- /dev/null
+++ b/Tiku.Domain/Identity/StudentProfile.cs
@@ -0,0 +1,20 @@
+using System.Text.Json;
+using Tiku.Domain.Common;
+
+namespace Tiku.Domain.Identity;
+
+public sealed class StudentProfile : AuditableTenantEntity
+{
+ public Guid UserId { get; set; }
+ public string? LegacyUserId { get; set; }
+ public Guid? RegionId { get; set; }
+ public Guid? SelectedSchoolId { get; set; }
+ public Guid? SelectedMajorId { get; set; }
+ public int QuestionsAnsweredToday { get; set; }
+ public int MasteredWordsCount { get; set; }
+ public DateOnly? LastCheckInDate { get; set; }
+ public JsonElement Stats { get; set; } = JsonDefaults.Object();
+ public JsonElement Progress { get; set; } = JsonDefaults.Object();
+ public JsonElement ModuleSelections { get; set; } = JsonDefaults.Object();
+ public JsonElement RecentActivities { get; set; } = JsonDefaults.Array();
+}
diff --git a/Tiku.Domain/Identity/User.cs b/Tiku.Domain/Identity/User.cs
new file mode 100644
index 0000000..69c98e0
--- /dev/null
+++ b/Tiku.Domain/Identity/User.cs
@@ -0,0 +1,20 @@
+using System.Text.Json;
+using Tiku.Domain.Common;
+
+namespace Tiku.Domain.Identity;
+
+public sealed class User : AuditableEntity
+{
+ public string? LegacyId { get; set; }
+ public string? Username { get; set; }
+ public string? Email { get; set; }
+ public string? Phone { get; set; }
+ public string? Name { get; set; }
+ public string? AvatarUrl { get; set; }
+ public string PrimaryRole { get; set; } = "student";
+ public int Score { get; set; }
+ public DateTimeOffset? LastSeenAt { get; set; }
+ public string? LegacyPasswordHash { get; set; }
+ public bool PasswordMigrationRequired { get; set; }
+ public JsonElement RawProfile { get; set; } = JsonDefaults.Object();
+}
diff --git a/Tiku.Domain/Identity/UserIdentity.cs b/Tiku.Domain/Identity/UserIdentity.cs
new file mode 100644
index 0000000..e268546
--- /dev/null
+++ b/Tiku.Domain/Identity/UserIdentity.cs
@@ -0,0 +1,16 @@
+using System.Text.Json;
+using Tiku.Domain.Common;
+
+namespace Tiku.Domain.Identity;
+
+public sealed class UserIdentity : AuditableEntity
+{
+ public Guid UserId { get; set; }
+ public string Provider { get; set; } = string.Empty;
+ public string ProviderSubject { get; set; } = string.Empty;
+ public string? UnionId { get; set; }
+ public string? OpenId { get; set; }
+ public string? Phone { get; set; }
+ public string? Email { get; set; }
+ public JsonElement SecretPayload { get; set; } = JsonDefaults.Object();
+}
diff --git a/Tiku.Domain/Learning/LearningEntities.cs b/Tiku.Domain/Learning/LearningEntities.cs
new file mode 100644
index 0000000..e273261
--- /dev/null
+++ b/Tiku.Domain/Learning/LearningEntities.cs
@@ -0,0 +1,64 @@
+using System.Text.Json;
+using Tiku.Domain.Common;
+
+namespace Tiku.Domain.Learning;
+
+public sealed class PracticeSession : TenantEntity
+{
+ public Guid UserId { get; set; }
+ public string Mode { get; set; } = "chapter";
+ public string? TargetType { get; set; }
+ public Guid? TargetId { get; set; }
+ public DateTimeOffset StartedAt { get; set; } = DateTimeOffset.UtcNow;
+ public DateTimeOffset? FinishedAt { get; set; }
+ public JsonElement Metadata { get; set; } = JsonDefaults.Object();
+}
+
+public sealed class AnswerRecord : TenantEntity
+{
+ public Guid UserId { get; set; }
+ public Guid? QuestionId { get; set; }
+ public Guid? QuestionVersionId { get; set; }
+ public Guid? PracticeSessionId { get; set; }
+ public string? LegacyId { get; set; }
+ public string? LegacyQuestionId { get; set; }
+ public string? LegacyCategoryId { get; set; }
+ public JsonElement SelectedOptions { get; set; } = JsonDefaults.Array();
+ public string? AnswerText { get; set; }
+ public bool? IsCorrect { get; set; }
+ public DateTimeOffset AnsweredAt { get; set; } = DateTimeOffset.UtcNow;
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+}
+
+public sealed class FavoriteQuestion
+{
+ public Guid TenantId { get; set; }
+ public Guid UserId { get; set; }
+ public Guid QuestionId { get; set; }
+ public string Source { get; set; } = "imported";
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+}
+
+public sealed class WrongQuestion
+{
+ public Guid TenantId { get; set; }
+ public Guid UserId { get; set; }
+ public Guid QuestionId { get; set; }
+ public int WrongCount { get; set; } = 1;
+ public DateTimeOffset LastWrongAt { get; set; } = DateTimeOffset.UtcNow;
+ public DateTimeOffset? ResolvedAt { get; set; }
+}
+
+public sealed class RecentPractice : AuditableTenantEntity
+{
+ public Guid? UserId { get; set; }
+ public string? LegacyId { get; set; }
+ public string? PracticeType { get; set; }
+ public string? TargetLegacyId { get; set; }
+ public string? TargetName { get; set; }
+ public int Progress { get; set; }
+ public string? Color { get; set; }
+ public DateTimeOffset? LastAccessAt { get; set; }
+ public DateTimeOffset? LastPracticeAt { get; set; }
+ public JsonElement Metadata { get; set; } = JsonDefaults.Object();
+}
diff --git a/Tiku.Domain/QuestionBanks/QuestionEntities.cs b/Tiku.Domain/QuestionBanks/QuestionEntities.cs
new file mode 100644
index 0000000..183fcab
--- /dev/null
+++ b/Tiku.Domain/QuestionBanks/QuestionEntities.cs
@@ -0,0 +1,71 @@
+using System.Text.Json;
+using Tiku.Domain.Common;
+
+namespace Tiku.Domain.QuestionBanks;
+
+public sealed class QuestionBank : AuditableTenantEntity
+{
+ public Guid? RegionId { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public QuestionBankScope SourceScope { get; set; } = QuestionBankScope.Tenant;
+ public QuestionBankStatus Status { get; set; } = QuestionBankStatus.Active;
+ public JsonElement Metadata { get; set; } = JsonDefaults.Object();
+}
+
+public enum QuestionBankScope
+{
+ Platform,
+ Tenant
+}
+
+public enum QuestionBankStatus
+{
+ Active,
+ Archived
+}
+
+public sealed class Question : AuditableTenantEntity
+{
+ public Guid? QuestionBankId { get; set; }
+ public Guid? SubjectId { get; set; }
+ public Guid? CategoryId { get; set; }
+ public Guid? NodeId { get; set; }
+ public string? LegacyId { get; set; }
+ public string? LegacySubjectId { get; set; }
+ public string? LegacyCategoryId { get; set; }
+ public string? LegacyNodeId { get; set; }
+ public string Type { get; set; } = "choice";
+ public string? TypeLabel { get; set; }
+ public int? Difficulty { get; set; }
+ public JsonElement Tags { get; set; } = JsonDefaults.Array();
+ public string? MediaUrl { get; set; }
+ public bool HasVideoExplanation { get; set; }
+ public QuestionStatus Status { get; set; } = QuestionStatus.Published;
+ public Guid? CurrentVersionId { get; set; }
+}
+
+public enum QuestionStatus
+{
+ Draft,
+ Published,
+ Archived
+}
+
+public sealed class QuestionVersion : Entity
+{
+ public Guid TenantId { get; set; }
+ public Guid QuestionId { get; set; }
+ public int VersionNo { get; set; } = 1;
+ public string? Content { get; set; }
+ public JsonElement Options { get; set; } = JsonDefaults.Array();
+ public int? CorrectOptionIndex { get; set; }
+ public JsonElement CorrectOptionIndices { get; set; } = JsonDefaults.Array();
+ public string? AnswerText { get; set; }
+ public string? Explanation { get; set; }
+ public JsonElement SubQuestions { get; set; } = JsonDefaults.Array();
+ public string? CodeLang { get; set; }
+ public string? CodeTemplate { get; set; }
+ public string? SourceHash { get; set; }
+ public Guid? CreatedBy { get; set; }
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+}
diff --git a/Tiku.Domain/Tenancy/Tenant.cs b/Tiku.Domain/Tenancy/Tenant.cs
new file mode 100644
index 0000000..3454f2c
--- /dev/null
+++ b/Tiku.Domain/Tenancy/Tenant.cs
@@ -0,0 +1,40 @@
+using System.Text.Json;
+using Tiku.Domain.Common;
+
+namespace Tiku.Domain.Tenancy;
+
+public sealed class Tenant : AuditableEntity
+{
+ public string Slug { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+ public string? LegalName { get; set; }
+ public TenantStatus Status { get; set; } = TenantStatus.Active;
+ public TenantMode Mode { get; set; } = TenantMode.Saas;
+ public BillingStatus BillingStatus { get; set; } = BillingStatus.Trial;
+ public Guid? OwnerUserId { get; set; }
+ public string? LegacyId { get; set; }
+ public JsonElement Metadata { get; set; } = JsonDefaults.Object();
+}
+
+public enum TenantStatus
+{
+ Draft,
+ Active,
+ Suspended,
+ Archived
+}
+
+public enum TenantMode
+{
+ PlatformOwned,
+ Saas,
+ Dedicated
+}
+
+public enum BillingStatus
+{
+ Trial,
+ Active,
+ PastDue,
+ Cancelled
+}
diff --git a/Tiku.Domain/Tenancy/TenantMembership.cs b/Tiku.Domain/Tenancy/TenantMembership.cs
new file mode 100644
index 0000000..224fbeb
--- /dev/null
+++ b/Tiku.Domain/Tenancy/TenantMembership.cs
@@ -0,0 +1,32 @@
+using System.Text.Json;
+using Tiku.Domain.Common;
+
+namespace Tiku.Domain.Tenancy;
+
+public sealed class TenantMembership : AuditableTenantEntity
+{
+ public Guid UserId { get; set; }
+ public TenantRole Role { get; set; } = TenantRole.Student;
+ public MembershipStatus Status { get; set; } = MembershipStatus.Active;
+ public JsonElement Permissions { get; set; } = JsonDefaults.Object();
+ public string? LegacyRole { get; set; }
+}
+
+public enum TenantRole
+{
+ PlatformAdmin,
+ TenantOwner,
+ TenantAdmin,
+ TenantOperator,
+ Teacher,
+ Sales,
+ Agent,
+ Student
+}
+
+public enum MembershipStatus
+{
+ Active,
+ Invited,
+ Disabled
+}
diff --git a/Tiku.Infrastructure/Class1.cs b/Tiku.Infrastructure/Class1.cs
deleted file mode 100644
index 631e294..0000000
--- a/Tiku.Infrastructure/Class1.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-namespace Tiku.Infrastructure;
-
-public class Class1
-{
-
-}
diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs
new file mode 100644
index 0000000..3026ec7
--- /dev/null
+++ b/Tiku.Infrastructure/DependencyInjection.cs
@@ -0,0 +1,26 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Npgsql;
+using Tiku.Infrastructure.Persistence;
+
+namespace Tiku.Infrastructure;
+
+public static class DependencyInjection
+{
+ public static IServiceCollection AddInfrastructure(
+ this IServiceCollection services,
+ string connectionString)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
+
+ services.AddSingleton(_ => NpgsqlDataSource.Create(connectionString));
+ services.AddDbContextPool((serviceProvider, options) =>
+ {
+ var dataSource = serviceProvider.GetRequiredService();
+ options.UseNpgsql(dataSource, npgsql =>
+ npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName));
+ });
+
+ return services;
+ }
+}
diff --git a/Tiku.Infrastructure/Persistence/Configurations/CatalogConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/CatalogConfigurations.cs
new file mode 100644
index 0000000..357ef0e
--- /dev/null
+++ b/Tiku.Infrastructure/Persistence/Configurations/CatalogConfigurations.cs
@@ -0,0 +1,187 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using Tiku.Domain.Catalog;
+
+namespace Tiku.Infrastructure.Persistence.Configurations;
+
+internal sealed class RegionConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ConfigureTenantEntity("regions");
+ builder.ConfigureTimestamps();
+ builder.Property(entity => entity.LegacyId).HasMaxLength(64);
+ builder.Property(entity => entity.Name).HasMaxLength(200);
+ builder.Property(entity => entity.Code).HasMaxLength(50);
+ builder.Property(entity => entity.ShortName).HasMaxLength(100);
+ builder.Property(entity => entity.FullName).HasMaxLength(300);
+ builder.Property(entity => entity.Icon).HasMaxLength(2048);
+ builder.Property(entity => entity.Pinyin).HasMaxLength(255);
+ builder.Property(entity => entity.Config).IsJson("{}");
+ builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
+ }
+}
+
+internal sealed class RegionModuleConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ConfigureTenantEntity("region_modules");
+ builder.ConfigureTimestamps();
+ builder.Property(entity => entity.LegacyId).HasMaxLength(64);
+ builder.Property(entity => entity.Name).HasMaxLength(200);
+ builder.Property(entity => entity.Type).HasMaxLength(50);
+ builder.Property(entity => entity.Icon).HasMaxLength(2048);
+ builder.Property(entity => entity.Color).HasMaxLength(50);
+ builder.Property(entity => entity.TextColor).HasMaxLength(50);
+ builder.Property(entity => entity.Route).HasMaxLength(500);
+ builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
+
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
+
+internal sealed class ModuleNodeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ConfigureTenantEntity("module_nodes");
+ builder.ConfigureTimestamps();
+ builder.Property(entity => entity.LegacyId).HasMaxLength(64);
+ builder.Property(entity => entity.LegacyParentId).HasMaxLength(64);
+ builder.Property(entity => entity.LegacyModuleId).HasMaxLength(64);
+ builder.Property(entity => entity.Type).HasSnakeCaseEnum();
+ builder.Property(entity => entity.Name).HasMaxLength(300);
+ builder.Property(entity => entity.Path).HasMaxLength(1000);
+ builder.Property(entity => entity.Metadata).IsJson("{}");
+ builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
+
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.ModuleId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.ParentId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
+
+internal sealed class SchoolConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ConfigureTenantEntity("schools");
+ builder.ConfigureTimestamps();
+ builder.Property(entity => entity.LegacyId).HasMaxLength(64);
+ builder.Property(entity => entity.Name).HasMaxLength(300);
+ builder.Property(entity => entity.ProfessionalExamDate).HasMaxLength(255);
+ builder.Property(entity => entity.Metadata).IsJson("{}");
+ builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
+
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.ModuleId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
+
+internal sealed class MajorConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ConfigureTenantEntity("majors");
+ builder.ConfigureTimestamps();
+ builder.Property(entity => entity.LegacyId).HasMaxLength(64);
+ builder.Property(entity => entity.Name).HasMaxLength(300);
+ builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
+
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.SchoolId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
+
+internal sealed class SubjectConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ConfigureTenantEntity("subjects");
+ builder.ConfigureTimestamps();
+ builder.Property(entity => entity.LegacyId).HasMaxLength(64);
+ builder.Property(entity => entity.Name).HasMaxLength(300);
+ builder.Property(entity => entity.Type).HasNullableSnakeCaseEnum();
+ builder.Property(entity => entity.MajorLegacyIds).IsJson("[]");
+ builder.Property(entity => entity.Icon).HasMaxLength(2048);
+ builder.Property(entity => entity.Stats).IsJson("{}");
+ builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
+
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.ModuleId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.SchoolId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.MajorId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.NodeId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+
+}
+
+internal sealed class CategoryConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ConfigureTenantEntity("categories");
+ builder.ConfigureTimestamps();
+ builder.Property(entity => entity.LegacyId).HasMaxLength(64);
+ builder.Property(entity => entity.Name).HasMaxLength(300);
+ builder.Property(entity => entity.CategoryType).HasNullableSnakeCaseEnum();
+ builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
+
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.SubjectId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.NodeId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
diff --git a/Tiku.Infrastructure/Persistence/Configurations/ConfigurationSupport.cs b/Tiku.Infrastructure/Persistence/Configurations/ConfigurationSupport.cs
new file mode 100644
index 0000000..edf9655
--- /dev/null
+++ b/Tiku.Infrastructure/Persistence/Configurations/ConfigurationSupport.cs
@@ -0,0 +1,75 @@
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using Tiku.Domain.Common;
+using Tiku.Domain.Tenancy;
+
+namespace Tiku.Infrastructure.Persistence.Configurations;
+
+internal static class ConfigurationSupport
+{
+ public static void ConfigureEntity(
+ this EntityTypeBuilder builder,
+ string tableName)
+ where TEntity : Entity
+ {
+ builder.ToTable(tableName);
+ builder.HasKey(entity => entity.Id);
+ builder.Property(entity => entity.Id).HasDefaultValueSql("gen_random_uuid()");
+ }
+
+ public static void ConfigureTenantEntity(
+ this EntityTypeBuilder builder,
+ string tableName)
+ where TEntity : TenantEntity
+ {
+ builder.ConfigureEntity(tableName);
+ builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(entity => entity.TenantId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+
+ public static void ConfigureTimestamps(this EntityTypeBuilder builder)
+ where TEntity : class, IHasTimestamps
+ {
+ builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
+ builder.Property(entity => entity.UpdatedAt).HasDefaultValueSql("now()");
+ }
+
+ public static PropertyBuilder IsJson(
+ this PropertyBuilder property,
+ string defaultJson)
+ {
+ return property
+ .HasColumnType("jsonb")
+ .HasDefaultValueSql($"'{defaultJson}'::jsonb");
+ }
+
+ public static PropertyBuilder HasSnakeCaseEnum(
+ this PropertyBuilder property,
+ int maxLength = 32)
+ where TEnum : struct, Enum
+ {
+ return property
+ .HasConversion(new SnakeCaseEnumConverter())
+ .HasMaxLength(maxLength);
+ }
+
+ public static PropertyBuilder HasNullableSnakeCaseEnum(
+ this PropertyBuilder property,
+ int maxLength = 32)
+ where TEnum : struct, Enum
+ {
+ return property
+ .HasConversion(
+ value => value.HasValue
+ ? ModelBuilderExtensions.ToSnakeCase(value.Value.ToString())
+ : null,
+ value => string.IsNullOrWhiteSpace(value)
+ ? null
+ : Enum.Parse(value.Replace("_", string.Empty), true))
+ .HasMaxLength(maxLength);
+ }
+}
diff --git a/Tiku.Infrastructure/Persistence/Configurations/IdentityConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/IdentityConfigurations.cs
new file mode 100644
index 0000000..fc64c1f
--- /dev/null
+++ b/Tiku.Infrastructure/Persistence/Configurations/IdentityConfigurations.cs
@@ -0,0 +1,90 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using Tiku.Domain.Catalog;
+using Tiku.Domain.Identity;
+
+namespace Tiku.Infrastructure.Persistence.Configurations;
+
+internal sealed class UserConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ConfigureEntity("users");
+ builder.ConfigureTimestamps();
+
+ builder.Property(entity => entity.LegacyId).HasMaxLength(64);
+ builder.Property(entity => entity.Username).HasMaxLength(100);
+ builder.Property(entity => entity.Email).HasColumnType("citext").HasMaxLength(320);
+ builder.Property(entity => entity.Phone).HasMaxLength(32);
+ builder.Property(entity => entity.Name).HasMaxLength(200);
+ builder.Property(entity => entity.AvatarUrl).HasMaxLength(2048);
+ builder.Property(entity => entity.PrimaryRole).HasMaxLength(50);
+ builder.Property(entity => entity.LegacyPasswordHash).HasMaxLength(512);
+ builder.Property(entity => entity.RawProfile).IsJson("{}");
+
+ builder.HasIndex(entity => entity.LegacyId).IsUnique();
+ builder.HasIndex(entity => entity.Username).IsUnique();
+ builder.HasIndex(entity => entity.Email).IsUnique();
+ builder.HasIndex(entity => entity.Phone).IsUnique();
+ }
+}
+
+internal sealed class UserIdentityConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ConfigureEntity("user_identities");
+ builder.ConfigureTimestamps();
+
+ builder.Property(entity => entity.Provider).HasMaxLength(50);
+ builder.Property(entity => entity.ProviderSubject).HasMaxLength(255);
+ builder.Property(entity => entity.UnionId).HasMaxLength(255);
+ builder.Property(entity => entity.OpenId).HasMaxLength(255);
+ builder.Property(entity => entity.Phone).HasMaxLength(32);
+ builder.Property(entity => entity.Email).HasColumnType("citext").HasMaxLength(320);
+ builder.Property(entity => entity.SecretPayload).IsJson("{}");
+
+ builder.HasIndex(entity => new { entity.Provider, entity.ProviderSubject }).IsUnique();
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(entity => entity.UserId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
+
+internal sealed class StudentProfileConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ConfigureTenantEntity("student_profiles");
+ builder.ConfigureTimestamps();
+
+ builder.Property(entity => entity.LegacyUserId).HasMaxLength(64);
+ builder.Property(entity => entity.Stats).IsJson("{}");
+ builder.Property(entity => entity.Progress).IsJson("{}");
+ builder.Property(entity => entity.ModuleSelections).IsJson("{}");
+ builder.Property(entity => entity.RecentActivities).IsJson("[]");
+
+ builder.HasIndex(entity => new { entity.TenantId, entity.UserId }).IsUnique();
+
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(entity => entity.UserId)
+ .OnDelete(DeleteBehavior.Cascade);
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.SelectedSchoolId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.SelectedMajorId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
diff --git a/Tiku.Infrastructure/Persistence/Configurations/LearningConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/LearningConfigurations.cs
new file mode 100644
index 0000000..ff85448
--- /dev/null
+++ b/Tiku.Infrastructure/Persistence/Configurations/LearningConfigurations.cs
@@ -0,0 +1,159 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using Tiku.Domain.Identity;
+using Tiku.Domain.Learning;
+using Tiku.Domain.QuestionBanks;
+using Tiku.Domain.Tenancy;
+
+namespace Tiku.Infrastructure.Persistence.Configurations;
+
+internal sealed class PracticeSessionConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ConfigureTenantEntity("practice_sessions");
+ builder.HasAlternateKey(entity => new { entity.TenantId, entity.UserId, entity.Id });
+ 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.Metadata).IsJson("{}");
+ builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.StartedAt });
+
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => entity.UserId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
+
+internal sealed class AnswerRecordConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ConfigureTenantEntity("answer_records");
+ builder.Property(entity => entity.LegacyId).HasMaxLength(64);
+ builder.Property(entity => entity.LegacyQuestionId).HasMaxLength(64);
+ builder.Property(entity => entity.LegacyCategoryId).HasMaxLength(64);
+ builder.Property(entity => entity.SelectedOptions).IsJson("[]");
+ 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,
+ entity.UserId,
+ 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().WithMany()
+ .HasForeignKey(entity => entity.UserId)
+ .OnDelete(DeleteBehavior.Cascade);
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => new
+ {
+ entity.TenantId,
+ entity.QuestionId,
+ entity.QuestionVersionId
+ })
+ .HasPrincipalKey(entity => new
+ {
+ entity.TenantId,
+ entity.QuestionId,
+ entity.Id
+ })
+ .OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => new
+ {
+ entity.TenantId,
+ entity.UserId,
+ entity.PracticeSessionId
+ })
+ .HasPrincipalKey(entity => new
+ {
+ entity.TenantId,
+ entity.UserId,
+ entity.Id
+ })
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
+
+internal sealed class FavoriteQuestionConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("favorite_questions");
+ builder.HasKey(entity => new { entity.TenantId, entity.UserId, entity.QuestionId });
+ builder.Property(entity => entity.Source).HasMaxLength(50);
+ builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
+
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => entity.TenantId)
+ .OnDelete(DeleteBehavior.Cascade);
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => entity.UserId)
+ .OnDelete(DeleteBehavior.Cascade);
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
+
+internal sealed class WrongQuestionConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("wrong_questions");
+ builder.HasKey(entity => new { entity.TenantId, entity.UserId, entity.QuestionId });
+ builder.Property(entity => entity.WrongCount).HasDefaultValue(1);
+ builder.Property(entity => entity.LastWrongAt).HasDefaultValueSql("now()");
+
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => entity.TenantId)
+ .OnDelete(DeleteBehavior.Cascade);
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => entity.UserId)
+ .OnDelete(DeleteBehavior.Cascade);
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
+
+internal sealed class RecentPracticeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ConfigureTenantEntity("recent_practices");
+ builder.ConfigureTimestamps();
+ builder.Property(entity => entity.LegacyId).HasMaxLength(64);
+ builder.Property(entity => entity.PracticeType).HasMaxLength(50);
+ builder.Property(entity => entity.TargetLegacyId).HasMaxLength(64);
+ builder.Property(entity => entity.TargetName).HasMaxLength(300);
+ builder.Property(entity => entity.Color).HasMaxLength(50);
+ builder.Property(entity => entity.Metadata).IsJson("{}");
+ builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
+ builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.LastAccessAt });
+
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => entity.UserId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
diff --git a/Tiku.Infrastructure/Persistence/Configurations/QuestionConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/QuestionConfigurations.cs
new file mode 100644
index 0000000..6f2a629
--- /dev/null
+++ b/Tiku.Infrastructure/Persistence/Configurations/QuestionConfigurations.cs
@@ -0,0 +1,92 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using Tiku.Domain.Catalog;
+using Tiku.Domain.Identity;
+using Tiku.Domain.QuestionBanks;
+
+namespace Tiku.Infrastructure.Persistence.Configurations;
+
+internal sealed class QuestionBankConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ 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("{}");
+
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
+
+internal sealed class QuestionConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ConfigureTenantEntity("questions");
+ builder.ConfigureTimestamps();
+ builder.Property(entity => entity.LegacyId).HasMaxLength(64);
+ builder.Property(entity => entity.LegacySubjectId).HasMaxLength(64);
+ builder.Property(entity => entity.LegacyCategoryId).HasMaxLength(64);
+ builder.Property(entity => entity.LegacyNodeId).HasMaxLength(64);
+ builder.Property(entity => entity.Type).HasMaxLength(50);
+ builder.Property(entity => entity.TypeLabel).HasMaxLength(100);
+ builder.Property(entity => entity.Tags).IsJson("[]");
+ builder.Property(entity => entity.MediaUrl).HasMaxLength(2048);
+ builder.Property(entity => entity.Status).HasSnakeCaseEnum();
+ builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
+
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.QuestionBankId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.SubjectId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.CategoryId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.NodeId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.Restrict);
+
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.Id, entity.CurrentVersionId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.QuestionId, entity.Id })
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
+
+internal sealed class QuestionVersionConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ConfigureEntity("question_versions");
+ builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
+ builder.HasAlternateKey(entity => new { entity.TenantId, entity.QuestionId, entity.Id });
+
+ builder.Property(entity => entity.Options).IsJson("[]");
+ builder.Property(entity => entity.CorrectOptionIndices).IsJson("[]");
+ builder.Property(entity => entity.SubQuestions).IsJson("[]");
+ builder.Property(entity => entity.CodeLang).HasMaxLength(50);
+ builder.Property(entity => entity.SourceHash).HasMaxLength(128);
+ builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
+
+ builder.HasIndex(entity => new { entity.QuestionId, entity.VersionNo }).IsUnique();
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
+ .HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
+ .OnDelete(DeleteBehavior.Cascade);
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => entity.CreatedBy)
+ .OnDelete(DeleteBehavior.SetNull);
+ }
+}
diff --git a/Tiku.Infrastructure/Persistence/Configurations/TenancyConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/TenancyConfigurations.cs
new file mode 100644
index 0000000..d7cb689
--- /dev/null
+++ b/Tiku.Infrastructure/Persistence/Configurations/TenancyConfigurations.cs
@@ -0,0 +1,53 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using Tiku.Domain.Identity;
+using Tiku.Domain.Tenancy;
+
+namespace Tiku.Infrastructure.Persistence.Configurations;
+
+internal sealed class TenantConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ConfigureEntity("tenants");
+ builder.ConfigureTimestamps();
+
+ builder.Property(entity => entity.Slug).HasColumnType("citext").HasMaxLength(100);
+ builder.Property(entity => entity.Name).HasMaxLength(200);
+ builder.Property(entity => entity.LegalName).HasMaxLength(300);
+ builder.Property(entity => entity.Status).HasSnakeCaseEnum();
+ builder.Property(entity => entity.Mode).HasSnakeCaseEnum();
+ builder.Property(entity => entity.BillingStatus).HasSnakeCaseEnum();
+ builder.Property(entity => entity.LegacyId).HasMaxLength(64);
+ builder.Property(entity => entity.Metadata).IsJson("{}");
+
+ builder.HasIndex(entity => entity.Slug).IsUnique();
+ builder.HasIndex(entity => entity.LegacyId).IsUnique();
+
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(entity => entity.OwnerUserId)
+ .OnDelete(DeleteBehavior.SetNull);
+ }
+}
+
+internal sealed class TenantMembershipConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ConfigureTenantEntity("tenant_memberships");
+ builder.ConfigureTimestamps();
+
+ builder.Property(entity => entity.Role).HasSnakeCaseEnum();
+ builder.Property(entity => entity.Status).HasSnakeCaseEnum();
+ builder.Property(entity => entity.Permissions).IsJson("{}");
+ builder.Property(entity => entity.LegacyRole).HasMaxLength(50);
+
+ builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.Role }).IsUnique();
+
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(entity => entity.UserId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
diff --git a/Tiku.Infrastructure/Persistence/Migrations/20260725205004_InitialCore.Designer.cs b/Tiku.Infrastructure/Persistence/Migrations/20260725205004_InitialCore.Designer.cs
new file mode 100644
index 0000000..3083ab8
--- /dev/null
+++ b/Tiku.Infrastructure/Persistence/Migrations/20260725205004_InitialCore.Designer.cs
@@ -0,0 +1,2176 @@
+//
+using System;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Tiku.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace Tiku.Infrastructure.Persistence.Migrations
+{
+ [DbContext(typeof(TikuDbContext))]
+ [Migration("20260725205004_InitialCore")]
+ partial class InitialCore
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext");
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.Category", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CategoryType")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("category_type");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("name");
+
+ b.Property("NodeId")
+ .HasColumnType("uuid")
+ .HasColumnName("node_id");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("SubjectId")
+ .HasColumnType("uuid")
+ .HasColumnName("subject_id");
+
+ b.Property("SvipQuestionLimit")
+ .HasColumnType("integer")
+ .HasColumnName("svip_question_limit");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_categories");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_categories_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_categories_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "NodeId")
+ .HasDatabaseName("ix_categories_tenant_id_node_id");
+
+ b.HasIndex("TenantId", "SubjectId")
+ .HasDatabaseName("ix_categories_tenant_id_subject_id");
+
+ b.ToTable("categories", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.Major", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("name");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("SchoolId")
+ .HasColumnType("uuid")
+ .HasColumnName("school_id");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("StudyTips")
+ .HasColumnType("text")
+ .HasColumnName("study_tips");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_majors");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_majors_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_majors_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "RegionId")
+ .HasDatabaseName("ix_majors_tenant_id_region_id");
+
+ b.HasIndex("TenantId", "SchoolId")
+ .HasDatabaseName("ix_majors_tenant_id_school_id");
+
+ b.ToTable("majors", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.ModuleNode", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("LegacyModuleId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_module_id");
+
+ b.Property("LegacyParentId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_parent_id");
+
+ b.Property("Metadata")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("metadata")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("ModuleId")
+ .HasColumnType("uuid")
+ .HasColumnName("module_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("name");
+
+ b.Property("ParentId")
+ .HasColumnType("uuid")
+ .HasColumnName("parent_id");
+
+ b.Property("Path")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)")
+ .HasColumnName("path");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("type");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_module_nodes");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_module_nodes_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_module_nodes_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "ModuleId")
+ .HasDatabaseName("ix_module_nodes_tenant_id_module_id");
+
+ b.HasIndex("TenantId", "ParentId")
+ .HasDatabaseName("ix_module_nodes_tenant_id_parent_id");
+
+ b.HasIndex("TenantId", "RegionId")
+ .HasDatabaseName("ix_module_nodes_tenant_id_region_id");
+
+ b.ToTable("module_nodes", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.Region", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("Code")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("code");
+
+ b.Property("Config")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("config")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("FullName")
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("full_name");
+
+ b.Property("Icon")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)")
+ .HasColumnName("icon");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("IsHot")
+ .HasColumnType("boolean")
+ .HasColumnName("is_hot");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("name");
+
+ b.Property("Pinyin")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("pinyin");
+
+ b.Property("ShortName")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("short_name");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_regions");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_regions_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_regions_tenant_id_legacy_id");
+
+ b.ToTable("regions", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.RegionModule", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("Color")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("color");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("Icon")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)")
+ .HasColumnName("icon");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("IsPrimarySchoolModule")
+ .HasColumnType("boolean")
+ .HasColumnName("is_primary_school_module");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("name");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("Route")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("route");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("TextColor")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("text_color");
+
+ b.Property("Type")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("type");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_region_modules");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_region_modules_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_region_modules_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "RegionId")
+ .HasDatabaseName("ix_region_modules_tenant_id_region_id");
+
+ b.ToTable("region_modules", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.School", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("Metadata")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("metadata")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("ModuleId")
+ .HasColumnType("uuid")
+ .HasColumnName("module_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("name");
+
+ b.Property("ProfessionalExamDate")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("professional_exam_date");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_schools");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_schools_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_schools_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "ModuleId")
+ .HasDatabaseName("ix_schools_tenant_id_module_id");
+
+ b.HasIndex("TenantId", "RegionId")
+ .HasDatabaseName("ix_schools_tenant_id_region_id");
+
+ b.ToTable("schools", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.Subject", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("Icon")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)")
+ .HasColumnName("icon");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("MajorId")
+ .HasColumnType("uuid")
+ .HasColumnName("major_id");
+
+ b.Property("MajorLegacyIds")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("major_legacy_ids")
+ .HasDefaultValueSql("'[]'::jsonb");
+
+ b.Property("ModuleId")
+ .HasColumnType("uuid")
+ .HasColumnName("module_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("name");
+
+ b.Property("NodeId")
+ .HasColumnType("uuid")
+ .HasColumnName("node_id");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("SchoolId")
+ .HasColumnType("uuid")
+ .HasColumnName("school_id");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("Stats")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("stats")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("Type")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("type");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_subjects");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_subjects_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_subjects_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "MajorId")
+ .HasDatabaseName("ix_subjects_tenant_id_major_id");
+
+ b.HasIndex("TenantId", "ModuleId")
+ .HasDatabaseName("ix_subjects_tenant_id_module_id");
+
+ b.HasIndex("TenantId", "NodeId")
+ .HasDatabaseName("ix_subjects_tenant_id_node_id");
+
+ b.HasIndex("TenantId", "RegionId")
+ .HasDatabaseName("ix_subjects_tenant_id_region_id");
+
+ b.HasIndex("TenantId", "SchoolId")
+ .HasDatabaseName("ix_subjects_tenant_id_school_id");
+
+ b.ToTable("subjects", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Identity.StudentProfile", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("LastCheckInDate")
+ .HasColumnType("date")
+ .HasColumnName("last_check_in_date");
+
+ b.Property("LegacyUserId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_user_id");
+
+ b.Property("MasteredWordsCount")
+ .HasColumnType("integer")
+ .HasColumnName("mastered_words_count");
+
+ b.Property("ModuleSelections")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("module_selections")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("Progress")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("progress")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("QuestionsAnsweredToday")
+ .HasColumnType("integer")
+ .HasColumnName("questions_answered_today");
+
+ b.Property("RecentActivities")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("recent_activities")
+ .HasDefaultValueSql("'[]'::jsonb");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("SelectedMajorId")
+ .HasColumnType("uuid")
+ .HasColumnName("selected_major_id");
+
+ b.Property("SelectedSchoolId")
+ .HasColumnType("uuid")
+ .HasColumnName("selected_school_id");
+
+ b.Property("Stats")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("stats")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_student_profiles");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_student_profiles_tenant_id_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_student_profiles_user_id");
+
+ b.HasIndex("TenantId", "RegionId")
+ .HasDatabaseName("ix_student_profiles_tenant_id_region_id");
+
+ b.HasIndex("TenantId", "SelectedMajorId")
+ .HasDatabaseName("ix_student_profiles_tenant_id_selected_major_id");
+
+ b.HasIndex("TenantId", "SelectedSchoolId")
+ .HasDatabaseName("ix_student_profiles_tenant_id_selected_school_id");
+
+ b.HasIndex("TenantId", "UserId")
+ .IsUnique()
+ .HasDatabaseName("ix_student_profiles_tenant_id_user_id");
+
+ b.ToTable("student_profiles", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Identity.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("AvatarUrl")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)")
+ .HasColumnName("avatar_url");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Email")
+ .HasMaxLength(320)
+ .HasColumnType("citext")
+ .HasColumnName("email");
+
+ b.Property("LastSeenAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_seen_at");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("LegacyPasswordHash")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasColumnName("legacy_password_hash");
+
+ b.Property("Name")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("name");
+
+ b.Property("PasswordMigrationRequired")
+ .HasColumnType("boolean")
+ .HasColumnName("password_migration_required");
+
+ b.Property("Phone")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("phone");
+
+ b.Property("PrimaryRole")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("primary_role");
+
+ b.Property("RawProfile")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("raw_profile")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("Score")
+ .HasColumnType("integer")
+ .HasColumnName("score");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Username")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("username");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.HasIndex("Email")
+ .IsUnique()
+ .HasDatabaseName("ix_users_email");
+
+ b.HasIndex("LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_users_legacy_id");
+
+ b.HasIndex("Phone")
+ .IsUnique()
+ .HasDatabaseName("ix_users_phone");
+
+ b.HasIndex("Username")
+ .IsUnique()
+ .HasDatabaseName("ix_users_username");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Identity.UserIdentity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Email")
+ .HasMaxLength(320)
+ .HasColumnType("citext")
+ .HasColumnName("email");
+
+ b.Property("OpenId")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("open_id");
+
+ b.Property("Phone")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("phone");
+
+ b.Property("Provider")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("provider");
+
+ b.Property("ProviderSubject")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("provider_subject");
+
+ b.Property("SecretPayload")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("secret_payload")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("UnionId")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("union_id");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_user_identities");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_user_identities_user_id");
+
+ b.HasIndex("Provider", "ProviderSubject")
+ .IsUnique()
+ .HasDatabaseName("ix_user_identities_provider_provider_subject");
+
+ b.ToTable("user_identities", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Learning.AnswerRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("AnswerText")
+ .HasColumnType("text")
+ .HasColumnName("answer_text");
+
+ b.Property("AnsweredAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("answered_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("IsCorrect")
+ .HasColumnType("boolean")
+ .HasColumnName("is_correct");
+
+ b.Property("LegacyCategoryId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_category_id");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property