feat: add learning reports and practice access persistence

This commit is contained in:
xiong
2026-07-26 05:24:55 +08:00
parent c217db4597
commit b2282cde8c
11 changed files with 9606 additions and 2 deletions

View File

@@ -58,6 +58,7 @@ public sealed class ContentNode : AuditableTenantEntity
public bool IsActive { get; set; } = true;
public bool IsSelectable { get; set; } = true;
public bool IsLeaf { get; set; }
public JsonElement AccessRules { get; set; } = JsonDefaults.Object();
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
public Guid? CreatedBy { get; set; }
public Guid? UpdatedBy { get; set; }
@@ -100,6 +101,7 @@ public sealed class QuestionCollection : AuditableTenantEntity
public QuestionCollectionType CollectionType { get; set; } = QuestionCollectionType.Dynamic;
public QuestionCollectionSourceType SourceType { get; set; } = QuestionCollectionSourceType.Filters;
public JsonElement Filters { get; set; } = JsonDefaults.Object();
public JsonElement AccessRules { get; set; } = JsonDefaults.Object();
public int QuestionCount { get; set; }
public decimal? TotalScore { get; set; }
public int? DurationMinutes { get; set; }
@@ -163,6 +165,7 @@ public sealed class PracticeBlueprint : AuditableTenantEntity
public decimal? PassScore { get; set; }
public JsonElement Sections { get; set; } = JsonDefaults.Array();
public JsonElement Rules { get; set; } = JsonDefaults.Object();
public JsonElement AccessRules { get; set; } = JsonDefaults.Object();
public ContentStatus Status { get; set; } = ContentStatus.Active;
public int SortOrder { get; set; }
public Guid? CreatedBy { get; set; }

View File

@@ -20,9 +20,20 @@ public sealed class PracticeSession : TenantEntity
public int? DurationMinutes { get; set; }
public decimal? TotalScore { get; set; }
public DateTimeOffset? ExpiresAt { get; set; }
public PracticeAccessMode AccessMode { get; set; } = PracticeAccessMode.Free;
public Guid? AccessEntitlementId { get; set; }
public int ConsumedFreeQuota { get; set; }
public JsonElement AccessSnapshot { get; set; } = JsonDefaults.Object();
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}
public enum PracticeAccessMode
{
Free,
Svip,
Staff
}
public sealed class AnswerRecord : TenantEntity
{
public Guid UserId { get; set; }

View File

@@ -0,0 +1,229 @@
using System.Text.Json;
using Tiku.Domain.Common;
namespace Tiku.Domain.Learning;
public sealed class ExamDate : AuditableTenantEntity
{
public Guid? RegionId { get; set; }
public Guid? SchoolId { get; set; }
public string? LegacyId { get; set; }
public string ExamName { get; set; } = string.Empty;
public DateTimeOffset? ExamAt { get; set; }
public string? ExamType { get; set; }
public string? Description { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}
public sealed class Report : AuditableTenantEntity
{
public Guid? QuestionId { get; set; }
public Guid? UserId { get; set; }
public Guid? HandledBy { get; set; }
public string? LegacyId { get; set; }
public ReportType? Type { get; set; }
public string? Title { get; set; }
public string? Category { get; set; }
public string? Description { get; set; }
public ReportStatus Status { get; set; } = ReportStatus.Pending;
public ReportPriority Priority { get; set; } = ReportPriority.Normal;
public DateTimeOffset? HandledAt { get; set; }
public string? Resolution { get; set; }
public string? Contact { get; set; }
public JsonElement Attachments { get; set; } = JsonDefaults.Array();
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}
public sealed class ReportStatusEvent : Entity
{
public Guid TenantId { get; set; }
public Guid ReportId { get; set; }
public ReportStatus? FromStatus { get; set; }
public ReportStatus ToStatus { get; set; } = ReportStatus.Pending;
public string? Note { get; set; }
public Guid? ActorUserId { get; set; }
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class UserScoreEvent : Entity
{
public Guid TenantId { get; set; }
public Guid UserId { get; set; }
public UserScoreEventType EventType { get; set; } = UserScoreEventType.ManualAdjust;
public int Points { get; set; }
public int BalanceAfter { get; set; }
public string? SourceType { get; set; }
public Guid? SourceId { get; set; }
public string? IdempotencyKey { get; set; }
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class PracticeDailyUsage : AuditableTenantEntity
{
public Guid UserId { get; set; }
public DateOnly UsageDate { get; set; } = DateOnly.FromDateTime(DateTime.UtcNow);
public PracticeUsageScopeType ScopeType { get; set; } = PracticeUsageScopeType.Tenant;
public Guid? ScopeId { get; set; }
public int FreeLimit { get; set; } = 25;
public int UsedCount { get; set; }
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}
public sealed class PracticeAccessEvent : Entity
{
public Guid TenantId { get; set; }
public Guid? UserId { get; set; }
public Guid? PracticeSessionId { get; set; }
public PracticeAccessEventType EventType { get; set; } = PracticeAccessEventType.SessionCreated;
public PracticeAccessEventMode AccessMode { get; set; } = PracticeAccessEventMode.Free;
public int RequestedCount { get; set; }
public int GrantedCount { get; set; }
public int ConsumedFreeQuota { get; set; }
public string? Reason { get; set; }
public PracticeUsageScopeType? ScopeType { get; set; }
public Guid? ScopeId { get; set; }
public Guid? EntitlementId { get; set; }
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class PracticeSessionReport : AuditableTenantEntity
{
public Guid UserId { get; set; }
public Guid PracticeSessionId { get; set; }
public Guid? BlueprintId { get; set; }
public Guid? CollectionId { get; set; }
public string Mode { get; set; } = "mock_exam";
public int TotalQuestions { get; set; }
public int AnsweredCount { get; set; }
public int CorrectCount { get; set; }
public int WrongCount { get; set; }
public int UnansweredCount { get; set; }
public decimal Score { get; set; }
public decimal TotalScore { get; set; }
public decimal Accuracy { get; set; }
public int DurationSeconds { get; set; }
public DateTimeOffset? StartedAt { get; set; }
public DateTimeOffset SubmittedAt { get; set; } = DateTimeOffset.UtcNow;
public JsonElement SectionStats { get; set; } = JsonDefaults.Array();
public JsonElement QuestionResults { get; set; } = JsonDefaults.Array();
public JsonElement WrongQuestionIds { get; set; } = JsonDefaults.Array();
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}
public sealed class PracticeSessionReportSection : AuditableTenantEntity
{
public Guid ReportId { get; set; }
public Guid PracticeSessionId { get; set; }
public string SectionKey { get; set; } = string.Empty;
public string? SectionName { get; set; }
public string? QuestionType { get; set; }
public int QuestionCount { get; set; }
public int AnsweredCount { get; set; }
public int CorrectCount { get; set; }
public int WrongCount { get; set; }
public int UnansweredCount { get; set; }
public decimal Score { get; set; }
public decimal TotalScore { get; set; }
public decimal Accuracy { get; set; }
public int SortOrder { get; set; }
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}
public sealed class DashboardDailyStat : AuditableTenantEntity
{
public Guid? RegionId { get; set; }
public string? LegacyId { get; set; }
public string? LegacyRegionId { get; set; }
public DateOnly StatDate { get; set; }
public int NewUsers { get; set; }
public int NewQuestions { get; set; }
public int NewOrders { get; set; }
public int NewRevenueCents { get; set; }
public int ActiveUsers { get; set; }
public DateTimeOffset? RebuiltAt { get; set; }
}
public sealed class RevenueDailyStat : AuditableTenantEntity
{
public Guid? RegionId { get; set; }
public string? LegacyId { get; set; }
public string? LegacyRegionId { get; set; }
public DateOnly StatDate { get; set; }
public string? SaleType { get; set; }
public int RealRevenueCents { get; set; }
public int OrderCount { get; set; }
public int CodeCount { get; set; }
public int CodeUsed { get; set; }
public int CodeEstimated { get; set; }
public int EstimatedRevenueCents { get; set; }
public DateTimeOffset? RebuiltAt { get; set; }
}
public enum ReportType
{
QuestionError,
ContentError,
VideoError,
AssetError,
SystemBug,
Suggestion,
Other
}
public enum ReportStatus
{
Pending,
Accepted,
Rejected,
Resolved,
Closed
}
public enum ReportPriority
{
Low,
Normal,
High,
Urgent
}
public enum UserScoreEventType
{
CheckIn,
ManualAdjust,
FeedbackReward,
ActivityReward,
RedeemCost
}
public enum PracticeUsageScopeType
{
Tenant,
Region,
Subject,
QuestionBank,
ContentEntry,
ContentNode,
Collection,
Blueprint
}
public enum PracticeAccessEventType
{
SessionCreated,
SessionDenied,
QuotaConsumed
}
public enum PracticeAccessEventMode
{
Free,
Svip,
Staff,
Denied
}

View File

@@ -60,6 +60,7 @@ internal sealed class ContentNodeConfiguration : IEntityTypeConfiguration<Conten
builder.Property(entity => entity.MarkerType).HasNullableSnakeCaseEnum();
builder.Property(entity => entity.MarkerConfig).IsJson("{}");
builder.Property(entity => entity.Path).HasColumnType("ltree");
builder.Property(entity => entity.AccessRules).IsJson("{}");
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.EntryId, entity.NodeKey }).IsUnique();
@@ -110,6 +111,7 @@ internal sealed class QuestionCollectionConfiguration : IEntityTypeConfiguration
builder.Property(entity => entity.CollectionType).HasSnakeCaseEnum();
builder.Property(entity => entity.SourceType).HasSnakeCaseEnum();
builder.Property(entity => entity.Filters).IsJson("{}");
builder.Property(entity => entity.AccessRules).IsJson("{}");
builder.Property(entity => entity.TotalScore).HasPrecision(8, 2);
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.Metadata).IsJson("{}");
@@ -213,6 +215,7 @@ internal sealed class PracticeBlueprintConfiguration : IEntityTypeConfiguration<
builder.Property(entity => entity.PassScore).HasPrecision(8, 2);
builder.Property(entity => entity.Sections).IsJson("[]");
builder.Property(entity => entity.Rules).IsJson("{}");
builder.Property(entity => entity.AccessRules).IsJson("{}");
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasIndex(entity => new

View File

@@ -19,9 +19,20 @@ internal sealed class PracticeSessionConfiguration : IEntityTypeConfiguration<Pr
builder.Property(entity => entity.StartedAt).HasDefaultValueSql("now()");
builder.Property(entity => entity.QuestionIds).IsJson("[]");
builder.Property(entity => entity.TotalScore).HasPrecision(8, 2);
builder.Property(entity => entity.AccessMode)
.HasSnakeCaseEnum()
.HasDefaultValue(PracticeAccessMode.Free);
builder.Property(entity => entity.AccessSnapshot).IsJson("{}");
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.StartedAt });
builder.ToTable(table =>
{
table.HasCheckConstraint(
"ck_practice_sessions_consumed_free_quota",
"consumed_free_quota >= 0");
});
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);

View File

@@ -0,0 +1,294 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tiku.Domain.Catalog;
using Tiku.Domain.Content;
using Tiku.Domain.Identity;
using Tiku.Domain.Learning;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
namespace Tiku.Infrastructure.Persistence.Configurations;
internal sealed class ExamDateConfiguration : IEntityTypeConfiguration<ExamDate>
{
public void Configure(EntityTypeBuilder<ExamDate> builder)
{
builder.ConfigureTenantEntity("exam_dates");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.ExamName).HasMaxLength(300);
builder.Property(entity => entity.ExamType).HasMaxLength(50);
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.SchoolId, entity.ExamAt });
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 ReportConfiguration : IEntityTypeConfiguration<Report>
{
public void Configure(EntityTypeBuilder<Report> builder)
{
builder.ConfigureTenantEntity("reports");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.Type).HasNullableSnakeCaseEnum();
builder.Property(entity => entity.Title).HasMaxLength(300);
builder.Property(entity => entity.Category).HasMaxLength(100);
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.Priority).HasSnakeCaseEnum();
builder.Property(entity => entity.Contact).HasMaxLength(300);
builder.Property(entity => entity.Attachments).IsJson("[]");
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.CreatedAt });
builder.HasIndex(entity => new { entity.TenantId, entity.QuestionId, entity.CreatedAt })
.HasFilter("question_id is not null");
builder.HasOne<Question>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.HandledBy)
.OnDelete(DeleteBehavior.SetNull);
}
}
internal sealed class ReportStatusEventConfiguration : IEntityTypeConfiguration<ReportStatusEvent>
{
public void Configure(EntityTypeBuilder<ReportStatusEvent> builder)
{
builder.ConfigureEntity("report_status_events");
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
builder.Property(entity => entity.FromStatus).HasNullableSnakeCaseEnum();
builder.Property(entity => entity.ToStatus).HasSnakeCaseEnum();
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new { entity.TenantId, entity.ReportId, entity.CreatedAt });
builder.HasOne<Tenant>().WithMany()
.HasForeignKey(entity => entity.TenantId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<Report>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.ReportId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.ActorUserId)
.OnDelete(DeleteBehavior.SetNull);
}
}
internal sealed class UserScoreEventConfiguration : IEntityTypeConfiguration<UserScoreEvent>
{
public void Configure(EntityTypeBuilder<UserScoreEvent> builder)
{
builder.ConfigureEntity("user_score_events");
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
builder.Property(entity => entity.EventType).HasSnakeCaseEnum();
builder.Property(entity => entity.SourceType).HasMaxLength(100);
builder.Property(entity => entity.IdempotencyKey).HasMaxLength(200);
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new { entity.TenantId, entity.IdempotencyKey }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.CreatedAt });
builder.HasOne<Tenant>().WithMany()
.HasForeignKey(entity => entity.TenantId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class PracticeDailyUsageConfiguration : IEntityTypeConfiguration<PracticeDailyUsage>
{
public void Configure(EntityTypeBuilder<PracticeDailyUsage> builder)
{
builder.ConfigureTenantEntity("practice_daily_usage");
builder.ConfigureTimestamps();
builder.Property(entity => entity.UsageDate).HasDefaultValueSql("current_date");
builder.Property(entity => entity.ScopeType).HasSnakeCaseEnum();
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.UsageDate });
builder.HasIndex(entity => new
{
entity.TenantId,
entity.UserId,
entity.UsageDate,
entity.ScopeType,
entity.ScopeId
}).IsUnique().AreNullsDistinct(false);
builder.ToTable(table =>
{
table.HasCheckConstraint("ck_practice_daily_usage_free_limit", "free_limit >= 0");
table.HasCheckConstraint("ck_practice_daily_usage_used_count", "used_count >= 0");
});
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class PracticeAccessEventConfiguration : IEntityTypeConfiguration<PracticeAccessEvent>
{
public void Configure(EntityTypeBuilder<PracticeAccessEvent> builder)
{
builder.ConfigureEntity("practice_access_events");
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
builder.Property(entity => entity.EventType).HasSnakeCaseEnum();
builder.Property(entity => entity.AccessMode).HasSnakeCaseEnum();
builder.Property(entity => entity.ScopeType).HasNullableSnakeCaseEnum();
builder.Property(entity => entity.Reason).HasMaxLength(1000);
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.CreatedAt });
builder.HasIndex(entity => new { entity.TenantId, entity.PracticeSessionId });
builder.ToTable(table =>
{
table.HasCheckConstraint("ck_practice_access_events_requested_count", "requested_count >= 0");
table.HasCheckConstraint("ck_practice_access_events_granted_count", "granted_count >= 0");
table.HasCheckConstraint("ck_practice_access_events_consumed_free_quota", "consumed_free_quota >= 0");
});
builder.HasOne<Tenant>().WithMany()
.HasForeignKey(entity => entity.TenantId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne<PracticeSession>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.PracticeSessionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.SetNull);
}
}
internal sealed class PracticeSessionReportConfiguration : IEntityTypeConfiguration<PracticeSessionReport>
{
public void Configure(EntityTypeBuilder<PracticeSessionReport> builder)
{
builder.ConfigureTenantEntity("practice_session_reports");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Mode).HasMaxLength(50);
builder.Property(entity => entity.Score).HasPrecision(10, 2);
builder.Property(entity => entity.TotalScore).HasPrecision(10, 2);
builder.Property(entity => entity.Accuracy).HasPrecision(6, 4);
builder.Property(entity => entity.SubmittedAt).HasDefaultValueSql("now()");
builder.Property(entity => entity.SectionStats).IsJson("[]");
builder.Property(entity => entity.QuestionResults).IsJson("[]");
builder.Property(entity => entity.WrongQuestionIds).IsJson("[]");
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.PracticeSessionId }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.SubmittedAt });
builder.HasIndex(entity => new { entity.TenantId, entity.BlueprintId, entity.SubmittedAt })
.HasFilter("blueprint_id is not null");
builder.ToTable(table =>
{
table.HasCheckConstraint("ck_practice_session_reports_counts", "total_questions >= 0 and answered_count >= 0 and correct_count >= 0 and wrong_count >= 0 and unanswered_count >= 0");
table.HasCheckConstraint("ck_practice_session_reports_scores", "score >= 0 and total_score >= 0");
table.HasCheckConstraint("ck_practice_session_reports_accuracy", "accuracy >= 0 and accuracy <= 1");
table.HasCheckConstraint("ck_practice_session_reports_duration", "duration_seconds >= 0");
});
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<PracticeSession>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.PracticeSessionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<PracticeBlueprint>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.BlueprintId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<QuestionCollection>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.CollectionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class PracticeSessionReportSectionConfiguration : IEntityTypeConfiguration<PracticeSessionReportSection>
{
public void Configure(EntityTypeBuilder<PracticeSessionReportSection> builder)
{
builder.ConfigureTenantEntity("practice_session_report_sections");
builder.ConfigureTimestamps();
builder.Property(entity => entity.SectionKey).HasMaxLength(100);
builder.Property(entity => entity.SectionName).HasMaxLength(300);
builder.Property(entity => entity.QuestionType).HasMaxLength(50);
builder.Property(entity => entity.Score).HasPrecision(10, 2);
builder.Property(entity => entity.TotalScore).HasPrecision(10, 2);
builder.Property(entity => entity.Accuracy).HasPrecision(6, 4);
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.ReportId, entity.SectionKey }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.ReportId, entity.SortOrder });
builder.ToTable(table =>
{
table.HasCheckConstraint("ck_practice_session_report_sections_counts", "question_count >= 0 and answered_count >= 0 and correct_count >= 0 and wrong_count >= 0 and unanswered_count >= 0");
table.HasCheckConstraint("ck_practice_session_report_sections_scores", "score >= 0 and total_score >= 0");
table.HasCheckConstraint("ck_practice_session_report_sections_accuracy", "accuracy >= 0 and accuracy <= 1");
});
builder.HasOne<PracticeSessionReport>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.ReportId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<PracticeSession>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.PracticeSessionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class DashboardDailyStatConfiguration : IEntityTypeConfiguration<DashboardDailyStat>
{
public void Configure(EntityTypeBuilder<DashboardDailyStat> builder)
{
builder.ConfigureTenantEntity("dashboard_daily_stats");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.LegacyRegionId).HasMaxLength(64);
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.StatDate, entity.RegionId }).IsUnique().AreNullsDistinct(false);
builder.HasOne<Region>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class RevenueDailyStatConfiguration : IEntityTypeConfiguration<RevenueDailyStat>
{
public void Configure(EntityTypeBuilder<RevenueDailyStat> builder)
{
builder.ConfigureTenantEntity("revenue_daily_stats");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.LegacyRegionId).HasMaxLength(64);
builder.Property(entity => entity.SaleType).HasMaxLength(50);
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.StatDate, entity.RegionId, entity.SaleType }).IsUnique().AreNullsDistinct(false);
builder.HasOne<Region>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}

View File

@@ -0,0 +1,764 @@
using System;
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddLearningReportsAndPracticeAccessPersistence : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<JsonElement>(
name: "access_rules",
table: "question_collections",
type: "jsonb",
nullable: false,
defaultValueSql: "'{}'::jsonb");
migrationBuilder.AddColumn<Guid>(
name: "access_entitlement_id",
table: "practice_sessions",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "access_mode",
table: "practice_sessions",
type: "character varying(32)",
maxLength: 32,
nullable: false,
defaultValue: "free");
migrationBuilder.AddColumn<JsonElement>(
name: "access_snapshot",
table: "practice_sessions",
type: "jsonb",
nullable: false,
defaultValueSql: "'{}'::jsonb");
migrationBuilder.AddColumn<int>(
name: "consumed_free_quota",
table: "practice_sessions",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<JsonElement>(
name: "access_rules",
table: "practice_blueprints",
type: "jsonb",
nullable: false,
defaultValueSql: "'{}'::jsonb");
migrationBuilder.AddColumn<JsonElement>(
name: "access_rules",
table: "content_nodes",
type: "jsonb",
nullable: false,
defaultValueSql: "'{}'::jsonb");
migrationBuilder.CreateTable(
name: "dashboard_daily_stats",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
region_id = table.Column<Guid>(type: "uuid", nullable: true),
legacy_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
legacy_region_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
stat_date = table.Column<DateOnly>(type: "date", nullable: false),
new_users = table.Column<int>(type: "integer", nullable: false),
new_questions = table.Column<int>(type: "integer", nullable: false),
new_orders = table.Column<int>(type: "integer", nullable: false),
new_revenue_cents = table.Column<int>(type: "integer", nullable: false),
active_users = table.Column<int>(type: "integer", nullable: false),
rebuilt_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_dashboard_daily_stats", x => x.id);
table.UniqueConstraint("ak_dashboard_daily_stats_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_dashboard_daily_stats_regions_tenant_id_region_id",
columns: x => new { x.tenant_id, x.region_id },
principalTable: "regions",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_dashboard_daily_stats_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "exam_dates",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
region_id = table.Column<Guid>(type: "uuid", nullable: true),
school_id = table.Column<Guid>(type: "uuid", nullable: true),
legacy_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
exam_name = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: false),
exam_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
exam_type = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
description = table.Column<string>(type: "text", nullable: true),
sort_order = table.Column<int>(type: "integer", nullable: false),
is_active = table.Column<bool>(type: "boolean", nullable: false),
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_exam_dates", x => x.id);
table.UniqueConstraint("ak_exam_dates_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_exam_dates_regions_tenant_id_region_id",
columns: x => new { x.tenant_id, x.region_id },
principalTable: "regions",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_exam_dates_schools_tenant_id_school_id",
columns: x => new { x.tenant_id, x.school_id },
principalTable: "schools",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_exam_dates_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "practice_access_events",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
user_id = table.Column<Guid>(type: "uuid", nullable: true),
practice_session_id = table.Column<Guid>(type: "uuid", nullable: true),
event_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
access_mode = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
requested_count = table.Column<int>(type: "integer", nullable: false),
granted_count = table.Column<int>(type: "integer", nullable: false),
consumed_free_quota = table.Column<int>(type: "integer", nullable: false),
reason = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
scope_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
scope_id = table.Column<Guid>(type: "uuid", nullable: true),
entitlement_id = table.Column<Guid>(type: "uuid", nullable: true),
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_practice_access_events", x => x.id);
table.UniqueConstraint("ak_practice_access_events_tenant_id_id", x => new { x.tenant_id, x.id });
table.CheckConstraint("ck_practice_access_events_consumed_free_quota", "consumed_free_quota >= 0");
table.CheckConstraint("ck_practice_access_events_granted_count", "granted_count >= 0");
table.CheckConstraint("ck_practice_access_events_requested_count", "requested_count >= 0");
table.ForeignKey(
name: "fk_practice_access_events_practice_sessions_tenant_id_practice~",
columns: x => new { x.tenant_id, x.practice_session_id },
principalTable: "practice_sessions",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_practice_access_events_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_practice_access_events_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "practice_daily_usage",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
user_id = table.Column<Guid>(type: "uuid", nullable: false),
usage_date = table.Column<DateOnly>(type: "date", nullable: false, defaultValueSql: "current_date"),
scope_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
scope_id = table.Column<Guid>(type: "uuid", nullable: true),
free_limit = table.Column<int>(type: "integer", nullable: false),
used_count = table.Column<int>(type: "integer", nullable: false),
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_practice_daily_usage", x => x.id);
table.UniqueConstraint("ak_practice_daily_usage_tenant_id_id", x => new { x.tenant_id, x.id });
table.CheckConstraint("ck_practice_daily_usage_free_limit", "free_limit >= 0");
table.CheckConstraint("ck_practice_daily_usage_used_count", "used_count >= 0");
table.ForeignKey(
name: "fk_practice_daily_usage_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_practice_daily_usage_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "practice_session_reports",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
user_id = table.Column<Guid>(type: "uuid", nullable: false),
practice_session_id = table.Column<Guid>(type: "uuid", nullable: false),
blueprint_id = table.Column<Guid>(type: "uuid", nullable: true),
collection_id = table.Column<Guid>(type: "uuid", nullable: true),
mode = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
total_questions = table.Column<int>(type: "integer", nullable: false),
answered_count = table.Column<int>(type: "integer", nullable: false),
correct_count = table.Column<int>(type: "integer", nullable: false),
wrong_count = table.Column<int>(type: "integer", nullable: false),
unanswered_count = table.Column<int>(type: "integer", nullable: false),
score = table.Column<decimal>(type: "numeric(10,2)", precision: 10, scale: 2, nullable: false),
total_score = table.Column<decimal>(type: "numeric(10,2)", precision: 10, scale: 2, nullable: false),
accuracy = table.Column<decimal>(type: "numeric(6,4)", precision: 6, scale: 4, nullable: false),
duration_seconds = table.Column<int>(type: "integer", nullable: false),
started_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
submitted_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
section_stats = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'[]'::jsonb"),
question_results = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'[]'::jsonb"),
wrong_question_ids = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'[]'::jsonb"),
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_practice_session_reports", x => x.id);
table.UniqueConstraint("ak_practice_session_reports_tenant_id_id", x => new { x.tenant_id, x.id });
table.CheckConstraint("ck_practice_session_reports_accuracy", "accuracy >= 0 and accuracy <= 1");
table.CheckConstraint("ck_practice_session_reports_counts", "total_questions >= 0 and answered_count >= 0 and correct_count >= 0 and wrong_count >= 0 and unanswered_count >= 0");
table.CheckConstraint("ck_practice_session_reports_duration", "duration_seconds >= 0");
table.CheckConstraint("ck_practice_session_reports_scores", "score >= 0 and total_score >= 0");
table.ForeignKey(
name: "fk_practice_session_reports_practice_blueprints_tenant_id_blue~",
columns: x => new { x.tenant_id, x.blueprint_id },
principalTable: "practice_blueprints",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_practice_session_reports_practice_sessions_tenant_id_practi~",
columns: x => new { x.tenant_id, x.practice_session_id },
principalTable: "practice_sessions",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_practice_session_reports_question_collections_tenant_id_col~",
columns: x => new { x.tenant_id, x.collection_id },
principalTable: "question_collections",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_practice_session_reports_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_practice_session_reports_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "reports",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
question_id = table.Column<Guid>(type: "uuid", nullable: true),
user_id = table.Column<Guid>(type: "uuid", nullable: true),
handled_by = table.Column<Guid>(type: "uuid", nullable: true),
legacy_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
title = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
category = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
description = table.Column<string>(type: "text", nullable: true),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
priority = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
handled_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
resolution = table.Column<string>(type: "text", nullable: true),
contact = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
attachments = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'[]'::jsonb"),
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_reports", x => x.id);
table.UniqueConstraint("ak_reports_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_reports_questions_tenant_id_question_id",
columns: x => new { x.tenant_id, x.question_id },
principalTable: "questions",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_reports_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_reports_users_handled_by",
column: x => x.handled_by,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_reports_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "revenue_daily_stats",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
region_id = table.Column<Guid>(type: "uuid", nullable: true),
legacy_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
legacy_region_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
stat_date = table.Column<DateOnly>(type: "date", nullable: false),
sale_type = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
real_revenue_cents = table.Column<int>(type: "integer", nullable: false),
order_count = table.Column<int>(type: "integer", nullable: false),
code_count = table.Column<int>(type: "integer", nullable: false),
code_used = table.Column<int>(type: "integer", nullable: false),
code_estimated = table.Column<int>(type: "integer", nullable: false),
estimated_revenue_cents = table.Column<int>(type: "integer", nullable: false),
rebuilt_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_revenue_daily_stats", x => x.id);
table.UniqueConstraint("ak_revenue_daily_stats_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_revenue_daily_stats_regions_tenant_id_region_id",
columns: x => new { x.tenant_id, x.region_id },
principalTable: "regions",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_revenue_daily_stats_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "user_score_events",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
user_id = table.Column<Guid>(type: "uuid", nullable: false),
event_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
points = table.Column<int>(type: "integer", nullable: false),
balance_after = table.Column<int>(type: "integer", nullable: false),
source_type = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
source_id = table.Column<Guid>(type: "uuid", nullable: true),
idempotency_key = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_user_score_events", x => x.id);
table.UniqueConstraint("ak_user_score_events_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_user_score_events_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_user_score_events_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "practice_session_report_sections",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
report_id = table.Column<Guid>(type: "uuid", nullable: false),
practice_session_id = table.Column<Guid>(type: "uuid", nullable: false),
section_key = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
section_name = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
question_type = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
question_count = table.Column<int>(type: "integer", nullable: false),
answered_count = table.Column<int>(type: "integer", nullable: false),
correct_count = table.Column<int>(type: "integer", nullable: false),
wrong_count = table.Column<int>(type: "integer", nullable: false),
unanswered_count = table.Column<int>(type: "integer", nullable: false),
score = table.Column<decimal>(type: "numeric(10,2)", precision: 10, scale: 2, nullable: false),
total_score = table.Column<decimal>(type: "numeric(10,2)", precision: 10, scale: 2, nullable: false),
accuracy = table.Column<decimal>(type: "numeric(6,4)", precision: 6, scale: 4, nullable: false),
sort_order = table.Column<int>(type: "integer", nullable: false),
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_practice_session_report_sections", x => x.id);
table.UniqueConstraint("ak_practice_session_report_sections_tenant_id_id", x => new { x.tenant_id, x.id });
table.CheckConstraint("ck_practice_session_report_sections_accuracy", "accuracy >= 0 and accuracy <= 1");
table.CheckConstraint("ck_practice_session_report_sections_counts", "question_count >= 0 and answered_count >= 0 and correct_count >= 0 and wrong_count >= 0 and unanswered_count >= 0");
table.CheckConstraint("ck_practice_session_report_sections_scores", "score >= 0 and total_score >= 0");
table.ForeignKey(
name: "fk_practice_session_report_sections_practice_session_reports_t~",
columns: x => new { x.tenant_id, x.report_id },
principalTable: "practice_session_reports",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_practice_session_report_sections_practice_sessions_tenant_i~",
columns: x => new { x.tenant_id, x.practice_session_id },
principalTable: "practice_sessions",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_practice_session_report_sections_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "report_status_events",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
report_id = table.Column<Guid>(type: "uuid", nullable: false),
from_status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
to_status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
note = table.Column<string>(type: "text", nullable: true),
actor_user_id = table.Column<Guid>(type: "uuid", nullable: true),
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_report_status_events", x => x.id);
table.UniqueConstraint("ak_report_status_events_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_report_status_events_reports_tenant_id_report_id",
columns: x => new { x.tenant_id, x.report_id },
principalTable: "reports",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_report_status_events_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_report_status_events_users_actor_user_id",
column: x => x.actor_user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.AddCheckConstraint(
name: "ck_practice_sessions_consumed_free_quota",
table: "practice_sessions",
sql: "consumed_free_quota >= 0");
migrationBuilder.CreateIndex(
name: "ix_dashboard_daily_stats_tenant_id_legacy_id",
table: "dashboard_daily_stats",
columns: new[] { "tenant_id", "legacy_id" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_dashboard_daily_stats_tenant_id_region_id",
table: "dashboard_daily_stats",
columns: new[] { "tenant_id", "region_id" });
migrationBuilder.CreateIndex(
name: "ix_dashboard_daily_stats_tenant_id_stat_date_region_id",
table: "dashboard_daily_stats",
columns: new[] { "tenant_id", "stat_date", "region_id" },
unique: true)
.Annotation("Npgsql:NullsDistinct", false);
migrationBuilder.CreateIndex(
name: "ix_exam_dates_tenant_id_legacy_id",
table: "exam_dates",
columns: new[] { "tenant_id", "legacy_id" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_exam_dates_tenant_id_region_id",
table: "exam_dates",
columns: new[] { "tenant_id", "region_id" });
migrationBuilder.CreateIndex(
name: "ix_exam_dates_tenant_id_school_id_exam_at",
table: "exam_dates",
columns: new[] { "tenant_id", "school_id", "exam_at" });
migrationBuilder.CreateIndex(
name: "ix_practice_access_events_tenant_id_practice_session_id",
table: "practice_access_events",
columns: new[] { "tenant_id", "practice_session_id" });
migrationBuilder.CreateIndex(
name: "ix_practice_access_events_tenant_id_user_id_created_at",
table: "practice_access_events",
columns: new[] { "tenant_id", "user_id", "created_at" });
migrationBuilder.CreateIndex(
name: "ix_practice_access_events_user_id",
table: "practice_access_events",
column: "user_id");
migrationBuilder.CreateIndex(
name: "ix_practice_daily_usage_tenant_id_user_id_usage_date",
table: "practice_daily_usage",
columns: new[] { "tenant_id", "user_id", "usage_date" });
migrationBuilder.CreateIndex(
name: "ix_practice_daily_usage_tenant_id_user_id_usage_date_scope_typ~",
table: "practice_daily_usage",
columns: new[] { "tenant_id", "user_id", "usage_date", "scope_type", "scope_id" },
unique: true)
.Annotation("Npgsql:NullsDistinct", false);
migrationBuilder.CreateIndex(
name: "ix_practice_daily_usage_user_id",
table: "practice_daily_usage",
column: "user_id");
migrationBuilder.CreateIndex(
name: "ix_practice_session_report_sections_tenant_id_practice_session~",
table: "practice_session_report_sections",
columns: new[] { "tenant_id", "practice_session_id" });
migrationBuilder.CreateIndex(
name: "ix_practice_session_report_sections_tenant_id_report_id_sectio~",
table: "practice_session_report_sections",
columns: new[] { "tenant_id", "report_id", "section_key" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_practice_session_report_sections_tenant_id_report_id_sort_o~",
table: "practice_session_report_sections",
columns: new[] { "tenant_id", "report_id", "sort_order" });
migrationBuilder.CreateIndex(
name: "ix_practice_session_reports_tenant_id_blueprint_id_submitted_at",
table: "practice_session_reports",
columns: new[] { "tenant_id", "blueprint_id", "submitted_at" },
filter: "blueprint_id is not null");
migrationBuilder.CreateIndex(
name: "ix_practice_session_reports_tenant_id_collection_id",
table: "practice_session_reports",
columns: new[] { "tenant_id", "collection_id" });
migrationBuilder.CreateIndex(
name: "ix_practice_session_reports_tenant_id_practice_session_id",
table: "practice_session_reports",
columns: new[] { "tenant_id", "practice_session_id" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_practice_session_reports_tenant_id_user_id_submitted_at",
table: "practice_session_reports",
columns: new[] { "tenant_id", "user_id", "submitted_at" });
migrationBuilder.CreateIndex(
name: "ix_practice_session_reports_user_id",
table: "practice_session_reports",
column: "user_id");
migrationBuilder.CreateIndex(
name: "ix_report_status_events_actor_user_id",
table: "report_status_events",
column: "actor_user_id");
migrationBuilder.CreateIndex(
name: "ix_report_status_events_tenant_id_report_id_created_at",
table: "report_status_events",
columns: new[] { "tenant_id", "report_id", "created_at" });
migrationBuilder.CreateIndex(
name: "ix_reports_handled_by",
table: "reports",
column: "handled_by");
migrationBuilder.CreateIndex(
name: "ix_reports_tenant_id_legacy_id",
table: "reports",
columns: new[] { "tenant_id", "legacy_id" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_reports_tenant_id_question_id_created_at",
table: "reports",
columns: new[] { "tenant_id", "question_id", "created_at" },
filter: "question_id is not null");
migrationBuilder.CreateIndex(
name: "ix_reports_tenant_id_status_created_at",
table: "reports",
columns: new[] { "tenant_id", "status", "created_at" });
migrationBuilder.CreateIndex(
name: "ix_reports_user_id",
table: "reports",
column: "user_id");
migrationBuilder.CreateIndex(
name: "ix_revenue_daily_stats_tenant_id_legacy_id",
table: "revenue_daily_stats",
columns: new[] { "tenant_id", "legacy_id" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_revenue_daily_stats_tenant_id_region_id",
table: "revenue_daily_stats",
columns: new[] { "tenant_id", "region_id" });
migrationBuilder.CreateIndex(
name: "ix_revenue_daily_stats_tenant_id_stat_date_region_id_sale_type",
table: "revenue_daily_stats",
columns: new[] { "tenant_id", "stat_date", "region_id", "sale_type" },
unique: true)
.Annotation("Npgsql:NullsDistinct", false);
migrationBuilder.CreateIndex(
name: "ix_user_score_events_tenant_id_idempotency_key",
table: "user_score_events",
columns: new[] { "tenant_id", "idempotency_key" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_user_score_events_tenant_id_user_id_created_at",
table: "user_score_events",
columns: new[] { "tenant_id", "user_id", "created_at" });
migrationBuilder.CreateIndex(
name: "ix_user_score_events_user_id",
table: "user_score_events",
column: "user_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "dashboard_daily_stats");
migrationBuilder.DropTable(
name: "exam_dates");
migrationBuilder.DropTable(
name: "practice_access_events");
migrationBuilder.DropTable(
name: "practice_daily_usage");
migrationBuilder.DropTable(
name: "practice_session_report_sections");
migrationBuilder.DropTable(
name: "report_status_events");
migrationBuilder.DropTable(
name: "revenue_daily_stats");
migrationBuilder.DropTable(
name: "user_score_events");
migrationBuilder.DropTable(
name: "practice_session_reports");
migrationBuilder.DropTable(
name: "reports");
migrationBuilder.DropCheckConstraint(
name: "ck_practice_sessions_consumed_free_quota",
table: "practice_sessions");
migrationBuilder.DropColumn(
name: "access_rules",
table: "question_collections");
migrationBuilder.DropColumn(
name: "access_entitlement_id",
table: "practice_sessions");
migrationBuilder.DropColumn(
name: "access_mode",
table: "practice_sessions");
migrationBuilder.DropColumn(
name: "access_snapshot",
table: "practice_sessions");
migrationBuilder.DropColumn(
name: "consumed_free_quota",
table: "practice_sessions");
migrationBuilder.DropColumn(
name: "access_rules",
table: "practice_blueprints");
migrationBuilder.DropColumn(
name: "access_rules",
table: "content_nodes");
}
}
}

View File

@@ -56,6 +56,16 @@ public sealed class TikuDbContext(DbContextOptions<TikuDbContext> options) : DbC
public DbSet<FavoriteQuestion> FavoriteQuestions => Set<FavoriteQuestion>();
public DbSet<WrongQuestion> WrongQuestions => Set<WrongQuestion>();
public DbSet<RecentPractice> RecentPractices => Set<RecentPractice>();
public DbSet<ExamDate> ExamDates => Set<ExamDate>();
public DbSet<Report> Reports => Set<Report>();
public DbSet<ReportStatusEvent> ReportStatusEvents => Set<ReportStatusEvent>();
public DbSet<UserScoreEvent> UserScoreEvents => Set<UserScoreEvent>();
public DbSet<PracticeDailyUsage> PracticeDailyUsages => Set<PracticeDailyUsage>();
public DbSet<PracticeAccessEvent> PracticeAccessEvents => Set<PracticeAccessEvent>();
public DbSet<PracticeSessionReport> PracticeSessionReports => Set<PracticeSessionReport>();
public DbSet<PracticeSessionReportSection> PracticeSessionReportSections => Set<PracticeSessionReportSection>();
public DbSet<DashboardDailyStat> DashboardDailyStats => Set<DashboardDailyStat>();
public DbSet<RevenueDailyStat> RevenueDailyStats => Set<RevenueDailyStat>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{

View File

@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Tiku.Domain.Content;
using Tiku.Domain.Learning;
using Tiku.Domain.QuestionBanks;
using Tiku.Infrastructure.Persistence;
@@ -23,7 +24,7 @@ public sealed class PersistenceModelTests
.Select(entity => entity.GetTableName())
.ToHashSet(StringComparer.Ordinal);
Assert.Equal(45, tableNames.Count);
Assert.Equal(55, tableNames.Count);
Assert.Contains("tenants", tableNames);
Assert.Contains("tenant_settings", tableNames);
Assert.Contains("content_entries", tableNames);
@@ -47,6 +48,16 @@ public sealed class PersistenceModelTests
Assert.Contains("app_assets", tableNames);
Assert.Contains("video_explanations", tableNames);
Assert.Contains("question_videos", tableNames);
Assert.Contains("exam_dates", tableNames);
Assert.Contains("reports", tableNames);
Assert.Contains("report_status_events", tableNames);
Assert.Contains("user_score_events", tableNames);
Assert.Contains("practice_daily_usage", tableNames);
Assert.Contains("practice_access_events", tableNames);
Assert.Contains("practice_session_reports", tableNames);
Assert.Contains("practice_session_report_sections", tableNames);
Assert.Contains("dashboard_daily_stats", tableNames);
Assert.Contains("revenue_daily_stats", tableNames);
Assert.Contains("questions", tableNames);
Assert.Contains("question_versions", tableNames);
Assert.Contains("answer_records", tableNames);
@@ -296,4 +307,67 @@ public sealed class PersistenceModelTests
compositeForeignKeys,
properties => Assert.Contains("TenantId", properties));
}
[Theory]
[InlineData(typeof(ContentNode), nameof(ContentNode.AccessRules), "'{}'::jsonb")]
[InlineData(typeof(QuestionCollection), nameof(QuestionCollection.AccessRules), "'{}'::jsonb")]
[InlineData(typeof(PracticeBlueprint), nameof(PracticeBlueprint.AccessRules), "'{}'::jsonb")]
[InlineData(typeof(PracticeSession), nameof(PracticeSession.AccessSnapshot), "'{}'::jsonb")]
[InlineData(typeof(Report), nameof(Report.Attachments), "'[]'::jsonb")]
[InlineData(typeof(Report), nameof(Report.Metadata), "'{}'::jsonb")]
[InlineData(typeof(PracticeSessionReport), nameof(PracticeSessionReport.QuestionResults), "'[]'::jsonb")]
[InlineData(typeof(PracticeSessionReport), nameof(PracticeSessionReport.WrongQuestionIds), "'[]'::jsonb")]
[InlineData(typeof(PracticeSessionReportSection), nameof(PracticeSessionReportSection.Metadata), "'{}'::jsonb")]
[InlineData(typeof(PracticeDailyUsage), nameof(PracticeDailyUsage.Metadata), "'{}'::jsonb")]
[InlineData(typeof(PracticeAccessEvent), nameof(PracticeAccessEvent.Metadata), "'{}'::jsonb")]
public void Learning_report_json_properties_are_mapped_to_jsonb(
Type entityType,
string propertyName,
string expectedDefaultValueSql)
{
using var context = new TikuDbContext(Options);
var property = context.Model.FindEntityType(entityType)!
.FindProperty(propertyName)!;
Assert.Equal("jsonb", property.GetColumnType());
Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql());
Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType);
}
[Fact]
public void Practice_usage_unique_scope_treats_nulls_as_not_distinct()
{
using var context = new TikuDbContext(Options);
var index = context.GetService<IDesignTimeModel>()
.Model
.FindEntityType(typeof(PracticeDailyUsage))!
.GetIndexes()
.Single(index => index.IsUnique
&& index.Properties.Select(property => property.Name).SequenceEqual([
nameof(PracticeDailyUsage.TenantId),
nameof(PracticeDailyUsage.UserId),
nameof(PracticeDailyUsage.UsageDate),
nameof(PracticeDailyUsage.ScopeType),
nameof(PracticeDailyUsage.ScopeId)
]));
Assert.False(index.GetAreNullsDistinct());
}
[Fact]
public void Practice_report_scores_have_expected_precision()
{
using var context = new TikuDbContext(Options);
var reportType = context.Model.FindEntityType(typeof(PracticeSessionReport))!;
var score = reportType.FindProperty(nameof(PracticeSessionReport.Score))!;
var accuracy = reportType.FindProperty(nameof(PracticeSessionReport.Accuracy))!;
Assert.Equal(10, score.GetPrecision());
Assert.Equal(2, score.GetScale());
Assert.Equal(6, accuracy.GetPrecision());
Assert.Equal(4, accuracy.GetScale());
}
}