diff --git a/Tiku.Api/Contracts/AssetDtos.cs b/Tiku.Api/Contracts/AssetDtos.cs
index ad2df7c..8b3191a 100644
--- a/Tiku.Api/Contracts/AssetDtos.cs
+++ b/Tiku.Api/Contracts/AssetDtos.cs
@@ -37,7 +37,7 @@ public sealed class AssetQueryDto
///
/// 题目 ID。
///
- public Guid? QuestionId { get; set; }
+ public Guid? QuestionPlacementId { get; set; }
///
/// 资产 ID。
@@ -94,7 +94,7 @@ public sealed class AssetQueryDto
SubjectId,
CategoryId,
ContentNodeId,
- QuestionId,
+ QuestionPlacementId,
AssetId,
AssetType,
Category,
diff --git a/Tiku.Api/Contracts/ContentV2Dtos.cs b/Tiku.Api/Contracts/ContentV2Dtos.cs
index ad5afd4..678599a 100644
--- a/Tiku.Api/Contracts/ContentV2Dtos.cs
+++ b/Tiku.Api/Contracts/ContentV2Dtos.cs
@@ -121,7 +121,9 @@ public sealed class QuestionImportPreviewRowDto
public sealed class PreviewQuestionImportV2Dto
{
- [Required][MinLength(1)][MaxLength(2000)]
+ [Required]
+ [MinLength(1)]
+ [MaxLength(2000)]
public IReadOnlyCollection Rows { get; set; } = [];
}
@@ -133,6 +135,8 @@ public sealed class ExecuteQuestionImportRowDto
public sealed class ExecuteQuestionImportV2Dto
{
- [Required][MinLength(1)][MaxLength(2000)]
+ [Required]
+ [MinLength(1)]
+ [MaxLength(2000)]
public IReadOnlyCollection Rows { get; set; } = [];
}
diff --git a/Tiku.Api/Contracts/DirectContentDtos.cs b/Tiku.Api/Contracts/DirectContentDtos.cs
index 1dd3ef5..523801b 100644
--- a/Tiku.Api/Contracts/DirectContentDtos.cs
+++ b/Tiku.Api/Contracts/DirectContentDtos.cs
@@ -56,7 +56,7 @@ public sealed class DirectContentQueryDto
public Guid? MajorId { get; set; }
///
- /// 题目 ID。
+ /// 题目放置 ID。
///
public Guid? QuestionId { get; set; }
@@ -245,37 +245,6 @@ public sealed class DirectQuestionWriteDto
///
public bool? CreateVersion { get; set; }
- public QuestionWriteCommand ToCommand(bool createVersionDefault)
- {
- return new QuestionWriteCommand(
- QuestionId,
- QuestionBankId,
- SubjectId,
- CategoryId,
- NodeId,
- EntryId,
- ContentNodeId,
- PrimaryCollectionId,
- LegacyId,
- Type,
- TypeLabel,
- Difficulty,
- Tags,
- Content,
- Options,
- CorrectOptionIndex,
- CorrectOptionIndices,
- AnswerText,
- Explanation,
- SubQuestions,
- CodeLang,
- CodeTemplate,
- MediaUrl,
- Status,
- ExamMarkers,
- SourceHash,
- CreateVersion ?? createVersionDefault);
- }
}
///
@@ -1048,7 +1017,7 @@ public sealed class DirectQuestionVideoDto
///
/// 题目 ID。
///
- public Guid QuestionId { get; set; }
+ public Guid QuestionPlacementId { get; set; }
///
/// 视频 ID。
@@ -1077,7 +1046,7 @@ public sealed class DirectQuestionVideoDto
public QuestionVideoCommand ToCommand()
{
- return new QuestionVideoCommand(QuestionId, VideoId, LegacyId, VideoType, Order, Metadata);
+ return new QuestionVideoCommand(QuestionPlacementId, VideoId, LegacyId, VideoType, Order, Metadata);
}
}
@@ -1363,4 +1332,4 @@ public sealed class DirectImportJobDto
/// 任务 ID。
///
public Guid JobId { get; set; }
-}
\ No newline at end of file
+}
diff --git a/Tiku.Api/Contracts/VideoDtos.cs b/Tiku.Api/Contracts/VideoDtos.cs
index b2c77a9..46129dd 100644
--- a/Tiku.Api/Contracts/VideoDtos.cs
+++ b/Tiku.Api/Contracts/VideoDtos.cs
@@ -44,13 +44,13 @@ public sealed class VideoPlayDto
public Guid VideoId { get; set; }
///
- /// 题目 ID。
+ /// 题目放置 ID。
///
- public Guid? QuestionId { get; set; }
+ public Guid? QuestionPlacementId { get; set; }
public VideoPlayCommand ToCommand()
{
- return new VideoPlayCommand(VideoId, QuestionId);
+ return new VideoPlayCommand(VideoId, QuestionPlacementId);
}
}
@@ -66,9 +66,9 @@ public sealed class VideoProgressDto
public Guid VideoId { get; set; }
///
- /// 题目 ID。
+ /// 题目放置 ID。
///
- public Guid? QuestionId { get; set; }
+ public Guid? QuestionPlacementId { get; set; }
///
/// 播放位置,单位为秒。
@@ -102,7 +102,7 @@ public sealed class VideoProgressDto
{
return new VideoProgressCommand(
VideoId,
- QuestionId,
+ QuestionPlacementId,
PositionSeconds,
DurationSeconds,
WatchedSeconds,
@@ -117,15 +117,15 @@ public sealed class VideoProgressDto
public sealed class QuestionVideoQueryDto
{
///
- /// 题目 ID。
+ /// 题目放置 ID。
///
- public Guid? QuestionId { get; set; }
+ public Guid? QuestionPlacementId { get; set; }
///
- /// 题目 ID 列表。
+ /// 题目放置 ID 列表。
///
[MaxLength(100)]
- public IReadOnlyCollection? QuestionIds { get; set; }
+ public IReadOnlyCollection? QuestionPlacementIds { get; set; }
///
/// 返回数量上限。
@@ -135,6 +135,6 @@ public sealed class QuestionVideoQueryDto
public QuestionVideoQuery ToQuery()
{
- return new QuestionVideoQuery(QuestionId, QuestionIds, Limit);
+ return new QuestionVideoQuery(QuestionPlacementId, QuestionPlacementIds, Limit);
}
-}
\ No newline at end of file
+}
diff --git a/Tiku.Api/Controllers/QuestionVideosController.cs b/Tiku.Api/Controllers/QuestionVideosController.cs
index fc52ddc..0292999 100644
--- a/Tiku.Api/Controllers/QuestionVideosController.cs
+++ b/Tiku.Api/Controllers/QuestionVideosController.cs
@@ -13,14 +13,14 @@ namespace Tiku.Api.Controllers;
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
[RequireSaasFeature(SaasFeatureCatalog.Video)]
[Produces("application/json")]
-[Route("api/student/questions/videos")]
+[Route("api/student/question-placements/videos")]
public sealed class QuestionVideosController(
IVideoPlaybackService videoPlaybackService,
ICurrentUser currentUser,
ITenantContext currentTenant) : ControllerBase
{
[HttpGet]
- [EndpointSummary("查询单道题目的解析视频")]
+ [EndpointSummary("查询单个题目放置的解析视频")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> List(
[FromQuery] QuestionVideoQueryDto query,
@@ -31,7 +31,7 @@ public sealed class QuestionVideosController(
}
[HttpPost("batch")]
- [EndpointSummary("批量查询题目解析视频")]
+ [EndpointSummary("批量查询题目放置的解析视频")]
[ProducesResponseType>(StatusCodes.Status200OK)]
public async Task>> Batch(
QuestionVideoQueryDto request,
@@ -48,4 +48,4 @@ public sealed class QuestionVideosController(
return new VideoPlaybackActor(currentTenant.TenantId.Value, currentUser.UserId.Value);
}
-}
\ No newline at end of file
+}
diff --git a/Tiku.Api/Modules/Student/Content/Errors/ContentExceptionProblemDetailsMapper.cs b/Tiku.Api/Modules/Student/Content/Errors/ContentExceptionProblemDetailsMapper.cs
index 5e9eb6b..33d023c 100644
--- a/Tiku.Api/Modules/Student/Content/Errors/ContentExceptionProblemDetailsMapper.cs
+++ b/Tiku.Api/Modules/Student/Content/Errors/ContentExceptionProblemDetailsMapper.cs
@@ -11,8 +11,8 @@ internal sealed class ContentExceptionProblemDetailsMapper : ExceptionProblemDet
{
public override IReadOnlyCollection HandledExceptionTypes { get; } =
[
- typeof(PublicQuestionAccessDeniedException), typeof(QuestionLocatorException), typeof(RequiredFieldException),
- typeof(ContentNavigationNotFoundException), typeof(QuestionBankRequiredFieldException),
+ typeof(RequiredFieldException), typeof(ContentNavigationNotFoundException),
+ typeof(QuestionBankRequiredFieldException),
typeof(QuestionBankNotFoundException), typeof(AssetAccessException), typeof(AssetManagementException),
typeof(VideoPlaybackException), typeof(ContentManagementException), typeof(ScorelineQueryException),
typeof(ProfileException), typeof(ContentV2Exception)
@@ -22,10 +22,6 @@ internal sealed class ContentExceptionProblemDetailsMapper : ExceptionProblemDet
{
return exception switch
{
- PublicQuestionAccessDeniedException denied => Mapped(denied, StatusCodes.Status403Forbidden, denied.Code,
- out mapping),
- QuestionLocatorException locator => Mapped(locator, StatusCodes.Status404NotFound, locator.Code,
- out mapping),
RequiredFieldException => Mapped(exception, StatusCodes.Status400BadRequest, "required_field", out mapping),
ContentNavigationNotFoundException => Mapped(exception, StatusCodes.Status404NotFound,
"content_navigation_not_found", out mapping),
diff --git a/Tiku.Application/Assets/AssetQueryModels.cs b/Tiku.Application/Assets/AssetQueryModels.cs
index ae97d09..a195368 100644
--- a/Tiku.Application/Assets/AssetQueryModels.cs
+++ b/Tiku.Application/Assets/AssetQueryModels.cs
@@ -9,7 +9,7 @@ public sealed record AssetFilter(
Guid? SubjectId = null,
Guid? CategoryId = null,
Guid? ContentNodeId = null,
- Guid? QuestionId = null,
+ Guid? QuestionPlacementId = null,
Guid? AssetId = null,
string? AssetType = null,
string? Category = null,
@@ -88,8 +88,8 @@ public sealed record VideoExplanationCatalogItem(
public sealed record QuestionVideoCatalogItem(
Guid Id,
string? LegacyId,
- Guid? QuestionId,
- Guid? VideoId,
+ Guid QuestionPlacementId,
+ Guid VideoId,
QuestionVideoType VideoType,
int Order,
JsonElement Metadata,
diff --git a/Tiku.Application/Assets/VideoPlaybackModels.cs b/Tiku.Application/Assets/VideoPlaybackModels.cs
index 19f2074..647afc2 100644
--- a/Tiku.Application/Assets/VideoPlaybackModels.cs
+++ b/Tiku.Application/Assets/VideoPlaybackModels.cs
@@ -11,15 +11,15 @@ public sealed record VideoSearchQuery(
int? Limit = null);
public sealed record QuestionVideoQuery(
- Guid? QuestionId = null,
- IReadOnlyCollection? QuestionIds = null,
+ Guid? QuestionPlacementId = null,
+ IReadOnlyCollection? QuestionPlacementIds = null,
int? Limit = null);
-public sealed record VideoPlayCommand(Guid VideoId, Guid? QuestionId = null);
+public sealed record VideoPlayCommand(Guid VideoId, Guid? QuestionPlacementId = null);
public sealed record VideoProgressCommand(
Guid VideoId,
- Guid? QuestionId,
+ Guid? QuestionPlacementId,
int PositionSeconds,
int? DurationSeconds,
int? WatchedSeconds,
@@ -28,7 +28,7 @@ public sealed record VideoProgressCommand(
public sealed record VideoPlaybackItem(
Guid VideoId,
- Guid? QuestionId,
+ Guid? QuestionPlacementId,
string Title,
string? Description,
string? PlayUrl,
@@ -40,7 +40,7 @@ public sealed record VideoPlaybackItem(
public sealed record VideoProgressItem(
Guid Id,
Guid VideoId,
- Guid? QuestionId,
+ Guid? QuestionPlacementId,
int PositionSeconds,
int? DurationSeconds,
int WatchedSeconds,
@@ -76,4 +76,4 @@ public interface IVideoPlaybackService
public sealed class VideoPlaybackException(string message, string code) : Exception(message)
{
public string Code { get; } = code;
-}
\ No newline at end of file
+}
diff --git a/Tiku.Application/Content/DirectContentModels.cs b/Tiku.Application/Content/DirectContentModels.cs
index fff70f7..d15e5a0 100644
--- a/Tiku.Application/Content/DirectContentModels.cs
+++ b/Tiku.Application/Content/DirectContentModels.cs
@@ -3,7 +3,6 @@ using Tiku.Application.Assets;
using Tiku.Application.Catalog;
using Tiku.Domain.Catalog;
using Tiku.Domain.Content;
-using Tiku.Domain.QuestionBanks;
namespace Tiku.Application.Content;
@@ -25,63 +24,6 @@ public sealed record AdminLimitFilter(
int? Year = null,
int? Limit = null);
-public sealed record QuestionWriteCommand(
- Guid? QuestionId,
- Guid? QuestionBankId,
- Guid? SubjectId,
- Guid? CategoryId,
- Guid? NodeId,
- Guid? EntryId,
- Guid? ContentNodeId,
- Guid? PrimaryCollectionId,
- string? LegacyId,
- string? Type,
- string? TypeLabel,
- int? Difficulty,
- JsonElement Tags,
- string? Content,
- JsonElement Options,
- int? CorrectOptionIndex,
- JsonElement CorrectOptionIndices,
- string? AnswerText,
- string? Explanation,
- JsonElement SubQuestions,
- string? CodeLang,
- string? CodeTemplate,
- string? MediaUrl,
- string? Status,
- JsonElement ExamMarkers,
- string? SourceHash,
- bool CreateVersion);
-
-public sealed record QuestionManagementItem(
- Guid Id,
- Guid? VersionId,
- Guid? QuestionBankId,
- Guid? SubjectId,
- Guid? CategoryId,
- Guid? NodeId,
- Guid? EntryId,
- Guid? ContentNodeId,
- Guid? PrimaryCollectionId,
- string? LegacyId,
- string Type,
- string? TypeLabel,
- int? Difficulty,
- JsonElement Tags,
- string? Content,
- JsonElement Options,
- int? CorrectOptionIndex,
- JsonElement CorrectOptionIndices,
- string? AnswerText,
- string? Explanation,
- JsonElement SubQuestions,
- string? CodeLang,
- string? CodeTemplate,
- string? MediaUrl,
- bool HasVideoExplanation,
- QuestionStatus Status);
-
public sealed record VocabularyUnitCommand(
Guid? Id,
Guid? RegionId,
@@ -219,7 +161,7 @@ public sealed record VideoExplanationCommand(
JsonElement Metadata);
public sealed record QuestionVideoCommand(
- Guid QuestionId,
+ Guid QuestionPlacementId,
Guid VideoId,
string? LegacyId,
string? VideoType,
@@ -292,8 +234,8 @@ public sealed record VideoManagementItem(
public sealed record QuestionVideoManagementItem(
Guid Id,
- Guid? QuestionId,
- Guid? VideoId,
+ Guid QuestionPlacementId,
+ Guid VideoId,
string? LegacyId,
QuestionVideoType VideoType,
int Order,
@@ -317,16 +259,6 @@ public sealed record OperationContentItem(
public sealed record ScorelineTrendItem(int Year, int SchoolCount, int MajorCount);
-public interface IQuestionManagementService
-{
- Task> CreateQuestionAsync(DirectContentActor actor,
- QuestionWriteCommand command, CancellationToken cancellationToken = default);
-
- Task> UpdateQuestionAsync(DirectContentActor actor,
- QuestionWriteCommand command, CancellationToken cancellationToken = default);
-
-}
-
public interface IVocabularyManagementService
{
Task> GetVocabularyUnitsAsync(DirectContentActor actor, AdminLimitFilter filter,
diff --git a/Tiku.Application/QuestionBanks/IQuestionReferenceService.cs b/Tiku.Application/QuestionBanks/IQuestionReferenceService.cs
deleted file mode 100644
index f1a28d8..0000000
--- a/Tiku.Application/QuestionBanks/IQuestionReferenceService.cs
+++ /dev/null
@@ -1,27 +0,0 @@
-using Tiku.Domain.Content;
-
-namespace Tiku.Application.QuestionBanks;
-
-public interface IPublicQuestionAccessPolicy
-{
- Task EnsureCanStartAsync(Guid tenantId, CancellationToken cancellationToken = default);
-}
-
-public interface IQuestionReferenceService
-{
- Task ResolveAsync(
- Guid tenantId,
- Guid? userId,
- QuestionLocator locator,
- CancellationToken cancellationToken = default);
-}
-
-public sealed class PublicQuestionAccessDeniedException(string code, string message) : Exception(message)
-{
- public string Code { get; } = code;
-}
-
-public sealed class QuestionLocatorException(string code, string message) : Exception(message)
-{
- public string Code { get; } = code;
-}
\ No newline at end of file
diff --git a/Tiku.Domain/Content/AssetEntities.cs b/Tiku.Domain/Content/AssetEntities.cs
index b5e9620..c5f2265 100644
--- a/Tiku.Domain/Content/AssetEntities.cs
+++ b/Tiku.Domain/Content/AssetEntities.cs
@@ -145,8 +145,8 @@ public sealed class VideoExplanation : AuditableTenantEntity
public sealed class QuestionVideo : AuditableTenantEntity
{
- public Guid? QuestionId { get; set; }
- public Guid? VideoId { get; set; }
+ public Guid QuestionPlacementId { get; set; }
+ public Guid VideoId { get; set; }
public string? LegacyId { get; set; }
public string? LegacyQuestionId { get; set; }
public string? LegacyVideoId { get; set; }
@@ -159,7 +159,7 @@ public sealed class VideoPlaybackProgress : AuditableTenantEntity
{
public Guid UserId { get; set; }
public Guid VideoId { get; set; }
- public Guid? QuestionId { get; set; }
+ public Guid? QuestionPlacementId { get; set; }
public int PositionSeconds { get; set; }
public int? DurationSeconds { get; set; }
public int WatchedSeconds { get; set; }
@@ -268,4 +268,4 @@ public enum QuestionVideoType
Specific,
General,
Related
-}
\ No newline at end of file
+}
diff --git a/Tiku.Infrastructure/Assets/AssetQueryService.cs b/Tiku.Infrastructure/Assets/AssetQueryService.cs
index e881b07..93db504 100644
--- a/Tiku.Infrastructure/Assets/AssetQueryService.cs
+++ b/Tiku.Infrastructure/Assets/AssetQueryService.cs
@@ -240,7 +240,8 @@ public sealed class AssetQueryService(IContentAssetPersistence dbContext) : IAss
.AsNoTracking()
.Where(video => video.TenantId == filter.TenantId);
- if (filter.QuestionId.HasValue) query = query.Where(video => video.QuestionId == filter.QuestionId.Value);
+ if (filter.QuestionPlacementId.HasValue)
+ query = query.Where(video => video.QuestionPlacementId == filter.QuestionPlacementId.Value);
if (filter.AssetId.HasValue) query = query.Where(video => video.VideoId == filter.AssetId.Value);
@@ -252,7 +253,7 @@ public sealed class AssetQueryService(IContentAssetPersistence dbContext) : IAss
.GroupJoin(
videoExplanations,
questionVideo => new { questionVideo.TenantId, Id = questionVideo.VideoId },
- video => new { video.TenantId, Id = (Guid?)video.Id },
+ video => new { video.TenantId, Id = video.Id },
(questionVideo, videos) => new
{
QuestionVideo = questionVideo,
@@ -261,7 +262,7 @@ public sealed class AssetQueryService(IContentAssetPersistence dbContext) : IAss
.Select(row => new QuestionVideoCatalogItem(
row.QuestionVideo.Id,
row.QuestionVideo.LegacyId,
- row.QuestionVideo.QuestionId,
+ row.QuestionVideo.QuestionPlacementId,
row.QuestionVideo.VideoId,
row.QuestionVideo.VideoType,
row.QuestionVideo.SortOrder,
diff --git a/Tiku.Infrastructure/Assets/VideoPlaybackService.cs b/Tiku.Infrastructure/Assets/VideoPlaybackService.cs
index 7b8ce9a..aa91fcd 100644
--- a/Tiku.Infrastructure/Assets/VideoPlaybackService.cs
+++ b/Tiku.Infrastructure/Assets/VideoPlaybackService.cs
@@ -47,11 +47,12 @@ public sealed class VideoPlaybackService(IContentAssetPersistence contentAssetPe
{
await AssertActiveMemberAsync(actor, cancellationToken);
var video = await ResolveVideoAsync(actor.TenantId, command.VideoId, cancellationToken);
- if (command.QuestionId.HasValue)
- await AssertQuestionVideoAsync(actor.TenantId, command.QuestionId.Value, command.VideoId,
+ if (command.QuestionPlacementId.HasValue)
+ await AssertQuestionVideoAsync(actor.TenantId, command.QuestionPlacementId.Value, command.VideoId,
cancellationToken);
- var progress = await ResolveProgressAsync(actor, command.VideoId, command.QuestionId, cancellationToken);
+ var progress = await ResolveProgressAsync(actor, command.VideoId, command.QuestionPlacementId,
+ cancellationToken);
progress.PlayCount++;
progress.LastPlayedAt = DateTimeOffset.UtcNow;
await contentAssetPersistence.SaveChangesAsync(cancellationToken);
@@ -67,7 +68,7 @@ public sealed class VideoPlaybackService(IContentAssetPersistence contentAssetPe
Metadata = JsonSerializer.SerializeToElement(new
{
videoId = video.Id,
- command.QuestionId,
+ command.QuestionPlacementId,
source = "student_video_play"
})
});
@@ -75,7 +76,7 @@ public sealed class VideoPlaybackService(IContentAssetPersistence contentAssetPe
return new VideoPlaybackItem(
video.Id,
- command.QuestionId,
+ command.QuestionPlacementId,
video.Title,
video.Description,
video.VideoUrl,
@@ -92,11 +93,12 @@ public sealed class VideoPlaybackService(IContentAssetPersistence contentAssetPe
{
await AssertActiveMemberAsync(actor, cancellationToken);
var video = await ResolveVideoAsync(actor.TenantId, command.VideoId, cancellationToken);
- if (command.QuestionId.HasValue)
- await AssertQuestionVideoAsync(actor.TenantId, command.QuestionId.Value, command.VideoId,
+ if (command.QuestionPlacementId.HasValue)
+ await AssertQuestionVideoAsync(actor.TenantId, command.QuestionPlacementId.Value, command.VideoId,
cancellationToken);
- var progress = await ResolveProgressAsync(actor, command.VideoId, command.QuestionId, cancellationToken);
+ var progress = await ResolveProgressAsync(actor, command.VideoId, command.QuestionPlacementId,
+ cancellationToken);
var positionSeconds = Math.Max(command.PositionSeconds, 0);
var durationSeconds = command.DurationSeconds ?? video.DurationSeconds;
var watchedSeconds = Math.Max(command.WatchedSeconds ?? positionSeconds, progress.WatchedSeconds);
@@ -123,12 +125,13 @@ public sealed class VideoPlaybackService(IContentAssetPersistence contentAssetPe
await AssertActiveMemberAsync(actor, cancellationToken);
var questionVideos = contentAssetPersistence.QuestionVideos.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId);
- if (query.QuestionId.HasValue)
- questionVideos = questionVideos.Where(item => item.QuestionId == query.QuestionId.Value);
-
- if (query.QuestionIds is { Count: > 0 })
+ if (query.QuestionPlacementId.HasValue)
questionVideos = questionVideos.Where(item =>
- item.QuestionId.HasValue && query.QuestionIds.Contains(item.QuestionId.Value));
+ item.QuestionPlacementId == query.QuestionPlacementId.Value);
+
+ if (query.QuestionPlacementIds is { Count: > 0 })
+ questionVideos = questionVideos.Where(item =>
+ query.QuestionPlacementIds.Contains(item.QuestionPlacementId));
var videos = contentAssetPersistence.VideoExplanations.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.IsActive);
@@ -139,7 +142,7 @@ public sealed class VideoPlaybackService(IContentAssetPersistence contentAssetPe
.GroupJoin(
videos,
questionVideo => new { questionVideo.TenantId, Id = questionVideo.VideoId },
- video => new { video.TenantId, Id = (Guid?)video.Id },
+ video => new { video.TenantId, Id = video.Id },
(questionVideo, matchedVideos) => new
{
QuestionVideo = questionVideo,
@@ -148,7 +151,7 @@ public sealed class VideoPlaybackService(IContentAssetPersistence contentAssetPe
.Select(row => new QuestionVideoCatalogItem(
row.QuestionVideo.Id,
row.QuestionVideo.LegacyId,
- row.QuestionVideo.QuestionId,
+ row.QuestionVideo.QuestionPlacementId,
row.QuestionVideo.VideoId,
row.QuestionVideo.VideoType,
row.QuestionVideo.SortOrder,
@@ -161,7 +164,7 @@ public sealed class VideoPlaybackService(IContentAssetPersistence contentAssetPe
private async Task ResolveProgressAsync(
VideoPlaybackActor actor,
Guid videoId,
- Guid? questionId,
+ Guid? questionPlacementId,
CancellationToken cancellationToken)
{
var progress = await contentAssetPersistence.VideoPlaybackProgress.SingleOrDefaultAsync(
@@ -169,7 +172,7 @@ public sealed class VideoPlaybackService(IContentAssetPersistence contentAssetPe
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.VideoId == videoId &&
- item.QuestionId == questionId,
+ item.QuestionPlacementId == questionPlacementId,
cancellationToken);
if (progress is not null) return progress;
@@ -178,7 +181,7 @@ public sealed class VideoPlaybackService(IContentAssetPersistence contentAssetPe
TenantId = actor.TenantId,
UserId = actor.UserId,
VideoId = videoId,
- QuestionId = questionId,
+ QuestionPlacementId = questionPlacementId,
LastPlayedAt = DateTimeOffset.UtcNow,
Metadata = JsonDefaults.Object()
};
@@ -199,14 +202,14 @@ public sealed class VideoPlaybackService(IContentAssetPersistence contentAssetPe
private async Task AssertQuestionVideoAsync(
Guid tenantId,
- Guid questionId,
+ Guid questionPlacementId,
Guid videoId,
CancellationToken cancellationToken)
{
var exists = await contentAssetPersistence.QuestionVideos.AnyAsync(
item =>
item.TenantId == tenantId &&
- item.QuestionId == questionId &&
+ item.QuestionPlacementId == questionPlacementId &&
item.VideoId == videoId,
cancellationToken);
if (!exists) throw new VideoPlaybackException("Question video was not found.", "question_video_not_found");
@@ -248,7 +251,7 @@ public sealed class VideoPlaybackService(IContentAssetPersistence contentAssetPe
return new VideoProgressItem(
progress.Id,
progress.VideoId,
- progress.QuestionId,
+ progress.QuestionPlacementId,
progress.PositionSeconds,
progress.DurationSeconds,
progress.WatchedSeconds,
@@ -258,4 +261,4 @@ public sealed class VideoPlaybackService(IContentAssetPersistence contentAssetPe
progress.PlayCount,
progress.Metadata);
}
-}
\ No newline at end of file
+}
diff --git a/Tiku.Infrastructure/Content/Exports/ContentExportJobHandler.cs b/Tiku.Infrastructure/Content/Exports/ContentExportJobHandler.cs
index 09408f8..8fad953 100644
--- a/Tiku.Infrastructure/Content/Exports/ContentExportJobHandler.cs
+++ b/Tiku.Infrastructure/Content/Exports/ContentExportJobHandler.cs
@@ -37,10 +37,12 @@ internal sealed class ContentExportJobHandler(IContentAssetPersistence contentAs
contentAssetPersistence.ContentAssets.Add(asset);
}
- var questionBankCount =
- await questionBankPersistence.QuestionBanks.CountAsync(item => item.TenantId == context.TenantId, cancellationToken);
- var questionCount =
- await questionBankPersistence.Questions.CountAsync(item => item.TenantId == context.TenantId, cancellationToken);
+ var contentReleaseCount =
+ await questionBankPersistence.ContentReleases.CountAsync(item => item.TenantId == context.TenantId,
+ cancellationToken);
+ var questionAssetCount =
+ await questionBankPersistence.QuestionAssets.CountAsync(item => item.TenantId == context.TenantId,
+ cancellationToken);
var studentCount =
await tenantAdministrationPersistence.StudentProfiles.CountAsync(item => item.TenantId == context.TenantId, cancellationToken);
asset.FileName = $"content-export-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}.json";
@@ -52,8 +54,8 @@ internal sealed class ContentExportJobHandler(IContentAssetPersistence contentAs
{
exportType,
generatedAt = DateTimeOffset.UtcNow,
- questionBankCount,
- questionCount,
+ contentReleaseCount,
+ questionAssetCount,
studentCount,
payload = context.Payload
});
@@ -63,8 +65,8 @@ internal sealed class ContentExportJobHandler(IContentAssetPersistence contentAs
{
outputAssetId = asset.Id,
asset.AssetKey,
- questionBankCount,
- questionCount,
+ contentReleaseCount,
+ questionAssetCount,
studentCount
}),
asset.Id);
diff --git a/Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.MappingAndValidation.cs b/Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.MappingAndValidation.cs
index 369ee36..22fdee4 100644
--- a/Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.MappingAndValidation.cs
+++ b/Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.MappingAndValidation.cs
@@ -7,43 +7,11 @@ using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.Learning;
using Tiku.Domain.Operations;
-using Tiku.Domain.QuestionBanks;
namespace Tiku.Infrastructure.Content;
internal abstract partial class DirectContentServiceBase
{
- protected static QuestionManagementItem ToQuestionItem(Question question, QuestionVersion? version)
- {
- return new QuestionManagementItem(
- question.Id,
- version?.Id,
- question.QuestionBankId,
- question.SubjectId,
- question.CategoryId,
- question.NodeId,
- question.EntryId,
- question.ContentNodeId,
- question.PrimaryCollectionId,
- question.LegacyId,
- question.Type,
- question.TypeLabel,
- question.Difficulty,
- question.Tags,
- version?.Content,
- version?.Options ?? JsonDefaults.Array(),
- version?.CorrectOptionIndex,
- version?.CorrectOptionIndices ?? JsonDefaults.Array(),
- version?.AnswerText,
- version?.Explanation,
- version?.SubQuestions ?? JsonDefaults.Array(),
- version?.CodeLang,
- version?.CodeTemplate,
- question.MediaUrl,
- question.HasVideoExplanation,
- question.Status);
- }
-
protected static VideoManagementItem ToVideoItem(VideoExplanation item)
{
return new VideoManagementItem(
@@ -67,7 +35,7 @@ internal abstract partial class DirectContentServiceBase
{
return new QuestionVideoManagementItem(
item.Id,
- item.QuestionId,
+ item.QuestionPlacementId,
item.VideoId,
item.LegacyId,
item.VideoType,
diff --git a/Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.Writes.cs b/Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.Writes.cs
index 88db95f..55bd617 100644
--- a/Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.Writes.cs
+++ b/Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.Writes.cs
@@ -2,13 +2,11 @@ using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Content;
using Tiku.Application.Learning;
-using Tiku.Application.QuestionBanks;
using Tiku.Domain.Catalog;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.Learning;
using Tiku.Domain.Operations;
-using Tiku.Domain.QuestionBanks;
namespace Tiku.Infrastructure.Content;
@@ -104,135 +102,6 @@ internal abstract partial class DirectContentServiceBase
return item;
}
- protected static void ApplyQuestion(Question question, QuestionWriteCommand command)
- {
- question.QuestionBankId = command.QuestionBankId;
- question.SubjectId = command.SubjectId;
- question.CategoryId = command.CategoryId;
- question.NodeId = command.NodeId;
- question.EntryId = command.EntryId;
- question.ContentNodeId = command.ContentNodeId;
- question.PrimaryCollectionId = command.PrimaryCollectionId;
- question.LegacyId = Normalize(command.LegacyId);
- question.Type = Normalize(command.Type) ?? question.Type;
- question.TypeLabel = Normalize(command.TypeLabel);
- question.Difficulty = command.Difficulty;
- question.Tags = JsonArrayOrDefault(command.Tags);
- question.ExamMarkers = JsonObjectOrDefault(command.ExamMarkers);
- question.MediaUrl = Normalize(command.MediaUrl);
- question.Status = Parse(command.Status, QuestionStatus.Published, "question_status_invalid");
- }
-
- protected static void ValidateQuestionForPublication(QuestionWriteCommand command)
- {
- var status = Parse(command.Status, QuestionStatus.Published, "question_status_invalid");
- if (status != QuestionStatus.Published) return;
-
- var type = Normalize(command.Type) ?? "choice";
- if (!QuestionGrader.HasValidAuthoritativeAnswer(
- type,
- command.CorrectOptionIndex,
- command.CorrectOptionIndices,
- command.AnswerText))
- throw new ContentManagementException(
- "Published questions require a valid authoritative answer.",
- "question_grading_rule_invalid");
- }
-
- protected static QuestionVersion BuildQuestionVersion(
- DirectContentActor actor,
- Guid questionId,
- int versionNo,
- QuestionWriteCommand command)
- {
- var version = new QuestionVersion
- {
- TenantId = actor.TenantId,
- QuestionId = questionId,
- VersionNo = versionNo,
- CreatedBy = actor.UserId
- };
- ApplyQuestionVersion(version, command);
- return version;
- }
-
- protected static void ApplyQuestionVersion(QuestionVersion version, QuestionWriteCommand command)
- {
- version.QuestionType = Normalize(command.Type) ?? "choice";
- version.TypeLabel = Normalize(command.TypeLabel);
- version.Difficulty = command.Difficulty;
- version.Tags = JsonArrayOrDefault(command.Tags);
- version.Content = Normalize(command.Content);
- version.Options = JsonArrayOrDefault(command.Options);
- version.CorrectOptionIndex = command.CorrectOptionIndex;
- version.CorrectOptionIndices = JsonArrayOrDefault(command.CorrectOptionIndices);
- version.AnswerText = Normalize(command.AnswerText);
- version.Explanation = Normalize(command.Explanation);
- version.SubQuestions = JsonArrayOrDefault(command.SubQuestions);
- version.CodeLang = Normalize(command.CodeLang);
- version.CodeTemplate = Normalize(command.CodeTemplate);
- version.SourceHash = Normalize(command.SourceHash);
- }
-
- protected async Task AssertQuestionReferencesAsync(Guid tenantId, QuestionWriteCommand command,
- CancellationToken cancellationToken)
- {
- await AssertReferenceAsync(tenantId, command.QuestionBankId, "question_bank_not_found",
- cancellationToken);
- await AssertReferenceAsync(tenantId, command.SubjectId, "subject_not_found", cancellationToken);
- await AssertReferenceAsync(tenantId, command.CategoryId, "category_not_found", cancellationToken);
- await AssertReferenceAsync(tenantId, command.NodeId, "module_node_not_found", cancellationToken);
- await AssertReferenceAsync(tenantId, command.EntryId, "entry_not_found", cancellationToken);
- await AssertReferenceAsync(tenantId, command.ContentNodeId, "node_not_found", cancellationToken);
- await AssertReferenceAsync(tenantId, command.PrimaryCollectionId, "collection_not_found",
- cancellationToken);
- }
-
- protected async Task SyncPrimaryCollectionItemAsync(
- DirectContentActor actor,
- Question question,
- CancellationToken cancellationToken)
- {
- if (!question.PrimaryCollectionId.HasValue) return;
-
- var existing = await questionBankPersistence.QuestionCollectionItems.SingleOrDefaultAsync(
- item =>
- item.TenantId == actor.TenantId &&
- item.CollectionId == question.PrimaryCollectionId.Value &&
- item.QuestionId == question.Id,
- cancellationToken);
- if (existing is null)
- {
- var reference = await questionReferenceService.ResolveAsync(
- actor.TenantId,
- actor.UserId,
- new QuestionLocator(QuestionSource.Tenant, question.Id),
- cancellationToken);
- var nextOrder = await questionBankPersistence.QuestionCollectionItems
- .Where(item =>
- item.TenantId == actor.TenantId && item.CollectionId == question.PrimaryCollectionId.Value)
- .Select(item => (int?)item.SortOrder)
- .MaxAsync(cancellationToken) ?? -1;
- questionBankPersistence.QuestionCollectionItems.Add(new QuestionCollectionItem
- {
- TenantId = actor.TenantId,
- CollectionId = question.PrimaryCollectionId.Value,
- QuestionReferenceId = reference.Id,
- QuestionOwnerTenantId = reference.QuestionOwnerTenantId,
- QuestionId = question.Id,
- SortOrder = nextOrder + 1
- });
- }
-
- var collection = await questionBankPersistence.QuestionCollections.SingleAsync(
- item => item.TenantId == actor.TenantId && item.Id == question.PrimaryCollectionId.Value,
- cancellationToken);
- collection.QuestionCount = await questionBankPersistence.QuestionCollectionItems.CountAsync(
- item => item.TenantId == actor.TenantId && item.CollectionId == question.PrimaryCollectionId.Value,
- cancellationToken) + (existing is null ? 1 : 0);
- collection.UpdatedBy = actor.UserId;
- }
-
protected async Task<(Guid? EntryId, Guid? ContentNodeId)> ResolveVocabularyNavigationAsync(
Guid tenantId,
Guid? unitId,
diff --git a/Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.cs b/Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.cs
index 6b2556a..23fb8f3 100644
--- a/Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.cs
+++ b/Tiku.Infrastructure/Content/Foundation/DirectContentFoundation.cs
@@ -1,6 +1,5 @@
using System.Text.RegularExpressions;
using Tiku.Application.Content;
-using Tiku.Application.QuestionBanks;
using Tiku.Application.Security;
using Tiku.Infrastructure.Persistence;
@@ -13,7 +12,6 @@ internal sealed record DirectContentServiceDependencies(
ILearningPersistence LearningPersistence,
ITenantAdministrationPersistence TenantAdministrationPersistence,
IJobsOperationsPersistence JobsOperationsPersistence,
- IQuestionReferenceService QuestionReferenceService,
ICurrentAccessContext CurrentAccessContext,
IFeatureAccessService FeatureAccessService);
@@ -29,7 +27,6 @@ internal abstract partial class DirectContentServiceBase(DirectContentServiceDep
protected IJobsOperationsPersistence jobsOperationsPersistence { get; } = dependencies.JobsOperationsPersistence;
protected IModulePersistence unitOfWork { get; } = dependencies.QuestionBankPersistence;
- protected IQuestionReferenceService questionReferenceService { get; } = dependencies.QuestionReferenceService;
protected ICurrentAccessContext currentAccessContext { get; } = dependencies.CurrentAccessContext;
protected IFeatureAccessService featureAccessService { get; } = dependencies.FeatureAccessService;
diff --git a/Tiku.Infrastructure/Content/Videos/VideoManagementService.cs b/Tiku.Infrastructure/Content/Videos/VideoManagementService.cs
index b4393c1..4933272 100644
--- a/Tiku.Infrastructure/Content/Videos/VideoManagementService.cs
+++ b/Tiku.Infrastructure/Content/Videos/VideoManagementService.cs
@@ -3,7 +3,6 @@ using Tiku.Application.Catalog;
using Tiku.Application.Content;
using Tiku.Domain.Catalog;
using Tiku.Domain.Content;
-using Tiku.Domain.QuestionBanks;
namespace Tiku.Infrastructure.Content;
@@ -72,17 +71,19 @@ internal sealed class VideoManagementService(DirectContentServiceDependencies de
QuestionVideoCommand command,
CancellationToken cancellationToken = default)
{
- await AssertReferenceAsync(actor.TenantId, command.QuestionId, "question_not_found",
+ await AssertReferenceAsync(actor.TenantId, command.QuestionPlacementId,
+ "question_placement_not_found",
cancellationToken);
await AssertReferenceAsync(actor.TenantId, command.VideoId, "video_not_found",
cancellationToken);
var item = await contentAssetPersistence.QuestionVideos.SingleOrDefaultAsync(
- link => link.TenantId == actor.TenantId && link.QuestionId == command.QuestionId &&
+ link => link.TenantId == actor.TenantId &&
+ link.QuestionPlacementId == command.QuestionPlacementId &&
link.VideoId == command.VideoId,
cancellationToken);
var isNew = item is null;
item ??= new QuestionVideo { TenantId = actor.TenantId };
- item.QuestionId = command.QuestionId;
+ item.QuestionPlacementId = command.QuestionPlacementId;
item.VideoId = command.VideoId;
item.LegacyId = Normalize(command.LegacyId);
item.VideoType = Parse(command.VideoType, QuestionVideoType.Specific, "question_video_type_invalid");
@@ -90,10 +91,6 @@ internal sealed class VideoManagementService(DirectContentServiceDependencies de
item.Metadata = JsonObjectOrDefault(command.Metadata);
if (isNew) contentAssetPersistence.QuestionVideos.Add(item);
- var question = await questionBankPersistence.Questions.SingleAsync(
- question => question.TenantId == actor.TenantId && question.Id == command.QuestionId,
- cancellationToken);
- question.HasVideoExplanation = true;
await unitOfWork.SaveChangesAsync(cancellationToken);
return new ContentManagementResult(ToQuestionVideoItem(item));
}
diff --git a/Tiku.Infrastructure/ContentV2/ContentV2AuthoringService.cs b/Tiku.Infrastructure/ContentV2/ContentV2AuthoringService.cs
index 9ff7164..7a991d6 100644
--- a/Tiku.Infrastructure/ContentV2/ContentV2AuthoringService.cs
+++ b/Tiku.Infrastructure/ContentV2/ContentV2AuthoringService.cs
@@ -11,7 +11,8 @@ namespace Tiku.Infrastructure.ContentV2;
internal sealed class ContentV2AuthoringService(
IQuestionBankPersistence content,
- ITenantExecutionScope tenantExecutionScope) : IContentV2AuthoringService
+ ITenantExecutionScope tenantExecutionScope,
+ IFeatureAccessService featureAccessService) : IContentV2AuthoringService
{
private const int MaxImportRows = 2000;
@@ -102,6 +103,11 @@ internal sealed class ContentV2AuthoringService(
cancellationToken))
throw Error("question_family_not_found", "The question family was not found in this tenant.");
+ await featureAccessService.ConsumeQuotaIfConfiguredAsync(
+ actor.TenantId,
+ SaasQuotaMetricCatalog.PrivateQuestionCount,
+ cancellationToken: cancellationToken);
+
var asset = new QuestionAsset
{
TenantId = actor.TenantId,
diff --git a/Tiku.Infrastructure/Modules/ContentModule.cs b/Tiku.Infrastructure/Modules/ContentModule.cs
index f7b1af7..407e32e 100644
--- a/Tiku.Infrastructure/Modules/ContentModule.cs
+++ b/Tiku.Infrastructure/Modules/ContentModule.cs
@@ -16,7 +16,6 @@ using Tiku.Infrastructure.Content.Exports;
using Tiku.Infrastructure.Content.Imports;
using Tiku.Infrastructure.Assets.Security;
using Tiku.Infrastructure.Profile;
-using Tiku.Infrastructure.QuestionBanks;
using Tiku.Infrastructure.Scoreline;
using Tiku.Infrastructure.Storage;
using Tiku.Infrastructure.StudyContent;
@@ -36,8 +35,6 @@ internal static class ContentModule
services.AddScoped();
services.AddScoped();
services.AddScoped();
- services.AddScoped();
- services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
diff --git a/Tiku.Infrastructure/Persistence/Configurations/AssetConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/AssetConfigurations.cs
index d9e3a03..c4a6b83 100644
--- a/Tiku.Infrastructure/Persistence/Configurations/AssetConfigurations.cs
+++ b/Tiku.Infrastructure/Persistence/Configurations/AssetConfigurations.cs
@@ -297,13 +297,13 @@ internal sealed class QuestionVideoConfiguration : IEntityTypeConfiguration new
{
entity.TenantId,
- entity.QuestionId,
+ entity.QuestionPlacementId,
entity.VideoId,
entity.VideoType
- }).IsUnique();
+ }).IsUnique().HasDatabaseName("ux_question_videos_placement_video_type");
- builder.HasOne().WithMany()
- .HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, Id = entity.QuestionPlacementId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne().WithMany()
@@ -323,10 +323,18 @@ internal sealed class VideoPlaybackProgressConfiguration : IEntityTypeConfigurat
builder.Property(entity => entity.LastPlayedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.VideoId })
.IsUnique()
- .HasFilter("question_id is null");
- builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.VideoId, entity.QuestionId })
+ .HasDatabaseName("ux_video_progress_general")
+ .HasFilter("question_placement_id is null");
+ builder.HasIndex(entity => new
+ {
+ entity.TenantId,
+ entity.UserId,
+ entity.VideoId,
+ entity.QuestionPlacementId
+ })
.IsUnique()
- .HasFilter("question_id is not null");
+ .HasDatabaseName("ux_video_progress_placement")
+ .HasFilter("question_placement_id is not null");
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.LastPlayedAt });
builder.HasOne().WithMany()
@@ -336,8 +344,8 @@ internal sealed class VideoPlaybackProgressConfiguration : IEntityTypeConfigurat
.HasForeignKey(entity => new { entity.TenantId, entity.VideoId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
- builder.HasOne().WithMany()
- .HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
+ builder.HasOne().WithMany()
+ .HasForeignKey(entity => new { entity.TenantId, Id = entity.QuestionPlacementId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
@@ -350,4 +358,4 @@ internal sealed class VideoPlaybackProgressConfiguration : IEntityTypeConfigurat
table.HasCheckConstraint("ck_video_playback_progress_play_count", "play_count >= 0");
});
}
-}
\ No newline at end of file
+}
diff --git a/Tiku.Infrastructure/Persistence/Configurations/LearningConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/LearningConfigurations.cs
index 8bca2de..4a83e9b 100644
--- a/Tiku.Infrastructure/Persistence/Configurations/LearningConfigurations.cs
+++ b/Tiku.Infrastructure/Persistence/Configurations/LearningConfigurations.cs
@@ -59,11 +59,11 @@ internal sealed class PracticeSessionQuestionConfiguration : IEntityTypeConfigur
builder.HasIndex(entity => new { entity.QuestionOwnerTenantId, entity.QuestionAssetId })
.HasDatabaseName("ix_psq_question_asset");
builder.HasIndex(entity => new
- {
- entity.QuestionOwnerTenantId,
- entity.QuestionAssetId,
- entity.QuestionRevisionId
- })
+ {
+ entity.QuestionOwnerTenantId,
+ entity.QuestionAssetId,
+ entity.QuestionRevisionId
+ })
.HasDatabaseName("ix_psq_question_revision");
builder.HasIndex(entity => new { entity.QuestionOwnerTenantId, entity.QuestionPlacementId })
.HasDatabaseName("ix_psq_question_placement");
diff --git a/Tiku.Infrastructure/Persistence/Migrations/20260806023311_MigrateQuestionVideosV2.Designer.cs b/Tiku.Infrastructure/Persistence/Migrations/20260806023311_MigrateQuestionVideosV2.Designer.cs
new file mode 100644
index 0000000..7e1f51c
--- /dev/null
+++ b/Tiku.Infrastructure/Persistence/Migrations/20260806023311_MigrateQuestionVideosV2.Designer.cs
@@ -0,0 +1,26468 @@
+//
+using System;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Tiku.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace Tiku.Infrastructure.Persistence.Migrations
+{
+ [DbContext(typeof(TikuDbContext))]
+ [Migration("20260806023311_MigrateQuestionVideosV2")]
+ partial class MigrateQuestionVideosV2
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext");
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "ltree");
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("FriendlyName")
+ .HasColumnType("text")
+ .HasColumnName("friendly_name");
+
+ b.Property("Xml")
+ .HasColumnType("text")
+ .HasColumnName("xml");
+
+ b.HasKey("Id")
+ .HasName("pk_data_protection_keys");
+
+ b.ToTable("data_protection_keys", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text")
+ .HasColumnName("claim_type");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text")
+ .HasColumnName("claim_value");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_user_claims");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_user_claims_user_id");
+
+ b.ToTable("user_claims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("text")
+ .HasColumnName("login_provider");
+
+ b.Property("ProviderKey")
+ .HasColumnType("text")
+ .HasColumnName("provider_key");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("text")
+ .HasColumnName("provider_display_name");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("LoginProvider", "ProviderKey")
+ .HasName("pk_user_logins");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_user_logins_user_id");
+
+ b.ToTable("user_logins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("LoginProvider")
+ .HasColumnType("text")
+ .HasColumnName("login_provider");
+
+ b.Property("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("Value")
+ .HasColumnType("text")
+ .HasColumnName("value");
+
+ b.HasKey("UserId", "LoginProvider", "Name")
+ .HasName("pk_user_tokens");
+
+ b.ToTable("user_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.Category", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CategoryType")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("category_type");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("name");
+
+ b.Property("NodeId")
+ .HasColumnType("uuid")
+ .HasColumnName("node_id");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("SubjectId")
+ .HasColumnType("uuid")
+ .HasColumnName("subject_id");
+
+ b.Property("SvipQuestionLimit")
+ .HasColumnType("integer")
+ .HasColumnName("svip_question_limit");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_categories");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_categories_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_categories_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "NodeId")
+ .HasDatabaseName("ix_categories_tenant_id_node_id");
+
+ b.HasIndex("TenantId", "SubjectId")
+ .HasDatabaseName("ix_categories_tenant_id_subject_id");
+
+ b.ToTable("categories", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.Major", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("name");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("SchoolId")
+ .HasColumnType("uuid")
+ .HasColumnName("school_id");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("StudyTips")
+ .HasColumnType("text")
+ .HasColumnName("study_tips");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_majors");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_majors_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_majors_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "RegionId")
+ .HasDatabaseName("ix_majors_tenant_id_region_id");
+
+ b.HasIndex("TenantId", "SchoolId")
+ .HasDatabaseName("ix_majors_tenant_id_school_id");
+
+ b.ToTable("majors", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.ModuleNode", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("LegacyModuleId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_module_id");
+
+ b.Property("LegacyParentId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_parent_id");
+
+ b.Property("Metadata")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("metadata")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("ModuleId")
+ .HasColumnType("uuid")
+ .HasColumnName("module_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("name");
+
+ b.Property("ParentId")
+ .HasColumnType("uuid")
+ .HasColumnName("parent_id");
+
+ b.Property("Path")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)")
+ .HasColumnName("path");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("type");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_module_nodes");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_module_nodes_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_module_nodes_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "ModuleId")
+ .HasDatabaseName("ix_module_nodes_tenant_id_module_id");
+
+ b.HasIndex("TenantId", "ParentId")
+ .HasDatabaseName("ix_module_nodes_tenant_id_parent_id");
+
+ b.HasIndex("TenantId", "RegionId")
+ .HasDatabaseName("ix_module_nodes_tenant_id_region_id");
+
+ b.ToTable("module_nodes", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.Region", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("Code")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("code");
+
+ b.Property("Config")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("config")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("FullName")
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("full_name");
+
+ b.Property("Icon")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)")
+ .HasColumnName("icon");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("IsHot")
+ .HasColumnType("boolean")
+ .HasColumnName("is_hot");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("name");
+
+ b.Property("Pinyin")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("pinyin");
+
+ b.Property("ShortName")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("short_name");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_regions");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_regions_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_regions_tenant_id_legacy_id");
+
+ b.ToTable("regions", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.RegionModule", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("Color")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("color");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("Icon")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)")
+ .HasColumnName("icon");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("IsPrimarySchoolModule")
+ .HasColumnType("boolean")
+ .HasColumnName("is_primary_school_module");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("name");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("Route")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("route");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("TextColor")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("text_color");
+
+ b.Property("Type")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("type");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_region_modules");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_region_modules_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_region_modules_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "RegionId")
+ .HasDatabaseName("ix_region_modules_tenant_id_region_id");
+
+ b.ToTable("region_modules", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.School", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("Metadata")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("metadata")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("ModuleId")
+ .HasColumnType("uuid")
+ .HasColumnName("module_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("name");
+
+ b.Property("ProfessionalExamDate")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("professional_exam_date");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_schools");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_schools_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_schools_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "ModuleId")
+ .HasDatabaseName("ix_schools_tenant_id_module_id");
+
+ b.HasIndex("TenantId", "RegionId")
+ .HasDatabaseName("ix_schools_tenant_id_region_id");
+
+ b.ToTable("schools", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.ScorelineField", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Description")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)")
+ .HasColumnName("description");
+
+ b.Property("FieldKey")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("field_key");
+
+ b.Property("FieldName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("field_name");
+
+ b.Property("FieldType")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("field_type");
+
+ b.Property("IsFilter")
+ .HasColumnType("boolean")
+ .HasColumnName("is_filter");
+
+ b.Property("IsRequired")
+ .HasColumnType("boolean")
+ .HasColumnName("is_required");
+
+ b.Property("IsTrend")
+ .HasColumnType("boolean")
+ .HasColumnName("is_trend");
+
+ b.Property("IsVisible")
+ .HasColumnType("boolean")
+ .HasColumnName("is_visible");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("Options")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("options")
+ .HasDefaultValueSql("'[]'::jsonb");
+
+ b.Property("Placeholder")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("placeholder");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("Unit")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("unit");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_scoreline_fields");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_scoreline_fields_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_scoreline_fields_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "RegionId", "FieldKey")
+ .IsUnique()
+ .HasDatabaseName("ix_scoreline_fields_tenant_id_region_id_field_key");
+
+ b.HasIndex("TenantId", "RegionId", "IsFilter", "SortOrder")
+ .HasDatabaseName("ix_scoreline_fields_tenant_id_region_id_is_filter_sort_order");
+
+ b.ToTable("scoreline_fields", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.ScorelineRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("FieldValues")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("field_values")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("MajorId")
+ .HasColumnType("uuid")
+ .HasColumnName("major_id");
+
+ b.Property("MajorName")
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("major_name");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("SchoolId")
+ .HasColumnType("uuid")
+ .HasColumnName("school_id");
+
+ b.Property("SchoolName")
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("school_name");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Year")
+ .HasColumnType("integer")
+ .HasColumnName("year");
+
+ b.HasKey("Id")
+ .HasName("pk_scoreline_records");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_scoreline_records_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_scoreline_records_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "MajorId")
+ .HasDatabaseName("ix_scoreline_records_tenant_id_major_id");
+
+ b.HasIndex("TenantId", "SchoolId")
+ .HasDatabaseName("ix_scoreline_records_tenant_id_school_id");
+
+ b.HasIndex("TenantId", "Year")
+ .HasDatabaseName("ix_scoreline_records_tenant_id_year");
+
+ b.HasIndex("TenantId", "RegionId", "SchoolId", "MajorId", "Year")
+ .HasDatabaseName("ix_scoreline_records_tenant_id_region_id_school_id_major_id_ye~");
+
+ b.HasIndex("TenantId", "Year", "SchoolName", "MajorName", "Id")
+ .IsDescending(false, true, false, false, false)
+ .HasDatabaseName("ix_scoreline_records_tenant_id_year_school_name_major_name_id");
+
+ b.ToTable("scoreline_records", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Catalog.Subject", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("Icon")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)")
+ .HasColumnName("icon");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("MajorId")
+ .HasColumnType("uuid")
+ .HasColumnName("major_id");
+
+ b.Property("MajorLegacyIds")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("major_legacy_ids")
+ .HasDefaultValueSql("'[]'::jsonb");
+
+ b.Property("ModuleId")
+ .HasColumnType("uuid")
+ .HasColumnName("module_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(300)
+ .HasColumnType("character varying(300)")
+ .HasColumnName("name");
+
+ b.Property("NodeId")
+ .HasColumnType("uuid")
+ .HasColumnName("node_id");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("SchoolId")
+ .HasColumnType("uuid")
+ .HasColumnName("school_id");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("Stats")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("stats")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("Type")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("type");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_subjects");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_subjects_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_subjects_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "MajorId")
+ .HasDatabaseName("ix_subjects_tenant_id_major_id");
+
+ b.HasIndex("TenantId", "ModuleId")
+ .HasDatabaseName("ix_subjects_tenant_id_module_id");
+
+ b.HasIndex("TenantId", "NodeId")
+ .HasDatabaseName("ix_subjects_tenant_id_node_id");
+
+ b.HasIndex("TenantId", "RegionId")
+ .HasDatabaseName("ix_subjects_tenant_id_region_id");
+
+ b.HasIndex("TenantId", "SchoolId")
+ .HasDatabaseName("ix_subjects_tenant_id_school_id");
+
+ b.ToTable("subjects", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Commerce.ActivationCode", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("AgentUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("agent_user_id");
+
+ b.Property("BatchId")
+ .HasColumnType("uuid")
+ .HasColumnName("batch_id");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("citext")
+ .HasColumnName("code");
+
+ b.Property("CouponCode")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("coupon_code");
+
+ b.Property("CouponRedemptionId")
+ .HasColumnType("uuid")
+ .HasColumnName("coupon_redemption_id");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Days")
+ .HasColumnType("integer")
+ .HasColumnName("days");
+
+ b.Property("IsUsed")
+ .HasColumnType("boolean")
+ .HasColumnName("is_used");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("Remark")
+ .HasColumnType("text")
+ .HasColumnName("remark");
+
+ b.Property("SaleType")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("sale_type");
+
+ b.Property("SoldTo")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("sold_to");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UnitPriceCents")
+ .HasColumnType("integer")
+ .HasColumnName("unit_price_cents");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("UsedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("used_at");
+
+ b.Property("UsedBy")
+ .HasColumnType("uuid")
+ .HasColumnName("used_by");
+
+ b.Property("UsedRegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("used_region_id");
+
+ b.HasKey("Id")
+ .HasName("pk_activation_codes");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_activation_codes_tenant_id_id");
+
+ b.HasIndex("AgentUserId")
+ .HasDatabaseName("ix_activation_codes_agent_user_id");
+
+ b.HasIndex("UsedBy")
+ .HasDatabaseName("ix_activation_codes_used_by");
+
+ b.HasIndex("TenantId", "BatchId")
+ .HasDatabaseName("ix_activation_codes_tenant_id_batch_id");
+
+ b.HasIndex("TenantId", "Code")
+ .IsUnique()
+ .HasDatabaseName("ix_activation_codes_tenant_id_code");
+
+ b.HasIndex("TenantId", "CouponRedemptionId")
+ .HasDatabaseName("ix_activation_codes_tenant_id_coupon_redemption_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_activation_codes_tenant_id_legacy_id");
+
+ b.HasIndex("TenantId", "UsedRegionId")
+ .HasDatabaseName("ix_activation_codes_tenant_id_used_region_id");
+
+ b.ToTable("activation_codes", (string)null);
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Commerce.CodeBatch", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("CampaignName")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("campaign_name");
+
+ b.Property("Channel")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("channel");
+
+ b.Property("CommissionRate")
+ .HasPrecision(6, 4)
+ .HasColumnType("numeric(6,4)")
+ .HasColumnName("commission_rate");
+
+ b.Property("CostPriceCents")
+ .HasColumnType("integer")
+ .HasColumnName("cost_price_cents");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by");
+
+ b.Property("Days")
+ .HasColumnType("integer")
+ .HasColumnName("days");
+
+ b.Property("DefaultUnitPriceCents")
+ .HasColumnType("integer")
+ .HasColumnName("default_unit_price_cents");
+
+ b.Property("IssuedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("issued_at");
+
+ b.Property("LegacyId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_id");
+
+ b.Property("LegacyRegionId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("legacy_region_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("name");
+
+ b.Property("RegionId")
+ .HasColumnType("uuid")
+ .HasColumnName("region_id");
+
+ b.Property("Remark")
+ .HasColumnType("text")
+ .HasColumnName("remark");
+
+ b.Property("SaleType")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("sale_type");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("TotalCount")
+ .HasColumnType("integer")
+ .HasColumnName("total_count");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id")
+ .HasName("pk_code_batches");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_code_batches_tenant_id_id");
+
+ b.HasIndex("TenantId", "LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_code_batches_tenant_id_legacy_id");
+
+ b.ToTable("code_batches", null, t =>
+ {
+ t.HasCheckConstraint("ck_code_batches_amounts", "default_unit_price_cents >= 0 and cost_price_cents >= 0");
+
+ t.HasCheckConstraint("ck_code_batches_counts", "total_count >= 0");
+ });
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Commerce.CommerceAdjustmentVoucher", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("AmountCents")
+ .HasColumnType("integer")
+ .HasColumnName("amount_cents");
+
+ b.Property("BatchId")
+ .HasColumnType("uuid")
+ .HasColumnName("batch_id");
+
+ b.Property("ClosedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("closed_at");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("CreatedBy")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by");
+
+ b.Property("Currency")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)")
+ .HasColumnName("currency");
+
+ b.Property("Direction")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("direction");
+
+ b.Property("IssueId")
+ .HasColumnType("uuid")
+ .HasColumnName("issue_id");
+
+ b.Property("ItemId")
+ .HasColumnType("uuid")
+ .HasColumnName("item_id");
+
+ b.Property("Metadata")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("metadata")
+ .HasDefaultValueSql("'{}'::jsonb");
+
+ b.Property("OrderId")
+ .HasColumnType("uuid")
+ .HasColumnName("order_id");
+
+ b.Property("PaymentId")
+ .HasColumnType("uuid")
+ .HasColumnName("payment_id");
+
+ b.Property("ProofAssetKey")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("proof_asset_key");
+
+ b.Property("Reason")
+ .IsRequired()
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)")
+ .HasColumnName("reason");
+
+ b.Property("RefundRequestId")
+ .HasColumnType("uuid")
+ .HasColumnName("refund_request_id");
+
+ b.Property("ReviewedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("reviewed_at");
+
+ b.Property("ReviewedBy")
+ .HasColumnType("uuid")
+ .HasColumnName("reviewed_by");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("status");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at")
+ .HasDefaultValueSql("now()");
+
+ b.Property("VoucherNo")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("voucher_no");
+
+ b.HasKey("Id")
+ .HasName("pk_commerce_adjustment_vouchers");
+
+ b.HasAlternateKey("TenantId", "Id")
+ .HasName("ak_commerce_adjustment_vouchers_tenant_id_id");
+
+ b.HasIndex("CreatedBy")
+ .HasDatabaseName("ix_commerce_adjustment_vouchers_created_by");
+
+ b.HasIndex("ReviewedBy")
+ .HasDatabaseName("ix_commerce_adjustment_vouchers_reviewed_by");
+
+ b.HasIndex("TenantId", "BatchId")
+ .HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_batch_id");
+
+ b.HasIndex("TenantId", "ItemId")
+ .HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_item_id");
+
+ b.HasIndex("TenantId", "OrderId")
+ .HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_order_id");
+
+ b.HasIndex("TenantId", "PaymentId")
+ .HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_payment_id");
+
+ b.HasIndex("TenantId", "RefundRequestId")
+ .HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_refund_request_id");
+
+ b.HasIndex("TenantId", "VoucherNo")
+ .IsUnique()
+ .HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_voucher_no");
+
+ b.HasIndex("TenantId", "IssueId", "CreatedAt")
+ .HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_issue_id_created_at");
+
+ b.HasIndex("TenantId", "Status", "CreatedAt")
+ .HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_status_created_at");
+
+ b.ToTable("commerce_adjustment_vouchers", null, t =>
+ {
+ t.HasCheckConstraint("ck_commerce_adjustment_vouchers_amount", "amount_cents > 0");
+ });
+ });
+
+ modelBuilder.Entity("Tiku.Domain.Commerce.CommerceAdjustmentVoucherEvent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("ActorUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("actor_user_id");
+
+ b.Property