feat: add core persistence model
This commit is contained in:
@@ -11,9 +11,18 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageVersion>
|
</PackageVersion>
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
|
||||||
|
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
|
||||||
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageVersion>
|
||||||
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||||
<PackageVersion Include="Microsoft.OpenApi" Version="2.11.0" />
|
<PackageVersion Include="Microsoft.OpenApi" Version="2.11.0" />
|
||||||
|
<PackageVersion Include="Npgsql" Version="10.0.3" />
|
||||||
|
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||||
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
|
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
|
||||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4">
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4">
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ var summaries = new[]
|
|||||||
|
|
||||||
app.MapGet("/weatherforecast", () =>
|
app.MapGet("/weatherforecast", () =>
|
||||||
{
|
{
|
||||||
var forecast = Enumerable.Range(1, 5).Select(index =>
|
var forecast = Enumerable.Range(1, 5).Select(index =>
|
||||||
new WeatherForecast
|
new WeatherForecast
|
||||||
(
|
(
|
||||||
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
|
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace Tiku.Application;
|
|
||||||
|
|
||||||
public class Class1
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
22
Tiku.DbMigrator/DesignTimeTikuDbContextFactory.cs
Normal file
22
Tiku.DbMigrator/DesignTimeTikuDbContextFactory.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace Tiku.DbMigrator;
|
||||||
|
|
||||||
|
public sealed class DesignTimeTikuDbContextFactory : IDesignTimeDbContextFactory<TikuDbContext>
|
||||||
|
{
|
||||||
|
public TikuDbContext CreateDbContext(string[] args)
|
||||||
|
{
|
||||||
|
var connectionString =
|
||||||
|
Environment.GetEnvironmentVariable("DATABASE_URL") ??
|
||||||
|
"Host=localhost;Database=tiku;Username=postgres";
|
||||||
|
|
||||||
|
var options = new DbContextOptionsBuilder<TikuDbContext>()
|
||||||
|
.UseNpgsql(connectionString, npgsql =>
|
||||||
|
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName))
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
return new TikuDbContext(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,20 @@
|
|||||||
// See https://aka.ms/new-console-template for more information
|
using Microsoft.EntityFrameworkCore;
|
||||||
Console.WriteLine("Hello, World!");
|
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<TikuDbContext>();
|
||||||
|
await dbContext.Database.MigrateAsync();
|
||||||
|
|||||||
@@ -4,6 +4,14 @@
|
|||||||
<ProjectReference Include="..\Tiku.Infrastructure\Tiku.Infrastructure.csproj" />
|
<ProjectReference Include="..\Tiku.Infrastructure\Tiku.Infrastructure.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||||
|
<IncludeAssets>all</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
|||||||
126
Tiku.Domain/Catalog/CatalogEntities.cs
Normal file
126
Tiku.Domain/Catalog/CatalogEntities.cs
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace Tiku.Domain;
|
|
||||||
|
|
||||||
public class Class1
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
46
Tiku.Domain/Common/Entity.cs
Normal file
46
Tiku.Domain/Common/Entity.cs
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
20
Tiku.Domain/Identity/StudentProfile.cs
Normal file
20
Tiku.Domain/Identity/StudentProfile.cs
Normal file
@@ -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();
|
||||||
|
}
|
||||||
20
Tiku.Domain/Identity/User.cs
Normal file
20
Tiku.Domain/Identity/User.cs
Normal file
@@ -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();
|
||||||
|
}
|
||||||
16
Tiku.Domain/Identity/UserIdentity.cs
Normal file
16
Tiku.Domain/Identity/UserIdentity.cs
Normal file
@@ -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();
|
||||||
|
}
|
||||||
64
Tiku.Domain/Learning/LearningEntities.cs
Normal file
64
Tiku.Domain/Learning/LearningEntities.cs
Normal file
@@ -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();
|
||||||
|
}
|
||||||
71
Tiku.Domain/QuestionBanks/QuestionEntities.cs
Normal file
71
Tiku.Domain/QuestionBanks/QuestionEntities.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
40
Tiku.Domain/Tenancy/Tenant.cs
Normal file
40
Tiku.Domain/Tenancy/Tenant.cs
Normal file
@@ -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
|
||||||
|
}
|
||||||
32
Tiku.Domain/Tenancy/TenantMembership.cs
Normal file
32
Tiku.Domain/Tenancy/TenantMembership.cs
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace Tiku.Infrastructure;
|
|
||||||
|
|
||||||
public class Class1
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
26
Tiku.Infrastructure/DependencyInjection.cs
Normal file
26
Tiku.Infrastructure/DependencyInjection.cs
Normal file
@@ -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<TikuDbContext>((serviceProvider, options) =>
|
||||||
|
{
|
||||||
|
var dataSource = serviceProvider.GetRequiredService<NpgsqlDataSource>();
|
||||||
|
options.UseNpgsql(dataSource, npgsql =>
|
||||||
|
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName));
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Region>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Region> 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<RegionModule>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<RegionModule> 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<Region>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class ModuleNodeConfiguration : IEntityTypeConfiguration<ModuleNode>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<ModuleNode> 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<Region>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne<RegionModule>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.ModuleId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne<ModuleNode>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.ParentId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SchoolConfiguration : IEntityTypeConfiguration<School>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<School> 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<Region>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne<RegionModule>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.ModuleId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class MajorConfiguration : IEntityTypeConfiguration<Major>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Major> 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<Region>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne<School>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.SchoolId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SubjectConfiguration : IEntityTypeConfiguration<Subject>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Subject> 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<Region>().WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne<RegionModule>().WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.ModuleId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne<School>().WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.SchoolId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne<Major>().WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.MajorId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne<ModuleNode>().WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.NodeId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class CategoryConfiguration : IEntityTypeConfiguration<Category>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Category> 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<Subject>().WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.SubjectId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne<ModuleNode>().WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.NodeId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TEntity>(
|
||||||
|
this EntityTypeBuilder<TEntity> 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<TEntity>(
|
||||||
|
this EntityTypeBuilder<TEntity> builder,
|
||||||
|
string tableName)
|
||||||
|
where TEntity : TenantEntity
|
||||||
|
{
|
||||||
|
builder.ConfigureEntity(tableName);
|
||||||
|
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
|
||||||
|
builder.HasOne<Tenant>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(entity => entity.TenantId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ConfigureTimestamps<TEntity>(this EntityTypeBuilder<TEntity> builder)
|
||||||
|
where TEntity : class, IHasTimestamps
|
||||||
|
{
|
||||||
|
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
|
||||||
|
builder.Property(entity => entity.UpdatedAt).HasDefaultValueSql("now()");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PropertyBuilder<JsonElement> IsJson(
|
||||||
|
this PropertyBuilder<JsonElement> property,
|
||||||
|
string defaultJson)
|
||||||
|
{
|
||||||
|
return property
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasDefaultValueSql($"'{defaultJson}'::jsonb");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PropertyBuilder<TEnum> HasSnakeCaseEnum<TEnum>(
|
||||||
|
this PropertyBuilder<TEnum> property,
|
||||||
|
int maxLength = 32)
|
||||||
|
where TEnum : struct, Enum
|
||||||
|
{
|
||||||
|
return property
|
||||||
|
.HasConversion(new SnakeCaseEnumConverter<TEnum>())
|
||||||
|
.HasMaxLength(maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PropertyBuilder<TEnum?> HasNullableSnakeCaseEnum<TEnum>(
|
||||||
|
this PropertyBuilder<TEnum?> 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<TEnum>(value.Replace("_", string.Empty), true))
|
||||||
|
.HasMaxLength(maxLength);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<User>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<User> 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<UserIdentity>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<UserIdentity> 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<User>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(entity => entity.UserId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class StudentProfileConfiguration : IEntityTypeConfiguration<StudentProfile>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<StudentProfile> 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<User>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(entity => entity.UserId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
builder.HasOne<Region>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne<School>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.SelectedSchoolId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne<Major>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.SelectedMajorId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PracticeSession>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<PracticeSession> 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<User>().WithMany()
|
||||||
|
.HasForeignKey(entity => entity.UserId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class AnswerRecordConfiguration : IEntityTypeConfiguration<AnswerRecord>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<AnswerRecord> 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<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
|
||||||
|
{
|
||||||
|
entity.TenantId,
|
||||||
|
entity.UserId,
|
||||||
|
entity.PracticeSessionId
|
||||||
|
})
|
||||||
|
.HasPrincipalKey(entity => new
|
||||||
|
{
|
||||||
|
entity.TenantId,
|
||||||
|
entity.UserId,
|
||||||
|
entity.Id
|
||||||
|
})
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class FavoriteQuestionConfiguration : IEntityTypeConfiguration<FavoriteQuestion>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<FavoriteQuestion> 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<Tenant>().WithMany()
|
||||||
|
.HasForeignKey(entity => entity.TenantId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
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.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class WrongQuestionConfiguration : IEntityTypeConfiguration<WrongQuestion>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<WrongQuestion> 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<Tenant>().WithMany()
|
||||||
|
.HasForeignKey(entity => entity.TenantId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
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.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class RecentPracticeConfiguration : IEntityTypeConfiguration<RecentPractice>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<RecentPractice> 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<User>().WithMany()
|
||||||
|
.HasForeignKey(entity => entity.UserId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<QuestionBank>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<QuestionBank> 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<Region>().WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class QuestionConfiguration : IEntityTypeConfiguration<Question>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Question> 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<QuestionBank>().WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionBankId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne<Subject>().WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.SubjectId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne<Category>().WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.CategoryId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
builder.HasOne<ModuleNode>().WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.NodeId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
builder.HasOne<QuestionVersion>().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<QuestionVersion>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<QuestionVersion> 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<Question>().WithMany()
|
||||||
|
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
|
||||||
|
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
builder.HasOne<User>().WithMany()
|
||||||
|
.HasForeignKey(entity => entity.CreatedBy)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Tenant>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Tenant> 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<User>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(entity => entity.OwnerUserId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class TenantMembershipConfiguration : IEntityTypeConfiguration<TenantMembership>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<TenantMembership> 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<User>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(entity => entity.UserId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
2176
Tiku.Infrastructure/Persistence/Migrations/20260725205004_InitialCore.Designer.cs
generated
Normal file
2176
Tiku.Infrastructure/Persistence/Migrations/20260725205004_InitialCore.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
73
Tiku.Infrastructure/Persistence/ModelBuilderExtensions.cs
Normal file
73
Tiku.Infrastructure/Persistence/ModelBuilderExtensions.cs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
namespace Tiku.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
internal static class ModelBuilderExtensions
|
||||||
|
{
|
||||||
|
public static void UseSnakeCaseIdentifiers(this ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
||||||
|
{
|
||||||
|
foreach (var property in entityType.GetProperties())
|
||||||
|
{
|
||||||
|
property.SetColumnName(ToSnakeCase(property.Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var key in entityType.GetKeys())
|
||||||
|
{
|
||||||
|
if (key.GetName() is { } keyName)
|
||||||
|
{
|
||||||
|
key.SetName(ToSnakeCase(keyName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var foreignKey in entityType.GetForeignKeys())
|
||||||
|
{
|
||||||
|
if (foreignKey.GetConstraintName() is { } constraintName)
|
||||||
|
{
|
||||||
|
foreignKey.SetConstraintName(ToSnakeCase(constraintName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var index in entityType.GetIndexes())
|
||||||
|
{
|
||||||
|
if (index.GetDatabaseName() is { } indexName)
|
||||||
|
{
|
||||||
|
index.SetDatabaseName(ToSnakeCase(indexName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ToSnakeCase(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder = new StringBuilder(value.Length + 8);
|
||||||
|
for (var index = 0; index < value.Length; index++)
|
||||||
|
{
|
||||||
|
var character = value[index];
|
||||||
|
if (char.IsUpper(character) && index > 0 &&
|
||||||
|
(char.IsLower(value[index - 1]) ||
|
||||||
|
index + 1 < value.Length && char.IsLower(value[index + 1])))
|
||||||
|
{
|
||||||
|
builder.Append('_');
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.Append(char.ToLowerInvariant(character));
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SnakeCaseEnumConverter<TEnum>() : ValueConverter<TEnum, string>(
|
||||||
|
value => ModelBuilderExtensions.ToSnakeCase(value.ToString()),
|
||||||
|
value => Enum.Parse<TEnum>(value.Replace("_", string.Empty), true))
|
||||||
|
where TEnum : struct, Enum;
|
||||||
72
Tiku.Infrastructure/Persistence/TikuDbContext.cs
Normal file
72
Tiku.Infrastructure/Persistence/TikuDbContext.cs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Tiku.Domain.Catalog;
|
||||||
|
using Tiku.Domain.Common;
|
||||||
|
using Tiku.Domain.Identity;
|
||||||
|
using Tiku.Domain.Learning;
|
||||||
|
using Tiku.Domain.QuestionBanks;
|
||||||
|
using Tiku.Domain.Tenancy;
|
||||||
|
|
||||||
|
namespace Tiku.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
public sealed class TikuDbContext(DbContextOptions<TikuDbContext> options) : DbContext(options)
|
||||||
|
{
|
||||||
|
public DbSet<Tenant> Tenants => Set<Tenant>();
|
||||||
|
public DbSet<User> Users => Set<User>();
|
||||||
|
public DbSet<UserIdentity> UserIdentities => Set<UserIdentity>();
|
||||||
|
public DbSet<TenantMembership> TenantMemberships => Set<TenantMembership>();
|
||||||
|
public DbSet<StudentProfile> StudentProfiles => Set<StudentProfile>();
|
||||||
|
public DbSet<Region> Regions => Set<Region>();
|
||||||
|
public DbSet<RegionModule> RegionModules => Set<RegionModule>();
|
||||||
|
public DbSet<ModuleNode> ModuleNodes => Set<ModuleNode>();
|
||||||
|
public DbSet<School> Schools => Set<School>();
|
||||||
|
public DbSet<Major> Majors => Set<Major>();
|
||||||
|
public DbSet<Subject> Subjects => Set<Subject>();
|
||||||
|
public DbSet<Category> Categories => Set<Category>();
|
||||||
|
public DbSet<QuestionBank> QuestionBanks => Set<QuestionBank>();
|
||||||
|
public DbSet<Question> Questions => Set<Question>();
|
||||||
|
public DbSet<QuestionVersion> QuestionVersions => Set<QuestionVersion>();
|
||||||
|
public DbSet<PracticeSession> PracticeSessions => Set<PracticeSession>();
|
||||||
|
public DbSet<AnswerRecord> AnswerRecords => Set<AnswerRecord>();
|
||||||
|
public DbSet<FavoriteQuestion> FavoriteQuestions => Set<FavoriteQuestion>();
|
||||||
|
public DbSet<WrongQuestion> WrongQuestions => Set<WrongQuestion>();
|
||||||
|
public DbSet<RecentPractice> RecentPractices => Set<RecentPractice>();
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.HasPostgresExtension("citext");
|
||||||
|
modelBuilder.ApplyConfigurationsFromAssembly(typeof(TikuDbContext).Assembly);
|
||||||
|
modelBuilder.UseSnakeCaseIdentifiers();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override int SaveChanges(bool acceptAllChangesOnSuccess)
|
||||||
|
{
|
||||||
|
UpdateTimestamps();
|
||||||
|
return base.SaveChanges(acceptAllChangesOnSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task<int> SaveChangesAsync(
|
||||||
|
bool acceptAllChangesOnSuccess,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
UpdateTimestamps();
|
||||||
|
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateTimestamps()
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
foreach (var entry in ChangeTracker.Entries<IHasTimestamps>())
|
||||||
|
{
|
||||||
|
if (entry.State == EntityState.Added)
|
||||||
|
{
|
||||||
|
entry.Entity.CreatedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.State is EntityState.Added or EntityState.Modified)
|
||||||
|
{
|
||||||
|
entry.Entity.UpdatedAt = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,14 @@
|
|||||||
<ProjectReference Include="..\Tiku.Domain\Tiku.Domain.csproj" />
|
<ProjectReference Include="..\Tiku.Domain\Tiku.Domain.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||||
|
<PackageReference Include="Npgsql" />
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
|||||||
68
Tiku.IntegrationTests/PersistenceModelTests.cs
Normal file
68
Tiku.IntegrationTests/PersistenceModelTests.cs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Tiku.Domain.QuestionBanks;
|
||||||
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace Tiku.IntegrationTests;
|
||||||
|
|
||||||
|
public sealed class PersistenceModelTests
|
||||||
|
{
|
||||||
|
private static readonly DbContextOptions<TikuDbContext> Options =
|
||||||
|
new DbContextOptionsBuilder<TikuDbContext>()
|
||||||
|
.UseNpgsql("Host=localhost;Database=tiku_model_tests;Username=postgres")
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Core_model_contains_expected_tables()
|
||||||
|
{
|
||||||
|
using var context = new TikuDbContext(Options);
|
||||||
|
|
||||||
|
var tableNames = context.Model.GetEntityTypes()
|
||||||
|
.Select(entity => entity.GetTableName())
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
Assert.Equal(20, tableNames.Count);
|
||||||
|
Assert.Contains("tenants", tableNames);
|
||||||
|
Assert.Contains("questions", tableNames);
|
||||||
|
Assert.Contains("question_versions", tableNames);
|
||||||
|
Assert.Contains("answer_records", tableNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(nameof(Question.Tags), "'[]'::jsonb")]
|
||||||
|
public void Question_json_properties_are_mapped_to_jsonb(
|
||||||
|
string propertyName,
|
||||||
|
string expectedDefaultValueSql)
|
||||||
|
{
|
||||||
|
using var context = new TikuDbContext(Options);
|
||||||
|
|
||||||
|
var property = context.Model.FindEntityType(typeof(Question))!
|
||||||
|
.FindProperty(propertyName)!;
|
||||||
|
|
||||||
|
Assert.Equal("jsonb", property.GetColumnType());
|
||||||
|
Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql());
|
||||||
|
Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Question_relations_include_tenant_in_foreign_keys()
|
||||||
|
{
|
||||||
|
using var context = new TikuDbContext(Options);
|
||||||
|
|
||||||
|
var foreignKeys = context.Model.FindEntityType(typeof(Question))!
|
||||||
|
.GetForeignKeys()
|
||||||
|
.Select(foreignKey => foreignKey.Properties
|
||||||
|
.Select(property => property.Name)
|
||||||
|
.ToArray())
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Assert.All(
|
||||||
|
foreignKeys.Where(properties => properties.Length > 1),
|
||||||
|
properties => Assert.Contains("TenantId", properties));
|
||||||
|
|
||||||
|
Assert.Contains(
|
||||||
|
foreignKeys,
|
||||||
|
properties => properties.SequenceEqual(
|
||||||
|
["TenantId", "Id", "CurrentVersionId"]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace Tiku.IntegrationTests;
|
|
||||||
|
|
||||||
public class UnitTest1
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void Test1()
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
13
dotnet-tools.json
Normal file
13
dotnet-tools.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"isRoot": true,
|
||||||
|
"tools": {
|
||||||
|
"dotnet-ef": {
|
||||||
|
"version": "10.0.10",
|
||||||
|
"commands": [
|
||||||
|
"dotnet-ef"
|
||||||
|
],
|
||||||
|
"rollForward": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user