forked from gongxuegit/tiku-backend.net
feat: enforce tenant isolation and shared question bank
This commit is contained in:
@@ -152,10 +152,19 @@ public sealed class AuthService(
|
||||
RefreshSessionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!sessionService.TryParseRefreshToken(request.RefreshToken, out var locator))
|
||||
{
|
||||
throw new SessionRevokedException();
|
||||
}
|
||||
|
||||
var tokenHash = sessionService.HashRefreshToken(request.RefreshToken);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var session = await dbContext.AuthSessions
|
||||
.SingleOrDefaultAsync(entity => entity.TokenHash == tokenHash, cancellationToken);
|
||||
.SingleOrDefaultAsync(entity =>
|
||||
entity.Id == locator.SessionId &&
|
||||
entity.TenantId == locator.TenantId &&
|
||||
entity.TokenHash == tokenHash,
|
||||
cancellationToken);
|
||||
|
||||
if (session is null || session.RevokedAt is not null || session.ExpiresAt <= now)
|
||||
{
|
||||
@@ -194,9 +203,18 @@ public sealed class AuthService(
|
||||
LogoutSessionRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!sessionService.TryParseRefreshToken(request.RefreshToken, out var locator))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var tokenHash = sessionService.HashRefreshToken(request.RefreshToken);
|
||||
var session = await dbContext.AuthSessions
|
||||
.SingleOrDefaultAsync(entity => entity.TokenHash == tokenHash, cancellationToken);
|
||||
.SingleOrDefaultAsync(entity =>
|
||||
entity.Id == locator.SessionId &&
|
||||
entity.TenantId == locator.TenantId &&
|
||||
entity.TokenHash == tokenHash,
|
||||
cancellationToken);
|
||||
|
||||
if (session is null || session.RevokedAt is not null)
|
||||
{
|
||||
|
||||
@@ -16,9 +16,29 @@ public sealed class SessionService(
|
||||
{
|
||||
private readonly JwtOptions options = options.Value;
|
||||
|
||||
public string GenerateRefreshToken()
|
||||
public string GenerateRefreshToken(Guid tenantId, Guid sessionId)
|
||||
{
|
||||
return Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(64));
|
||||
return $"v1.{tenantId:N}.{sessionId:N}.{Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(64))}";
|
||||
}
|
||||
|
||||
public bool TryParseRefreshToken(string refreshToken, out RefreshTokenLocator locator)
|
||||
{
|
||||
locator = default;
|
||||
if (string.IsNullOrWhiteSpace(refreshToken))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var parts = refreshToken.Split('.', 4, StringSplitOptions.None);
|
||||
if (parts.Length != 4 || parts[0] != "v1" || parts[3].Length < 32 ||
|
||||
!Guid.TryParseExact(parts[1], "N", out var tenantId) ||
|
||||
!Guid.TryParseExact(parts[2], "N", out var sessionId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
locator = new RefreshTokenLocator(tenantId, sessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
public string HashRefreshToken(string refreshToken)
|
||||
@@ -37,17 +57,19 @@ public sealed class SessionService(
|
||||
string? userAgent,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var refreshToken = GenerateRefreshToken();
|
||||
var session = new AuthSession
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = membership.TenantId,
|
||||
UserId = userId,
|
||||
TokenHash = HashRefreshToken(refreshToken),
|
||||
TokenHash = string.Empty,
|
||||
Provider = provider,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(options.RefreshTokenDays),
|
||||
IpAddress = ipAddress,
|
||||
UserAgent = userAgent
|
||||
};
|
||||
var refreshToken = GenerateRefreshToken(session.TenantId, session.Id);
|
||||
session.TokenHash = HashRefreshToken(refreshToken);
|
||||
|
||||
dbContext.AuthSessions.Add(session);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
151
Tiku.Infrastructure/Catalog/TaxonomyService.cs
Normal file
151
Tiku.Infrastructure/Catalog/TaxonomyService.cs
Normal file
@@ -0,0 +1,151 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Catalog;
|
||||
|
||||
public sealed class TaxonomyService(
|
||||
TikuDbContext dbContext,
|
||||
IPublicQuestionAccessPolicy accessPolicy,
|
||||
ITenantExecutionScope tenantExecutionScope) : ITaxonomyService
|
||||
{
|
||||
public async Task<IReadOnlyCollection<TaxonomyNodeItem>> ListAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var includePlatform = true;
|
||||
try
|
||||
{
|
||||
await accessPolicy.EnsureCanStartAsync(tenantId, cancellationToken);
|
||||
}
|
||||
catch (PublicQuestionAccessDeniedException)
|
||||
{
|
||||
includePlatform = false;
|
||||
}
|
||||
|
||||
return await tenantExecutionScope.ExecuteAsync(
|
||||
tenantId,
|
||||
"List platform taxonomy with tenant extensions",
|
||||
async (provider, token) =>
|
||||
{
|
||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||
var platformTenantId = includePlatform
|
||||
? await systemDbContext.Tenants.AsNoTracking()
|
||||
.Where(tenant => tenant.Mode == TenantMode.PlatformOwned)
|
||||
.Select(tenant => (Guid?)tenant.Id)
|
||||
.SingleOrDefaultAsync(token)
|
||||
: null;
|
||||
return await systemDbContext.TaxonomyNodes.AsNoTracking()
|
||||
.Where(node =>
|
||||
node.IsActive &&
|
||||
(node.TenantId == tenantId ||
|
||||
(platformTenantId.HasValue && node.TenantId == platformTenantId.Value)))
|
||||
.OrderBy(node => node.Depth)
|
||||
.ThenBy(node => node.SortOrder)
|
||||
.Select(node => new TaxonomyNodeItem(
|
||||
node.Id,
|
||||
node.TenantId == tenantId ? QuestionSource.Tenant : QuestionSource.Platform,
|
||||
node.ParentId,
|
||||
!node.ParentOwnerTenantId.HasValue
|
||||
? null
|
||||
: node.ParentOwnerTenantId == tenantId
|
||||
? QuestionSource.Tenant
|
||||
: QuestionSource.Platform,
|
||||
node.NodeType,
|
||||
node.Code,
|
||||
node.Name,
|
||||
node.Path,
|
||||
node.Depth,
|
||||
node.SortOrder,
|
||||
node.Metadata))
|
||||
.ToArrayAsync(token);
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<TaxonomyNodeItem> CreateAsync(
|
||||
Guid tenantId,
|
||||
CreateTaxonomyNodeCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Guid? parentOwnerTenantId = null;
|
||||
TaxonomyParent? parent = null;
|
||||
if (command.ParentId.HasValue)
|
||||
{
|
||||
parentOwnerTenantId = command.ParentSource switch
|
||||
{
|
||||
QuestionSource.Tenant => tenantId,
|
||||
QuestionSource.Platform => await ResolvePlatformTenantIdAsync(tenantId, cancellationToken),
|
||||
_ => throw new InvalidOperationException("A parent source is required when parentId is provided.")
|
||||
};
|
||||
parent = await tenantExecutionScope.ExecuteAsync(
|
||||
tenantId,
|
||||
"Validate taxonomy extension parent ownership",
|
||||
async (provider, token) =>
|
||||
{
|
||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||
return await systemDbContext.TaxonomyNodes.AsNoTracking()
|
||||
.Where(node =>
|
||||
node.TenantId == parentOwnerTenantId &&
|
||||
node.Id == command.ParentId.Value &&
|
||||
node.IsActive)
|
||||
.Select(node => new TaxonomyParent(node.Path, node.Depth))
|
||||
.SingleOrDefaultAsync(token);
|
||||
},
|
||||
cancellationToken)
|
||||
?? throw new InvalidOperationException("Taxonomy parent was not found.");
|
||||
}
|
||||
|
||||
var node = new TaxonomyNode
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ParentOwnerTenantId = parentOwnerTenantId,
|
||||
ParentId = command.ParentId,
|
||||
NodeType = command.NodeType,
|
||||
Code = command.Code.Trim(),
|
||||
Name = command.Name.Trim(),
|
||||
Depth = parent is null ? 0 : parent.Depth + 1,
|
||||
SortOrder = command.SortOrder,
|
||||
Metadata = command.Metadata.Clone()
|
||||
};
|
||||
node.Path = parent is null
|
||||
? $"n{node.Id:N}"
|
||||
: $"{parent.Path}.n{node.Id:N}";
|
||||
dbContext.TaxonomyNodes.Add(node);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new TaxonomyNodeItem(
|
||||
node.Id,
|
||||
QuestionSource.Tenant,
|
||||
node.ParentId,
|
||||
node.ParentId.HasValue ? command.ParentSource : null,
|
||||
node.NodeType,
|
||||
node.Code,
|
||||
node.Name,
|
||||
node.Path,
|
||||
node.Depth,
|
||||
node.SortOrder,
|
||||
node.Metadata);
|
||||
}
|
||||
|
||||
private async Task<Guid> ResolvePlatformTenantIdAsync(Guid tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
await accessPolicy.EnsureCanStartAsync(tenantId, cancellationToken);
|
||||
return await tenantExecutionScope.ExecuteAsync(
|
||||
tenantId,
|
||||
"Resolve platform taxonomy owner",
|
||||
async (provider, token) => await provider.GetRequiredService<TikuDbContext>()
|
||||
.Tenants.AsNoTracking()
|
||||
.Where(tenant => tenant.Mode == TenantMode.PlatformOwned)
|
||||
.Select(tenant => tenant.Id)
|
||||
.SingleAsync(token),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private sealed record TaxonomyParent(string? Path, int Depth);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
@@ -11,7 +12,9 @@ using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Content;
|
||||
|
||||
public sealed class ContentManagementService(TikuDbContext dbContext) : IContentManagementService
|
||||
public sealed class ContentManagementService(
|
||||
TikuDbContext dbContext,
|
||||
IQuestionReferenceService questionReferenceService) : IContentManagementService
|
||||
{
|
||||
private const int DefaultLimit = 100;
|
||||
private const int MaxLimit = 1000;
|
||||
@@ -358,15 +361,15 @@ public sealed class ContentManagementService(TikuDbContext dbContext) : IContent
|
||||
throw new ContentManagementException("Collection was not found.", "collection_not_found");
|
||||
}
|
||||
|
||||
var questionIds = command.Questions.Select(item => item.QuestionId).Distinct().ToArray();
|
||||
var existingQuestions = await dbContext.Questions
|
||||
.Where(question => question.TenantId == actor.TenantId && questionIds.Contains(question.Id))
|
||||
.Select(question => question.Id)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
if (existingQuestions.Length != questionIds.Length)
|
||||
var resolvedQuestions = new List<(CollectionQuestionCommand Command, TenantQuestionReference Reference)>();
|
||||
foreach (var question in command.Questions)
|
||||
{
|
||||
throw new ContentManagementException("One or more questions are not in this tenant.", "question_not_found");
|
||||
var reference = await questionReferenceService.ResolveAsync(
|
||||
actor.TenantId,
|
||||
actor.UserId,
|
||||
question.Locator,
|
||||
cancellationToken);
|
||||
resolvedQuestions.Add((question, reference));
|
||||
}
|
||||
|
||||
var oldItems = await dbContext.QuestionCollectionItems
|
||||
@@ -374,17 +377,19 @@ public sealed class ContentManagementService(TikuDbContext dbContext) : IContent
|
||||
.ToArrayAsync(cancellationToken);
|
||||
dbContext.QuestionCollectionItems.RemoveRange(oldItems);
|
||||
|
||||
var items = command.Questions
|
||||
.Select((question, index) => new QuestionCollectionItem
|
||||
var items = resolvedQuestions
|
||||
.Select((resolved, index) => new QuestionCollectionItem
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
CollectionId = command.CollectionId,
|
||||
QuestionId = question.QuestionId,
|
||||
SectionKey = Normalize(question.SectionKey),
|
||||
SortOrder = question.Order ?? index,
|
||||
Score = question.Score,
|
||||
Required = question.Required ?? true,
|
||||
Metadata = JsonObjectOrDefault(question.Metadata)
|
||||
QuestionReferenceId = resolved.Reference.Id,
|
||||
QuestionOwnerTenantId = resolved.Reference.QuestionOwnerTenantId,
|
||||
QuestionId = resolved.Reference.QuestionId,
|
||||
SectionKey = Normalize(resolved.Command.SectionKey),
|
||||
SortOrder = resolved.Command.Order ?? index,
|
||||
Score = resolved.Command.Score,
|
||||
Required = resolved.Command.Required ?? true,
|
||||
Metadata = JsonObjectOrDefault(resolved.Command.Metadata)
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
@@ -743,6 +748,9 @@ public sealed class ContentManagementService(TikuDbContext dbContext) : IContent
|
||||
item.Id,
|
||||
item.CollectionId,
|
||||
item.QuestionId,
|
||||
new QuestionLocator(
|
||||
item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform,
|
||||
item.QuestionId),
|
||||
item.SectionKey,
|
||||
item.SortOrder,
|
||||
item.Score,
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
@@ -14,7 +15,9 @@ using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Content;
|
||||
|
||||
public sealed class DirectContentService(TikuDbContext dbContext) : IDirectContentService
|
||||
public sealed class DirectContentService(
|
||||
TikuDbContext dbContext,
|
||||
IQuestionReferenceService questionReferenceService) : IDirectContentService
|
||||
{
|
||||
private const int DefaultLimit = 100;
|
||||
private const int MaxLimit = 1000;
|
||||
@@ -1424,6 +1427,11 @@ public sealed class DirectContentService(TikuDbContext dbContext) : IDirectConte
|
||||
cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
var reference = await questionReferenceService.ResolveAsync(
|
||||
actor.TenantId,
|
||||
actor.UserId,
|
||||
new QuestionLocator(QuestionSource.Tenant, question.Id),
|
||||
cancellationToken);
|
||||
var nextOrder = await dbContext.QuestionCollectionItems
|
||||
.Where(item => item.TenantId == actor.TenantId && item.CollectionId == question.PrimaryCollectionId.Value)
|
||||
.Select(item => (int?)item.SortOrder)
|
||||
@@ -1432,6 +1440,8 @@ public sealed class DirectContentService(TikuDbContext dbContext) : IDirectConte
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
CollectionId = question.PrimaryCollectionId.Value,
|
||||
QuestionReferenceId = reference.Id,
|
||||
QuestionOwnerTenantId = reference.QuestionOwnerTenantId,
|
||||
QuestionId = question.Id,
|
||||
SortOrder = nextOrder + 1
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@ using Tiku.Application.Scoreline;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Application.StudyContent;
|
||||
using Tiku.Application.TenantAdmin;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Infrastructure.Assets;
|
||||
using Tiku.Infrastructure.Auth;
|
||||
using Tiku.Infrastructure.Catalog;
|
||||
@@ -30,6 +32,7 @@ using Tiku.Infrastructure.Scoreline;
|
||||
using Tiku.Infrastructure.Storage;
|
||||
using Tiku.Infrastructure.StudyContent;
|
||||
using Tiku.Infrastructure.TenantAdmin;
|
||||
using Tiku.Infrastructure.Tenancy;
|
||||
|
||||
namespace Tiku.Infrastructure;
|
||||
|
||||
@@ -42,12 +45,23 @@ public static class DependencyInjection
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
|
||||
|
||||
services.AddSingleton(_ => NpgsqlDataSource.Create(connectionString));
|
||||
services.AddDbContextPool<TikuDbContext>((serviceProvider, options) =>
|
||||
services.AddScoped<TenantIsolationSaveChangesInterceptor>();
|
||||
services.AddDbContext<TikuDbContext>((serviceProvider, options) =>
|
||||
{
|
||||
var dataSource = serviceProvider.GetRequiredService<NpgsqlDataSource>();
|
||||
options.UseNpgsql(dataSource, npgsql =>
|
||||
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName));
|
||||
options.AddInterceptors(serviceProvider.GetRequiredService<TenantIsolationSaveChangesInterceptor>());
|
||||
});
|
||||
services.AddScoped<ITenantDirectory, TenantDirectory>();
|
||||
services.AddMemoryCache();
|
||||
services.AddScoped<ITenantFrontendConfigService, TenantFrontendConfigService>();
|
||||
services.AddSingleton<ITenantRuntimeCacheInvalidator, TenantRuntimeCacheInvalidator>();
|
||||
services.AddHttpClient<IDomainOwnershipVerifier, DnsDomainOwnershipVerifier>();
|
||||
services.AddHttpClient<IDomainGatewayProvisioner, HttpDomainGatewayProvisioner>();
|
||||
services.AddScoped<ITenantDomainLifecycleService, TenantDomainLifecycleService>();
|
||||
services.AddOptions<DomainLifecycleOptions>();
|
||||
services.AddSingleton<ITenantExecutionScope, TenantExecutionScope>();
|
||||
services.AddScoped<IPasswordHasher, PasswordHasher>();
|
||||
services.AddScoped<ITokenService, TokenService>();
|
||||
services.AddScoped<ISessionService, SessionService>();
|
||||
@@ -55,10 +69,13 @@ public static class DependencyInjection
|
||||
services.AddScoped<IWechatOAuthClient, WechatOAuthClient>();
|
||||
services.AddScoped<IAuthService, AuthService>();
|
||||
services.AddScoped<ICatalogQueryService, CatalogQueryService>();
|
||||
services.AddScoped<ITaxonomyService, TaxonomyService>();
|
||||
services.AddScoped<IContentNavigationQueryService, ContentNavigationQueryService>();
|
||||
services.AddScoped<IContentManagementService, ContentManagementService>();
|
||||
services.AddScoped<IDirectContentService, DirectContentService>();
|
||||
services.AddScoped<IQuestionBankQueryService, QuestionBankQueryService>();
|
||||
services.AddScoped<IPublicQuestionAccessPolicy, PublicQuestionAccessPolicy>();
|
||||
services.AddScoped<IQuestionReferenceService, QuestionReferenceService>();
|
||||
services.AddScoped<IProfileService, ProfileService>();
|
||||
services.AddScoped<IScorelineQueryService, ScorelineQueryService>();
|
||||
services.AddScoped<IStudyContentQueryService, StudyContentQueryService>();
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Learning;
|
||||
@@ -9,7 +12,11 @@ using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Learning;
|
||||
|
||||
public sealed class LearningActivityService(TikuDbContext dbContext) : ILearningActivityService
|
||||
public sealed class LearningActivityService(
|
||||
TikuDbContext dbContext,
|
||||
IQuestionReferenceService questionReferenceService,
|
||||
IPublicQuestionAccessPolicy publicQuestionAccessPolicy,
|
||||
ITenantExecutionScope tenantExecutionScope) : ILearningActivityService
|
||||
{
|
||||
private const int DefaultLimit = 100;
|
||||
private const int MaxLimit = 500;
|
||||
@@ -113,47 +120,36 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
SubmitAnswerCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var question = await dbContext.Questions
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var sessionQuestion = await dbContext.PracticeSessionQuestions
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.Id == command.QuestionId &&
|
||||
item.Status == QuestionStatus.Published)
|
||||
.Select(item => new
|
||||
{
|
||||
item.Id,
|
||||
item.CurrentVersionId
|
||||
})
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (question is null)
|
||||
{
|
||||
throw new LearningResourceNotFoundException("question_not_found", "Question was not found.");
|
||||
}
|
||||
|
||||
if (command.PracticeSessionId.HasValue)
|
||||
{
|
||||
var sessionExists = await dbContext.PracticeSessions.AnyAsync(
|
||||
session =>
|
||||
item.Id == command.SessionQuestionId)
|
||||
.Join(
|
||||
dbContext.PracticeSessions.AsNoTracking().Where(session =>
|
||||
session.TenantId == actor.TenantId &&
|
||||
session.UserId == actor.UserId &&
|
||||
session.Id == command.PracticeSessionId.Value,
|
||||
cancellationToken);
|
||||
session.FinishedAt == null &&
|
||||
(!session.ExpiresAt.HasValue || session.ExpiresAt > now)),
|
||||
item => new { item.TenantId, Id = item.PracticeSessionId },
|
||||
session => new { session.TenantId, session.Id },
|
||||
(item, session) => item)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (!sessionExists)
|
||||
{
|
||||
throw new LearningResourceNotFoundException("practice_session_not_found", "Practice session was not found.");
|
||||
}
|
||||
if (sessionQuestion is null)
|
||||
{
|
||||
throw new LearningResourceNotFoundException(
|
||||
"session_question_not_found",
|
||||
"An active practice session question was not found.");
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var record = new AnswerRecord
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
UserId = actor.UserId,
|
||||
QuestionId = question.Id,
|
||||
QuestionVersionId = question.CurrentVersionId,
|
||||
PracticeSessionId = command.PracticeSessionId,
|
||||
PracticeSessionId = sessionQuestion.PracticeSessionId,
|
||||
SessionQuestionId = sessionQuestion.Id,
|
||||
SelectedOptions = JsonSerializer.SerializeToElement(command.SelectedOptions ?? []),
|
||||
AnswerText = command.AnswerText,
|
||||
IsCorrect = command.SelfJudgedCorrect,
|
||||
@@ -165,7 +161,7 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
if (command.SelfJudgedCorrect == false)
|
||||
{
|
||||
var wrongQuestion = await dbContext.WrongQuestions.FindAsync(
|
||||
[actor.TenantId, actor.UserId, question.Id],
|
||||
[actor.TenantId, actor.UserId, sessionQuestion.QuestionReferenceId],
|
||||
cancellationToken);
|
||||
|
||||
if (wrongQuestion is null)
|
||||
@@ -174,7 +170,9 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
UserId = actor.UserId,
|
||||
QuestionId = question.Id,
|
||||
QuestionReferenceId = sessionQuestion.QuestionReferenceId,
|
||||
QuestionOwnerTenantId = sessionQuestion.QuestionOwnerTenantId,
|
||||
QuestionId = sessionQuestion.QuestionId,
|
||||
WrongCount = 1,
|
||||
LastWrongAt = now
|
||||
});
|
||||
@@ -204,8 +202,10 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(item => new FavoriteQuestionItem(
|
||||
item.QuestionId,
|
||||
item.Source,
|
||||
item.QuestionReferenceId,
|
||||
new QuestionLocator(
|
||||
item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform,
|
||||
item.QuestionId),
|
||||
item.CreatedAt))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
@@ -217,11 +217,15 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
QuestionActionCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureQuestionExistsAsync(actor.TenantId, command.QuestionId, cancellationToken);
|
||||
var reference = await questionReferenceService.ResolveAsync(
|
||||
actor.TenantId,
|
||||
actor.UserId,
|
||||
command.Locator,
|
||||
cancellationToken);
|
||||
|
||||
var favorite = command.Favorite ?? true;
|
||||
var item = await dbContext.FavoriteQuestions.FindAsync(
|
||||
[actor.TenantId, actor.UserId, command.QuestionId],
|
||||
[actor.TenantId, actor.UserId, reference.Id],
|
||||
cancellationToken);
|
||||
|
||||
if (favorite)
|
||||
@@ -232,8 +236,10 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
UserId = actor.UserId,
|
||||
QuestionId = command.QuestionId,
|
||||
Source = "api",
|
||||
QuestionReferenceId = reference.Id,
|
||||
QuestionOwnerTenantId = reference.QuestionOwnerTenantId,
|
||||
QuestionId = reference.QuestionId,
|
||||
Source = reference.Source.ToString().ToLowerInvariant(),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
@@ -267,7 +273,10 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
.OrderByDescending(item => item.LastWrongAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(item => new WrongQuestionItem(
|
||||
item.QuestionId,
|
||||
item.QuestionReferenceId,
|
||||
new QuestionLocator(
|
||||
item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform,
|
||||
item.QuestionId),
|
||||
item.WrongCount,
|
||||
item.LastWrongAt,
|
||||
item.ResolvedAt))
|
||||
@@ -281,8 +290,13 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
QuestionActionCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var reference = await questionReferenceService.ResolveAsync(
|
||||
actor.TenantId,
|
||||
actor.UserId,
|
||||
command.Locator,
|
||||
cancellationToken);
|
||||
var item = await dbContext.WrongQuestions.FindAsync(
|
||||
[actor.TenantId, actor.UserId, command.QuestionId],
|
||||
[actor.TenantId, actor.UserId, reference.Id],
|
||||
cancellationToken);
|
||||
|
||||
if (item is null)
|
||||
@@ -308,7 +322,13 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
.OrderByDescending(item => item.WrongCount)
|
||||
.ThenBy(item => item.LastWrongAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(item => new WrongQuestionReviewPlanItem(item.QuestionId, item.WrongCount, item.LastWrongAt))
|
||||
.Select(item => new WrongQuestionReviewPlanItem(
|
||||
item.QuestionReferenceId,
|
||||
new QuestionLocator(
|
||||
item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform,
|
||||
item.QuestionId),
|
||||
item.WrongCount,
|
||||
item.LastWrongAt))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new WrongQuestionReviewPlan(
|
||||
items,
|
||||
@@ -594,12 +614,24 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var assembly = await BuildPracticeAssemblyAsync(actor.TenantId, command, cancellationToken);
|
||||
var questionIds = await CollectQuestionIdsAsync(actor, assembly, cancellationToken);
|
||||
if (questionIds.Count == 0)
|
||||
var questionReferenceIds = await CollectQuestionReferenceIdsAsync(actor, assembly, cancellationToken);
|
||||
if (questionReferenceIds.Count == 0)
|
||||
{
|
||||
throw new LearningValidationException("no_practice_questions", "No published questions are available for this practice target.");
|
||||
}
|
||||
|
||||
|
||||
var containsPlatformQuestion = await dbContext.TenantQuestionReferences.AsNoTracking().AnyAsync(
|
||||
reference =>
|
||||
reference.TenantId == actor.TenantId &&
|
||||
questionReferenceIds.Contains(reference.Id) &&
|
||||
reference.Source == QuestionSource.Platform,
|
||||
cancellationToken);
|
||||
if (containsPlatformQuestion)
|
||||
{
|
||||
await publicQuestionAccessPolicy.EnsureCanStartAsync(actor.TenantId, cancellationToken);
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var session = new PracticeSession
|
||||
{
|
||||
@@ -612,26 +644,46 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
CollectionId = assembly.CollectionId,
|
||||
EntryId = assembly.EntryId,
|
||||
ContentNodeId = assembly.ContentNodeId,
|
||||
QuestionIds = JsonSerializer.SerializeToElement(questionIds),
|
||||
QuestionCount = questionIds.Count,
|
||||
QuestionCount = questionReferenceIds.Count,
|
||||
DurationMinutes = assembly.DurationMinutes,
|
||||
TotalScore = assembly.TotalScore,
|
||||
ExpiresAt = assembly.DurationMinutes.HasValue
|
||||
? now.AddMinutes(assembly.DurationMinutes.Value)
|
||||
: null,
|
||||
AccessMode = PracticeAccessMode.Free,
|
||||
ConsumedFreeQuota = questionIds.Count,
|
||||
ConsumedFreeQuota = questionReferenceIds.Count,
|
||||
AccessSnapshot = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
strategy = "v1_free",
|
||||
requestedCount = assembly.QuestionLimit,
|
||||
grantedCount = questionIds.Count
|
||||
grantedCount = questionReferenceIds.Count
|
||||
}),
|
||||
Metadata = command.Metadata.ValueKind is JsonValueKind.Undefined
|
||||
? JsonDefaults.Object()
|
||||
: command.Metadata
|
||||
};
|
||||
dbContext.PracticeSessions.Add(session);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
var selections = await LoadQuestionSelectionsAsync(
|
||||
actor.TenantId,
|
||||
questionReferenceIds,
|
||||
cancellationToken);
|
||||
var scorePerQuestion = session.TotalScore.HasValue && selections.Count > 0
|
||||
? session.TotalScore.Value / selections.Count
|
||||
: (decimal?)null;
|
||||
dbContext.PracticeSessionQuestions.AddRange(selections.Select((selection, index) =>
|
||||
new PracticeSessionQuestion
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
PracticeSessionId = session.Id,
|
||||
QuestionReferenceId = selection.QuestionReferenceId,
|
||||
QuestionOwnerTenantId = selection.QuestionOwnerTenantId,
|
||||
QuestionId = selection.QuestionId,
|
||||
QuestionVersionId = selection.QuestionVersionId,
|
||||
Position = index,
|
||||
Score = scorePerQuestion
|
||||
}));
|
||||
dbContext.PracticeAccessEvents.Add(new PracticeAccessEvent
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
@@ -640,8 +692,8 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
EventType = PracticeAccessEventType.SessionCreated,
|
||||
AccessMode = PracticeAccessEventMode.Free,
|
||||
RequestedCount = assembly.QuestionLimit,
|
||||
GrantedCount = questionIds.Count,
|
||||
ConsumedFreeQuota = questionIds.Count,
|
||||
GrantedCount = selections.Count,
|
||||
ConsumedFreeQuota = selections.Count,
|
||||
Metadata = session.AccessSnapshot
|
||||
});
|
||||
|
||||
@@ -655,44 +707,20 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var session = await GetPracticeSessionAsync(actor, filter.PracticeSessionId, cancellationToken);
|
||||
var questionIds = ReadGuidArray(session.QuestionIds);
|
||||
var questions = await dbContext.Questions
|
||||
.AsNoTracking()
|
||||
.Where(question =>
|
||||
question.TenantId == actor.TenantId &&
|
||||
questionIds.Contains(question.Id))
|
||||
.GroupJoin(
|
||||
dbContext.QuestionVersions.AsNoTracking(),
|
||||
question => new { question.TenantId, QuestionId = question.Id, VersionId = question.CurrentVersionId },
|
||||
version => new { version.TenantId, version.QuestionId, VersionId = (Guid?)version.Id },
|
||||
(question, versions) => new { question, version = versions.FirstOrDefault() })
|
||||
.Select(row => new PracticeSessionQuestionItem(
|
||||
row.question.Id,
|
||||
row.question.Type,
|
||||
row.question.TypeLabel,
|
||||
row.question.Difficulty,
|
||||
row.question.Tags,
|
||||
row.version == null ? null : row.version.Id,
|
||||
row.version == null ? null : row.version.Content,
|
||||
row.version == null ? JsonDefaults.Array() : row.version.Options,
|
||||
row.version == null ? null : row.version.Explanation))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var questionById = questions.ToDictionary(question => question.Id);
|
||||
var orderedQuestions = questionIds
|
||||
.Where(questionById.ContainsKey)
|
||||
.Select(questionId => questionById[questionId])
|
||||
.ToArray();
|
||||
var orderedQuestions = await LoadSessionQuestionItemsAsync(
|
||||
actor.TenantId,
|
||||
session.Id,
|
||||
cancellationToken);
|
||||
|
||||
var answers = await dbContext.AnswerRecords
|
||||
.AsNoTracking()
|
||||
.Where(answer =>
|
||||
answer.TenantId == actor.TenantId &&
|
||||
answer.UserId == actor.UserId &&
|
||||
answer.PracticeSessionId == session.Id &&
|
||||
answer.QuestionId != null)
|
||||
answer.PracticeSessionId == session.Id)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var answersByQuestion = answers
|
||||
.GroupBy(answer => answer.QuestionId!.Value)
|
||||
.GroupBy(answer => answer.SessionQuestionId)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => ToItem(group.OrderByDescending(answer => answer.AnsweredAt).First()));
|
||||
@@ -885,7 +913,7 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<List<Guid>> CollectQuestionIdsAsync(
|
||||
private async Task<List<Guid>> CollectQuestionReferenceIdsAsync(
|
||||
LearningActor actor,
|
||||
PracticeAssembly assembly,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -898,16 +926,10 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.ResolvedAt == null)
|
||||
.Join(
|
||||
dbContext.Questions.AsNoTracking(),
|
||||
item => new { item.TenantId, item.QuestionId },
|
||||
question => new { question.TenantId, QuestionId = question.Id },
|
||||
(item, question) => new { item, question })
|
||||
.Where(row => row.question.Status == QuestionStatus.Published)
|
||||
.OrderByDescending(row => row.item.WrongCount)
|
||||
.ThenBy(row => row.item.LastWrongAt)
|
||||
.OrderByDescending(item => item.WrongCount)
|
||||
.ThenBy(item => item.LastWrongAt)
|
||||
.Take(assembly.QuestionLimit)
|
||||
.Select(row => row.question.Id)
|
||||
.Select(item => item.QuestionReferenceId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -918,15 +940,9 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId)
|
||||
.Join(
|
||||
dbContext.Questions.AsNoTracking(),
|
||||
item => new { item.TenantId, item.QuestionId },
|
||||
question => new { question.TenantId, QuestionId = question.Id },
|
||||
(item, question) => new { item, question })
|
||||
.Where(row => row.question.Status == QuestionStatus.Published)
|
||||
.OrderByDescending(row => row.item.CreatedAt)
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(assembly.QuestionLimit)
|
||||
.Select(row => row.question.Id)
|
||||
.Select(item => item.QuestionReferenceId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -937,16 +953,9 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.CollectionId == assembly.CollectionId.Value)
|
||||
.Join(
|
||||
dbContext.Questions.AsNoTracking(),
|
||||
item => new { item.TenantId, item.QuestionId },
|
||||
question => new { question.TenantId, QuestionId = question.Id },
|
||||
(item, question) => new { item, question })
|
||||
.Where(row => row.question.Status == QuestionStatus.Published)
|
||||
.OrderBy(row => row.item.SortOrder)
|
||||
.ThenBy(row => row.question.CreatedAt)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.Take(assembly.QuestionLimit)
|
||||
.Select(row => row.question.Id)
|
||||
.Select(item => item.QuestionReferenceId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -973,11 +982,23 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
throw new LearningValidationException("practice_target_required", "Practice target is required.");
|
||||
}
|
||||
|
||||
return await query
|
||||
var questionIds = await query
|
||||
.OrderBy(question => question.CreatedAt)
|
||||
.Take(assembly.QuestionLimit)
|
||||
.Select(question => question.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var referenceIds = new List<Guid>(questionIds.Count);
|
||||
foreach (var questionId in questionIds)
|
||||
{
|
||||
var reference = await questionReferenceService.ResolveAsync(
|
||||
actor.TenantId,
|
||||
actor.UserId,
|
||||
new QuestionLocator(QuestionSource.Tenant, questionId),
|
||||
cancellationToken);
|
||||
referenceIds.Add(reference.Id);
|
||||
}
|
||||
|
||||
return referenceIds;
|
||||
}
|
||||
|
||||
private static IQueryable<Question> ApplyLegacyTargetFilter(
|
||||
@@ -997,6 +1018,111 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<QuestionSelection>> LoadQuestionSelectionsAsync(
|
||||
Guid tenantId,
|
||||
IReadOnlyCollection<Guid> questionReferenceIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = await tenantExecutionScope.ExecuteAsync(
|
||||
tenantId,
|
||||
"Lock published question versions for a new practice session",
|
||||
async (provider, token) =>
|
||||
{
|
||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||
return await (
|
||||
from reference in systemDbContext.TenantQuestionReferences.AsNoTracking()
|
||||
join question in systemDbContext.Questions.AsNoTracking()
|
||||
on new { TenantId = reference.QuestionOwnerTenantId, Id = reference.QuestionId }
|
||||
equals new { question.TenantId, question.Id }
|
||||
join version in systemDbContext.QuestionVersions.AsNoTracking()
|
||||
on new
|
||||
{
|
||||
TenantId = reference.QuestionOwnerTenantId,
|
||||
reference.QuestionId,
|
||||
Id = question.CurrentVersionId
|
||||
}
|
||||
equals new
|
||||
{
|
||||
version.TenantId,
|
||||
version.QuestionId,
|
||||
Id = (Guid?)version.Id
|
||||
}
|
||||
where reference.TenantId == tenantId &&
|
||||
questionReferenceIds.Contains(reference.Id) &&
|
||||
question.Status == QuestionStatus.Published
|
||||
select new QuestionSelection(
|
||||
reference.Id,
|
||||
reference.QuestionOwnerTenantId,
|
||||
reference.QuestionId,
|
||||
version.Id))
|
||||
.ToArrayAsync(token);
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
var byReference = rows.ToDictionary(row => row.QuestionReferenceId);
|
||||
if (byReference.Count != questionReferenceIds.Distinct().Count())
|
||||
{
|
||||
throw new LearningValidationException(
|
||||
"practice_question_unavailable",
|
||||
"One or more practice questions have no published version.");
|
||||
}
|
||||
|
||||
return questionReferenceIds.Select(referenceId => byReference[referenceId]).ToArray();
|
||||
}
|
||||
|
||||
private Task<PracticeSessionQuestionItem[]> LoadSessionQuestionItemsAsync(
|
||||
Guid tenantId,
|
||||
Guid practiceSessionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return tenantExecutionScope.ExecuteAsync(
|
||||
tenantId,
|
||||
"Read locked question versions for a tenant practice session",
|
||||
async (provider, token) =>
|
||||
{
|
||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||
return await (
|
||||
from sessionQuestion in systemDbContext.PracticeSessionQuestions.AsNoTracking()
|
||||
join question in systemDbContext.Questions.AsNoTracking()
|
||||
on new
|
||||
{
|
||||
TenantId = sessionQuestion.QuestionOwnerTenantId,
|
||||
Id = sessionQuestion.QuestionId
|
||||
}
|
||||
equals new { question.TenantId, question.Id }
|
||||
join version in systemDbContext.QuestionVersions.AsNoTracking()
|
||||
on new
|
||||
{
|
||||
TenantId = sessionQuestion.QuestionOwnerTenantId,
|
||||
sessionQuestion.QuestionId,
|
||||
Id = sessionQuestion.QuestionVersionId
|
||||
}
|
||||
equals new { version.TenantId, version.QuestionId, version.Id }
|
||||
where sessionQuestion.TenantId == tenantId &&
|
||||
sessionQuestion.PracticeSessionId == practiceSessionId
|
||||
orderby sessionQuestion.Position
|
||||
select new PracticeSessionQuestionItem(
|
||||
sessionQuestion.Id,
|
||||
sessionQuestion.QuestionReferenceId,
|
||||
new QuestionLocator(
|
||||
sessionQuestion.QuestionOwnerTenantId == tenantId
|
||||
? QuestionSource.Tenant
|
||||
: QuestionSource.Platform,
|
||||
sessionQuestion.QuestionId),
|
||||
question.Id,
|
||||
question.Type,
|
||||
question.TypeLabel,
|
||||
question.Difficulty,
|
||||
question.Tags,
|
||||
version.Id,
|
||||
version.Content,
|
||||
version.Options,
|
||||
version.Explanation))
|
||||
.ToArrayAsync(token);
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<PracticeSession> GetPracticeSessionAsync(
|
||||
LearningActor actor,
|
||||
Guid? practiceSessionId,
|
||||
@@ -1028,8 +1154,13 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
PracticeSession session,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var questionIds = ReadGuidArray(session.QuestionIds);
|
||||
if (questionIds.Count == 0)
|
||||
var sessionQuestions = await dbContext.PracticeSessionQuestions.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.PracticeSessionId == session.Id)
|
||||
.OrderBy(item => item.Position)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
if (sessionQuestions.Length == 0)
|
||||
{
|
||||
throw new LearningValidationException("practice_session_empty", "Practice session has no question snapshot.");
|
||||
}
|
||||
@@ -1039,21 +1170,20 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
.Where(answer =>
|
||||
answer.TenantId == actor.TenantId &&
|
||||
answer.UserId == actor.UserId &&
|
||||
answer.PracticeSessionId == session.Id &&
|
||||
answer.QuestionId != null)
|
||||
answer.PracticeSessionId == session.Id)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var latestAnswers = answers
|
||||
.GroupBy(answer => answer.QuestionId!.Value)
|
||||
.GroupBy(answer => answer.SessionQuestionId)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group.OrderByDescending(answer => answer.AnsweredAt).First());
|
||||
var totalQuestions = questionIds.Count;
|
||||
var answeredCount = questionIds.Count(latestAnswers.ContainsKey);
|
||||
var correctCount = questionIds.Count(questionId =>
|
||||
latestAnswers.TryGetValue(questionId, out var answer) &&
|
||||
var totalQuestions = sessionQuestions.Length;
|
||||
var answeredCount = sessionQuestions.Count(question => latestAnswers.ContainsKey(question.Id));
|
||||
var correctCount = sessionQuestions.Count(question =>
|
||||
latestAnswers.TryGetValue(question.Id, out var answer) &&
|
||||
answer.IsCorrect == true);
|
||||
var wrongCount = questionIds.Count(questionId =>
|
||||
latestAnswers.TryGetValue(questionId, out var answer) &&
|
||||
var wrongCount = sessionQuestions.Count(question =>
|
||||
latestAnswers.TryGetValue(question.Id, out var answer) &&
|
||||
answer.IsCorrect != true);
|
||||
var unansweredCount = Math.Max(0, totalQuestions - answeredCount);
|
||||
var totalScore = session.TotalScore ?? totalQuestions;
|
||||
@@ -1062,18 +1192,22 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
var accuracy = totalQuestions == 0 ? 0 : Math.Round((decimal)correctCount / totalQuestions, 4);
|
||||
var submittedAt = DateTimeOffset.UtcNow;
|
||||
var durationSeconds = Math.Max(0, (int)(submittedAt - session.StartedAt).TotalSeconds);
|
||||
var wrongQuestionIds = questionIds
|
||||
.Where(questionId =>
|
||||
latestAnswers.TryGetValue(questionId, out var answer) &&
|
||||
var wrongQuestionIds = sessionQuestions
|
||||
.Where(question =>
|
||||
latestAnswers.TryGetValue(question.Id, out var answer) &&
|
||||
answer.IsCorrect != true)
|
||||
.Select(question => question.QuestionReferenceId)
|
||||
.ToArray();
|
||||
var questionResults = questionIds
|
||||
.Select(questionId =>
|
||||
var questionResults = sessionQuestions
|
||||
.Select(question =>
|
||||
{
|
||||
latestAnswers.TryGetValue(questionId, out var answer);
|
||||
latestAnswers.TryGetValue(question.Id, out var answer);
|
||||
return new
|
||||
{
|
||||
questionId,
|
||||
sessionQuestionId = question.Id,
|
||||
questionReferenceId = question.QuestionReferenceId,
|
||||
questionId = question.QuestionId,
|
||||
source = question.QuestionOwnerTenantId == actor.TenantId ? "tenant" : "platform",
|
||||
answered = answer is not null,
|
||||
isCorrect = answer?.IsCorrect,
|
||||
score = answer?.IsCorrect == true ? scorePerQuestion : 0,
|
||||
@@ -1190,8 +1324,7 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
{
|
||||
return new AnswerRecordItem(
|
||||
record.Id,
|
||||
record.QuestionId!.Value,
|
||||
record.QuestionVersionId,
|
||||
record.SessionQuestionId,
|
||||
record.PracticeSessionId,
|
||||
record.SelectedOptions,
|
||||
record.AnswerText,
|
||||
@@ -1226,7 +1359,6 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
item.CollectionId,
|
||||
item.EntryId,
|
||||
item.ContentNodeId,
|
||||
item.QuestionIds,
|
||||
item.QuestionCount,
|
||||
item.DurationMinutes,
|
||||
item.TotalScore,
|
||||
@@ -1340,6 +1472,12 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
int QuestionLimit,
|
||||
int? DurationMinutes,
|
||||
decimal? TotalScore);
|
||||
|
||||
private sealed record QuestionSelection(
|
||||
Guid QuestionReferenceId,
|
||||
Guid QuestionOwnerTenantId,
|
||||
Guid QuestionId,
|
||||
Guid QuestionVersionId);
|
||||
}
|
||||
|
||||
public class LearningException(string code, string message) : Exception(message)
|
||||
|
||||
@@ -182,7 +182,7 @@ internal sealed class ContentImportItemConfiguration : IEntityTypeConfiguration<
|
||||
builder.Property(entity => entity.SourcePayload).IsJson("{}");
|
||||
builder.Property(entity => entity.NormalizedPayload).IsJson("{}");
|
||||
builder.Property(entity => entity.ContentHash).HasMaxLength(128);
|
||||
builder.HasIndex(entity => new { entity.JobId, entity.RowNo }).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.JobId, entity.RowNo }).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.JobId, entity.Status, entity.RowNo });
|
||||
builder.ToTable(table =>
|
||||
{
|
||||
|
||||
@@ -134,7 +134,7 @@ internal sealed class PaymentConfiguration : IEntityTypeConfiguration<Payment>
|
||||
builder.Property(entity => entity.RawPayload).IsJson("{}");
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.OrderId, entity.UpdatedAt });
|
||||
builder.HasIndex(entity => new { entity.Provider, entity.ProviderTradeNo })
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.Provider, entity.ProviderTradeNo })
|
||||
.IsUnique()
|
||||
.HasFilter("provider_trade_no is not null");
|
||||
builder.ToTable(table =>
|
||||
@@ -160,7 +160,7 @@ internal sealed class PaymentEventConfiguration : IEntityTypeConfiguration<Payme
|
||||
builder.Property(entity => entity.EventId).HasMaxLength(200);
|
||||
builder.Property(entity => entity.Payload).IsJson("{}");
|
||||
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
|
||||
builder.HasIndex(entity => new { entity.Provider, entity.EventId }).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.Provider, entity.EventId }).IsUnique();
|
||||
builder.HasOne<Tenant>().WithMany().HasForeignKey(entity => entity.TenantId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<Payment>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.PaymentId })
|
||||
@@ -438,7 +438,7 @@ internal sealed class CommerceReconciliationItemConfiguration : IEntityTypeConfi
|
||||
builder.Property(entity => entity.IssueCode).HasMaxLength(100);
|
||||
builder.Property(entity => entity.Details).IsJson("{}");
|
||||
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
|
||||
builder.HasIndex(entity => new { entity.BatchId, entity.RowNo }).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.BatchId, entity.RowNo }).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.BatchId, entity.MatchStatus, entity.RowNo });
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.OrderNo, entity.CreatedAt });
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.RefundNo, entity.ProviderRefundNo, entity.CreatedAt });
|
||||
|
||||
@@ -180,7 +180,7 @@ internal sealed class QuestionCollectionItemConfiguration :
|
||||
{
|
||||
entity.TenantId,
|
||||
entity.CollectionId,
|
||||
entity.QuestionId
|
||||
entity.QuestionReferenceId
|
||||
}).IsUnique();
|
||||
builder.HasIndex(entity => new
|
||||
{
|
||||
@@ -194,10 +194,14 @@ internal sealed class QuestionCollectionItemConfiguration :
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.CollectionId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<Question>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
|
||||
builder.HasOne<TenantQuestionReference>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionReferenceId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<Question>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.QuestionOwnerTenantId, entity.QuestionId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,72 +65,48 @@ internal sealed class ContentAssetSecurityScanEventConfiguration : IEntityTypeCo
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class QuestionBankGrantConfiguration : IEntityTypeConfiguration<QuestionBankGrant>
|
||||
internal sealed class TenantQuestionBankPreferenceConfiguration : IEntityTypeConfiguration<TenantQuestionBankPreference>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<QuestionBankGrant> builder)
|
||||
public void Configure(EntityTypeBuilder<TenantQuestionBankPreference> builder)
|
||||
{
|
||||
builder.ConfigureEntity("question_bank_grants");
|
||||
builder.ConfigureTenantEntity("tenant_question_bank_preferences");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.GrantScope).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.AllowedPlanCodes)
|
||||
.HasColumnType("text[]")
|
||||
.HasDefaultValueSql("'{}'::text[]");
|
||||
builder.Property(entity => entity.AllowedTenantIds)
|
||||
.HasColumnType("uuid[]")
|
||||
.HasDefaultValueSql("'{}'::uuid[]");
|
||||
builder.Property(entity => entity.AllowedRegionIds)
|
||||
.HasColumnType("uuid[]")
|
||||
.HasDefaultValueSql("'{}'::uuid[]");
|
||||
builder.Property(entity => entity.AllowedSubjectIds)
|
||||
.HasColumnType("uuid[]")
|
||||
.HasDefaultValueSql("'{}'::uuid[]");
|
||||
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.Alias).HasMaxLength(300);
|
||||
builder.Property(entity => entity.NavigationLocation).HasMaxLength(100);
|
||||
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||
builder.HasIndex(entity => new { entity.SourceQuestionBankId, entity.Status, entity.StartsAt, entity.ExpiresAt });
|
||||
builder.HasIndex(entity => entity.AllowedTenantIds).HasMethod("gin");
|
||||
builder.HasIndex(entity => entity.AllowedPlanCodes).HasMethod("gin");
|
||||
builder.HasIndex(entity => new
|
||||
{
|
||||
entity.TenantId,
|
||||
entity.QuestionBankOwnerTenantId,
|
||||
entity.QuestionBankId
|
||||
}).IsUnique();
|
||||
builder.HasOne<QuestionBank>().WithMany()
|
||||
.HasForeignKey(entity => entity.SourceQuestionBankId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
.HasForeignKey(entity => new { entity.QuestionBankOwnerTenantId, entity.QuestionBankId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.CreatedBy).OnDelete(DeleteBehavior.SetNull);
|
||||
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.UpdatedBy).OnDelete(DeleteBehavior.SetNull);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TenantQuestionBankAdoptionConfiguration : IEntityTypeConfiguration<TenantQuestionBankAdoption>
|
||||
internal sealed class TenantQuestionReferenceConfiguration : IEntityTypeConfiguration<TenantQuestionReference>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TenantQuestionBankAdoption> builder)
|
||||
public void Configure(EntityTypeBuilder<TenantQuestionReference> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("tenant_question_bank_adoptions");
|
||||
builder.ConfigureTenantEntity("tenant_question_references");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.AdoptionMode).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.SyncStatus).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.SourceSnapshot).IsJson("{}");
|
||||
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.SourceQuestionBankId }).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.UpdatedAt });
|
||||
builder.ToTable(table => table.HasCheckConstraint("ck_tenant_question_bank_adoptions_copied_count", "copied_question_count >= 0"));
|
||||
builder.HasOne<QuestionBank>().WithMany()
|
||||
.HasForeignKey(entity => entity.SourceQuestionBankId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<QuestionBankGrant>().WithMany()
|
||||
.HasForeignKey(entity => entity.GrantId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
builder.HasOne<QuestionBank>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.TargetQuestionBankId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<ContentEntry>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.TargetEntryId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<QuestionCollection>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.TargetCollectionId })
|
||||
builder.Property(entity => entity.Source).HasSnakeCaseEnum();
|
||||
builder.HasAlternateKey(entity => new
|
||||
{
|
||||
entity.TenantId,
|
||||
entity.QuestionOwnerTenantId,
|
||||
entity.QuestionId
|
||||
});
|
||||
builder.HasOne<Question>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.QuestionOwnerTenantId, entity.QuestionId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.CreatedBy).OnDelete(DeleteBehavior.SetNull);
|
||||
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.UpdatedBy).OnDelete(DeleteBehavior.SetNull);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ internal sealed class PracticeSessionConfiguration : IEntityTypeConfiguration<Pr
|
||||
builder.Property(entity => entity.Mode).HasMaxLength(50);
|
||||
builder.Property(entity => entity.TargetType).HasMaxLength(50);
|
||||
builder.Property(entity => entity.StartedAt).HasDefaultValueSql("now()");
|
||||
builder.Property(entity => entity.QuestionIds).IsJson("[]");
|
||||
builder.Property(entity => entity.TotalScore).HasPrecision(8, 2);
|
||||
builder.Property(entity => entity.AccessMode)
|
||||
.HasSnakeCaseEnum()
|
||||
@@ -55,6 +54,45 @@ internal sealed class PracticeSessionConfiguration : IEntityTypeConfiguration<Pr
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PracticeSessionQuestionConfiguration : IEntityTypeConfiguration<PracticeSessionQuestion>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PracticeSessionQuestion> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("practice_session_questions");
|
||||
builder.HasAlternateKey(entity => new { entity.TenantId, entity.PracticeSessionId, entity.Id });
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.PracticeSessionId, entity.Position }).IsUnique();
|
||||
builder.Property(entity => entity.Score).HasPrecision(8, 2);
|
||||
|
||||
builder.HasOne<PracticeSession>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.PracticeSessionId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<TenantQuestionReference>().WithMany()
|
||||
.HasForeignKey(entity => new
|
||||
{
|
||||
entity.TenantId,
|
||||
entity.QuestionOwnerTenantId,
|
||||
entity.QuestionId
|
||||
})
|
||||
.HasPrincipalKey(entity => new
|
||||
{
|
||||
entity.TenantId,
|
||||
entity.QuestionOwnerTenantId,
|
||||
entity.QuestionId
|
||||
})
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<QuestionVersion>().WithMany()
|
||||
.HasForeignKey(entity => new
|
||||
{
|
||||
TenantId = entity.QuestionOwnerTenantId,
|
||||
entity.QuestionId,
|
||||
Id = entity.QuestionVersionId
|
||||
})
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.QuestionId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AnswerRecordConfiguration : IEntityTypeConfiguration<AnswerRecord>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<AnswerRecord> builder)
|
||||
@@ -67,13 +105,6 @@ internal sealed class AnswerRecordConfiguration : IEntityTypeConfiguration<Answe
|
||||
builder.Property(entity => entity.AnsweredAt).HasDefaultValueSql("now()");
|
||||
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.QuestionId });
|
||||
builder.HasIndex(entity => new
|
||||
{
|
||||
entity.TenantId,
|
||||
entity.QuestionId,
|
||||
entity.QuestionVersionId
|
||||
});
|
||||
builder.HasIndex(entity => new
|
||||
{
|
||||
entity.TenantId,
|
||||
@@ -81,31 +112,9 @@ internal sealed class AnswerRecordConfiguration : IEntityTypeConfiguration<Answe
|
||||
entity.PracticeSessionId
|
||||
});
|
||||
|
||||
builder.ToTable(table => table.HasCheckConstraint(
|
||||
"ck_answer_records_version_requires_question",
|
||||
"question_version_id is null or question_id is not null"));
|
||||
|
||||
builder.HasOne<User>().WithMany()
|
||||
.HasForeignKey(entity => entity.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<Question>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<QuestionVersion>().WithMany()
|
||||
.HasForeignKey(entity => new
|
||||
{
|
||||
entity.TenantId,
|
||||
entity.QuestionId,
|
||||
entity.QuestionVersionId
|
||||
})
|
||||
.HasPrincipalKey(entity => new
|
||||
{
|
||||
entity.TenantId,
|
||||
entity.QuestionId,
|
||||
entity.Id
|
||||
})
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<PracticeSession>().WithMany()
|
||||
.HasForeignKey(entity => new
|
||||
{
|
||||
@@ -120,6 +129,20 @@ internal sealed class AnswerRecordConfiguration : IEntityTypeConfiguration<Answe
|
||||
entity.Id
|
||||
})
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<PracticeSessionQuestion>().WithMany()
|
||||
.HasForeignKey(entity => new
|
||||
{
|
||||
entity.TenantId,
|
||||
entity.PracticeSessionId,
|
||||
Id = entity.SessionQuestionId
|
||||
})
|
||||
.HasPrincipalKey(entity => new
|
||||
{
|
||||
entity.TenantId,
|
||||
entity.PracticeSessionId,
|
||||
entity.Id
|
||||
})
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +151,7 @@ internal sealed class FavoriteQuestionConfiguration : IEntityTypeConfiguration<F
|
||||
public void Configure(EntityTypeBuilder<FavoriteQuestion> builder)
|
||||
{
|
||||
builder.ToTable("favorite_questions");
|
||||
builder.HasKey(entity => new { entity.TenantId, entity.UserId, entity.QuestionId });
|
||||
builder.HasKey(entity => new { entity.TenantId, entity.UserId, entity.QuestionReferenceId });
|
||||
builder.Property(entity => entity.Source).HasMaxLength(50);
|
||||
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
|
||||
|
||||
@@ -138,10 +161,14 @@ internal sealed class FavoriteQuestionConfiguration : IEntityTypeConfiguration<F
|
||||
builder.HasOne<User>().WithMany()
|
||||
.HasForeignKey(entity => entity.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<Question>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
|
||||
builder.HasOne<TenantQuestionReference>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionReferenceId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<Question>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.QuestionOwnerTenantId, entity.QuestionId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +177,7 @@ internal sealed class WrongQuestionConfiguration : IEntityTypeConfiguration<Wron
|
||||
public void Configure(EntityTypeBuilder<WrongQuestion> builder)
|
||||
{
|
||||
builder.ToTable("wrong_questions");
|
||||
builder.HasKey(entity => new { entity.TenantId, entity.UserId, entity.QuestionId });
|
||||
builder.HasKey(entity => new { entity.TenantId, entity.UserId, entity.QuestionReferenceId });
|
||||
builder.Property(entity => entity.WrongCount).HasDefaultValue(1);
|
||||
builder.Property(entity => entity.LastWrongAt).HasDefaultValueSql("now()");
|
||||
|
||||
@@ -160,10 +187,14 @@ internal sealed class WrongQuestionConfiguration : IEntityTypeConfiguration<Wron
|
||||
builder.HasOne<User>().WithMany()
|
||||
.HasForeignKey(entity => entity.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<Question>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
|
||||
builder.HasOne<TenantQuestionReference>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionReferenceId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<Question>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.QuestionOwnerTenantId, entity.QuestionId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ internal sealed class TenantInvoiceConfiguration : IEntityTypeConfiguration<Tena
|
||||
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.Currency).HasMaxLength(10);
|
||||
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||
builder.HasIndex(entity => entity.InvoiceNo).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.InvoiceNo }).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.DueDate });
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.BillingPeriodStart, entity.BillingPeriodEnd })
|
||||
.IsUnique()
|
||||
@@ -110,7 +110,7 @@ internal sealed class TenantInvoicePaymentConfiguration : IEntityTypeConfigurati
|
||||
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.ProviderTradeNo).HasMaxLength(200);
|
||||
builder.Property(entity => entity.RawPayload).IsJson("{}");
|
||||
builder.HasIndex(entity => entity.PaymentNo).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.PaymentNo }).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.InvoiceId, entity.Status });
|
||||
builder.ToTable(table => table.HasCheckConstraint("ck_tenant_invoice_payments_amount", "amount_cents >= 0"));
|
||||
builder.HasOne<TenantInvoice>().WithMany()
|
||||
@@ -229,7 +229,7 @@ internal sealed class PlatformDunningNotificationEventConfiguration : IEntityTyp
|
||||
builder.Property(entity => entity.RequestPayload).IsJson("{}");
|
||||
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||
builder.Property(entity => entity.ScheduledAt).HasDefaultValueSql("now()");
|
||||
builder.HasIndex(entity => new { entity.ChannelId, entity.ReminderId }).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.ChannelId, entity.ReminderId }).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.Status, entity.NextAttemptAt, entity.ScheduledAt, entity.CreatedAt });
|
||||
builder.HasIndex(entity => new { entity.ReminderId, entity.Status, entity.CreatedAt });
|
||||
builder.HasIndex(entity => new { entity.InvoiceId, entity.Status, entity.CreatedAt });
|
||||
|
||||
@@ -14,7 +14,6 @@ internal sealed class QuestionBankConfiguration : IEntityTypeConfiguration<Quest
|
||||
builder.ConfigureTenantEntity("question_banks");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.Name).HasMaxLength(300);
|
||||
builder.Property(entity => entity.SourceScope).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||
|
||||
@@ -94,7 +93,7 @@ internal sealed class QuestionVersionConfiguration : IEntityTypeConfiguration<Qu
|
||||
builder.Property(entity => entity.SourceHash).HasMaxLength(128);
|
||||
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
|
||||
|
||||
builder.HasIndex(entity => new { entity.QuestionId, entity.VersionNo }).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.QuestionId, entity.VersionNo }).IsUnique();
|
||||
builder.HasOne<Question>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Configurations;
|
||||
|
||||
internal sealed class TaxonomyNodeConfiguration : IEntityTypeConfiguration<TaxonomyNode>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TaxonomyNode> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("taxonomy_nodes");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.NodeType).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.Code).HasMaxLength(100);
|
||||
builder.Property(entity => entity.Name).HasMaxLength(300);
|
||||
builder.Property(entity => entity.Path).HasColumnType("ltree");
|
||||
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.Code }).IsUnique();
|
||||
builder.HasOne<TaxonomyNode>().WithMany()
|
||||
.HasForeignKey(entity => new { TenantId = entity.ParentOwnerTenantId, Id = entity.ParentId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
builder.ToTable(table => table.HasCheckConstraint(
|
||||
"ck_taxonomy_nodes_parent_pair",
|
||||
"(parent_owner_tenant_id is null) = (parent_id is null)"));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class QuestionTaxonomyAssignmentConfiguration : IEntityTypeConfiguration<QuestionTaxonomyAssignment>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<QuestionTaxonomyAssignment> builder)
|
||||
{
|
||||
builder.ConfigureEntity("question_taxonomy_assignments");
|
||||
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
|
||||
builder.HasIndex(entity => new
|
||||
{
|
||||
entity.TenantId,
|
||||
entity.QuestionId,
|
||||
entity.TaxonomyOwnerTenantId,
|
||||
entity.TaxonomyNodeId
|
||||
}).IsUnique();
|
||||
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
|
||||
builder.HasOne<Question>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<TaxonomyNode>().WithMany()
|
||||
.HasForeignKey(entity => new { TenantId = entity.TaxonomyOwnerTenantId, Id = entity.TaxonomyNodeId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,9 @@ internal sealed class TenantConfiguration : IEntityTypeConfiguration<Tenant>
|
||||
|
||||
builder.HasIndex(entity => entity.Slug).IsUnique();
|
||||
builder.HasIndex(entity => entity.LegacyId).IsUnique();
|
||||
builder.HasIndex(entity => entity.Mode)
|
||||
.IsUnique()
|
||||
.HasFilter("mode = 'platform_owned'");
|
||||
|
||||
builder.HasOne<User>()
|
||||
.WithMany()
|
||||
@@ -67,7 +70,10 @@ internal sealed class TenantDomainConfiguration : IEntityTypeConfiguration<Tenan
|
||||
builder.Property(entity => entity.DomainType).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.VerificationToken).HasMaxLength(256);
|
||||
builder.HasIndex(entity => entity.Host).IsUnique();
|
||||
builder.Property(entity => entity.LastFailureReason).HasMaxLength(2000);
|
||||
builder.HasIndex(entity => entity.Host)
|
||||
.IsUnique()
|
||||
.HasAnnotation("Tiku:GlobalUnique", true);
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.IsPrimary })
|
||||
.IsUnique()
|
||||
.HasFilter("is_primary");
|
||||
@@ -114,3 +120,29 @@ internal sealed class TenantSettingsConfiguration : IEntityTypeConfiguration<Ten
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TenantFrontendConfigConfiguration : IEntityTypeConfiguration<TenantFrontendConfig>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TenantFrontendConfig> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("tenant_frontend_configs");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.HasIndex(entity => entity.TenantId).IsUnique();
|
||||
builder.Property(entity => entity.ConfigVersion).IsConcurrencyToken();
|
||||
builder.Property(entity => entity.PublishedBranding).IsJson("{}");
|
||||
builder.Property(entity => entity.PublishedTheme).IsJson("{}");
|
||||
builder.Property(entity => entity.PublishedFeatures).IsJson("{}");
|
||||
builder.Property(entity => entity.PublishedNavigation).IsJson("[]");
|
||||
builder.Property(entity => entity.PublishedHomeModules).IsJson("[]");
|
||||
builder.Property(entity => entity.DraftBranding).IsJson("{}");
|
||||
builder.Property(entity => entity.DraftTheme).IsJson("{}");
|
||||
builder.Property(entity => entity.DraftFeatures).IsJson("{}");
|
||||
builder.Property(entity => entity.DraftNavigation).IsJson("[]");
|
||||
builder.Property(entity => entity.DraftHomeModules).IsJson("[]");
|
||||
builder.ToTable(table =>
|
||||
{
|
||||
table.HasCheckConstraint("ck_tenant_frontend_configs_schema_version", "schema_version > 0");
|
||||
table.HasCheckConstraint("ck_tenant_frontend_configs_config_version", "config_version > 0");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,9 @@ internal sealed class AuthSessionConfiguration : IEntityTypeConfiguration<AuthSe
|
||||
builder.Property(entity => entity.IpAddress).HasMaxLength(64);
|
||||
builder.Property(entity => entity.UserAgent).HasMaxLength(1000);
|
||||
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||
builder.HasIndex(entity => entity.TokenHash).IsUnique();
|
||||
builder.HasIndex(entity => entity.TokenHash)
|
||||
.IsUnique()
|
||||
.HasAnnotation("Tiku:GlobalUnique", true);
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.ExpiresAt })
|
||||
.HasFilter("revoked_at is null");
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,47 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddProductActiveAndOperationsCatalog : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_products_tenant_id_region_id_type_sort_order",
|
||||
table: "products");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "is_active",
|
||||
table: "products",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_products_tenant_id_region_id_type_is_active_sort_order",
|
||||
table: "products",
|
||||
columns: new[] { "tenant_id", "region_id", "type", "is_active", "sort_order" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_products_tenant_id_region_id_type_is_active_sort_order",
|
||||
table: "products");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "is_active",
|
||||
table: "products");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_products_tenant_id_region_id_type_sort_order",
|
||||
table: "products",
|
||||
columns: new[] { "tenant_id", "region_id", "type", "sort_order" });
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,157 +0,0 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddScorelineDynamicRecords : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "scoreline_fields",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
region_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
legacy_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||
field_key = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
field_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
field_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
unit = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||
is_filter = table.Column<bool>(type: "boolean", nullable: false),
|
||||
is_required = table.Column<bool>(type: "boolean", nullable: false),
|
||||
is_visible = table.Column<bool>(type: "boolean", nullable: false),
|
||||
is_trend = table.Column<bool>(type: "boolean", nullable: false),
|
||||
options = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'[]'::jsonb"),
|
||||
placeholder = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||
sort_order = table.Column<int>(type: "integer", nullable: false),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_scoreline_fields", x => x.id);
|
||||
table.UniqueConstraint("ak_scoreline_fields_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.ForeignKey(
|
||||
name: "fk_scoreline_fields_regions_tenant_id_region_id",
|
||||
columns: x => new { x.tenant_id, x.region_id },
|
||||
principalTable: "regions",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "fk_scoreline_fields_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "scoreline_records",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
region_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
school_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
major_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
legacy_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||
year = table.Column<int>(type: "integer", nullable: false),
|
||||
school_name = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
|
||||
major_name = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
|
||||
field_values = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_scoreline_records", x => x.id);
|
||||
table.UniqueConstraint("ak_scoreline_records_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.ForeignKey(
|
||||
name: "fk_scoreline_records_majors_tenant_id_major_id",
|
||||
columns: x => new { x.tenant_id, x.major_id },
|
||||
principalTable: "majors",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "fk_scoreline_records_regions_tenant_id_region_id",
|
||||
columns: x => new { x.tenant_id, x.region_id },
|
||||
principalTable: "regions",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "fk_scoreline_records_schools_tenant_id_school_id",
|
||||
columns: x => new { x.tenant_id, x.school_id },
|
||||
principalTable: "schools",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "fk_scoreline_records_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_scoreline_fields_tenant_id_legacy_id",
|
||||
table: "scoreline_fields",
|
||||
columns: new[] { "tenant_id", "legacy_id" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_scoreline_fields_tenant_id_region_id_field_key",
|
||||
table: "scoreline_fields",
|
||||
columns: new[] { "tenant_id", "region_id", "field_key" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_scoreline_fields_tenant_id_region_id_is_filter_sort_order",
|
||||
table: "scoreline_fields",
|
||||
columns: new[] { "tenant_id", "region_id", "is_filter", "sort_order" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_scoreline_records_tenant_id_legacy_id",
|
||||
table: "scoreline_records",
|
||||
columns: new[] { "tenant_id", "legacy_id" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_scoreline_records_tenant_id_major_id",
|
||||
table: "scoreline_records",
|
||||
columns: new[] { "tenant_id", "major_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_scoreline_records_tenant_id_region_id_school_id_major_id_ye~",
|
||||
table: "scoreline_records",
|
||||
columns: new[] { "tenant_id", "region_id", "school_id", "major_id", "year" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_scoreline_records_tenant_id_school_id",
|
||||
table: "scoreline_records",
|
||||
columns: new[] { "tenant_id", "school_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_scoreline_records_tenant_id_year",
|
||||
table: "scoreline_records",
|
||||
columns: new[] { "tenant_id", "year" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "scoreline_fields");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "scoreline_records");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,30 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddStudentProfileAvatarPreset : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "avatar_preset",
|
||||
table: "student_profiles",
|
||||
type: "character varying(32)",
|
||||
maxLength: 32,
|
||||
nullable: false,
|
||||
defaultValue: "male");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "avatar_preset",
|
||||
table: "student_profiles");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,69 +0,0 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPaymentSdkAndTenantSecretFoundation : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "tenant_secrets",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
purpose = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
|
||||
provider = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
secret_key = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
secret_ref = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: false),
|
||||
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
secret_payload = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
rotated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
expires_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_tenant_secrets", x => x.id);
|
||||
table.UniqueConstraint("ak_tenant_secrets_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.ForeignKey(
|
||||
name: "fk_tenant_secrets_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_tenant_secrets_tenant_id_purpose_provider_secret_key",
|
||||
table: "tenant_secrets",
|
||||
columns: new[] { "tenant_id", "purpose", "provider", "secret_key" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_tenant_secrets_tenant_id_purpose_provider_status",
|
||||
table: "tenant_secrets",
|
||||
columns: new[] { "tenant_id", "purpose", "provider", "status" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_tenant_secrets_tenant_id_secret_ref",
|
||||
table: "tenant_secrets",
|
||||
columns: new[] { "tenant_id", "secret_ref" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "tenant_secrets");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,265 +0,0 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPointsPersistenceFoundation : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "point_activity_tasks",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
task_key = table.Column<string>(type: "citext", maxLength: 100, nullable: false),
|
||||
title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||
task_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
points = table.Column<int>(type: "integer", nullable: false),
|
||||
max_claims_per_user = table.Column<int>(type: "integer", nullable: false),
|
||||
starts_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
ends_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
sort_order = table.Column<int>(type: "integer", nullable: false),
|
||||
rules = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_point_activity_tasks", x => x.id);
|
||||
table.UniqueConstraint("ak_point_activity_tasks_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.CheckConstraint("ck_point_activity_tasks_max_claims", "max_claims_per_user > 0");
|
||||
table.CheckConstraint("ck_point_activity_tasks_points", "points > 0");
|
||||
table.ForeignKey(
|
||||
name: "fk_point_activity_tasks_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "point_exchange_items",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
region_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
item_key = table.Column<string>(type: "citext", maxLength: 100, nullable: false),
|
||||
name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||
item_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
points_cost = table.Column<int>(type: "integer", nullable: false),
|
||||
stock = table.Column<int>(type: "integer", nullable: true),
|
||||
days = table.Column<int>(type: "integer", nullable: true),
|
||||
sort_order = table.Column<int>(type: "integer", nullable: false),
|
||||
starts_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
ends_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
fulfillment_payload = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_point_exchange_items", x => x.id);
|
||||
table.UniqueConstraint("ak_point_exchange_items_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.CheckConstraint("ck_point_exchange_items_days", "days is null or days >= 0");
|
||||
table.CheckConstraint("ck_point_exchange_items_points_cost", "points_cost > 0");
|
||||
table.CheckConstraint("ck_point_exchange_items_stock", "stock is null or stock >= 0");
|
||||
table.ForeignKey(
|
||||
name: "fk_point_exchange_items_regions_tenant_id_region_id",
|
||||
columns: x => new { x.tenant_id, x.region_id },
|
||||
principalTable: "regions",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "fk_point_exchange_items_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "point_activity_claims",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
task_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
task_key = table.Column<string>(type: "citext", maxLength: 100, nullable: false),
|
||||
points = table.Column<int>(type: "integer", nullable: false),
|
||||
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
source_type = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||
source_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
claimed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_point_activity_claims", x => x.id);
|
||||
table.UniqueConstraint("ak_point_activity_claims_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.CheckConstraint("ck_point_activity_claims_points", "points > 0");
|
||||
table.ForeignKey(
|
||||
name: "fk_point_activity_claims_point_activity_tasks_tenant_id_task_id",
|
||||
columns: x => new { x.tenant_id, x.task_id },
|
||||
principalTable: "point_activity_tasks",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "fk_point_activity_claims_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_point_activity_claims_users_user_id",
|
||||
column: x => x.user_id,
|
||||
principalTable: "users",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "point_exchange_orders",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
item_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
order_no = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
item_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
item_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
points_cost = table.Column<int>(type: "integer", nullable: false),
|
||||
ordered_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
completed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
cancelled_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
fulfillment_snapshot = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_point_exchange_orders", x => x.id);
|
||||
table.UniqueConstraint("ak_point_exchange_orders_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.CheckConstraint("ck_point_exchange_orders_points_cost", "points_cost > 0");
|
||||
table.ForeignKey(
|
||||
name: "fk_point_exchange_orders_point_exchange_items_tenant_id_item_id",
|
||||
columns: x => new { x.tenant_id, x.item_id },
|
||||
principalTable: "point_exchange_items",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "fk_point_exchange_orders_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_point_exchange_orders_users_user_id",
|
||||
column: x => x.user_id,
|
||||
principalTable: "users",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_point_activity_claims_tenant_id_task_id",
|
||||
table: "point_activity_claims",
|
||||
columns: new[] { "tenant_id", "task_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_point_activity_claims_tenant_id_user_id_created_at",
|
||||
table: "point_activity_claims",
|
||||
columns: new[] { "tenant_id", "user_id", "created_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_point_activity_claims_tenant_id_user_id_task_id_source_type~",
|
||||
table: "point_activity_claims",
|
||||
columns: new[] { "tenant_id", "user_id", "task_id", "source_type", "source_id" },
|
||||
unique: true,
|
||||
filter: "source_type is not null and source_id is not null");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_point_activity_claims_user_id",
|
||||
table: "point_activity_claims",
|
||||
column: "user_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_point_activity_tasks_tenant_id_status_sort_order",
|
||||
table: "point_activity_tasks",
|
||||
columns: new[] { "tenant_id", "status", "sort_order" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_point_activity_tasks_tenant_id_task_key",
|
||||
table: "point_activity_tasks",
|
||||
columns: new[] { "tenant_id", "task_key" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_point_exchange_items_tenant_id_item_key",
|
||||
table: "point_exchange_items",
|
||||
columns: new[] { "tenant_id", "item_key" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_point_exchange_items_tenant_id_region_id_status_sort_order",
|
||||
table: "point_exchange_items",
|
||||
columns: new[] { "tenant_id", "region_id", "status", "sort_order" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_point_exchange_orders_tenant_id_item_id",
|
||||
table: "point_exchange_orders",
|
||||
columns: new[] { "tenant_id", "item_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_point_exchange_orders_tenant_id_order_no",
|
||||
table: "point_exchange_orders",
|
||||
columns: new[] { "tenant_id", "order_no" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_point_exchange_orders_tenant_id_user_id_created_at",
|
||||
table: "point_exchange_orders",
|
||||
columns: new[] { "tenant_id", "user_id", "created_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_point_exchange_orders_user_id",
|
||||
table: "point_exchange_orders",
|
||||
column: "user_id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "point_activity_claims");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "point_exchange_orders");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "point_activity_tasks");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "point_exchange_items");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,157 +0,0 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCommissionSettlementProofs : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "commission_settlement_export_events",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
settlement_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
exported_by = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
export_format = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
filename = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||
row_count = table.Column<int>(type: "integer", nullable: false),
|
||||
content_sha256 = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_commission_settlement_export_events", x => x.id);
|
||||
table.CheckConstraint("ck_commission_export_events_rows", "row_count >= 0");
|
||||
table.ForeignKey(
|
||||
name: "fk_commission_settlement_export_events_commission_settlements_~",
|
||||
columns: x => new { x.tenant_id, x.settlement_id },
|
||||
principalTable: "commission_settlements",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_commission_settlement_export_events_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_commission_settlement_export_events_users_exported_by",
|
||||
column: x => x.exported_by,
|
||||
principalTable: "users",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "commission_settlement_proofs",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
settlement_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
asset_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
submitted_by = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
reviewed_by = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
proof_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
description = table.Column<string>(type: "text", nullable: true),
|
||||
external_url = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||
amount_cents = table.Column<int>(type: "integer", nullable: true),
|
||||
payment_method = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||
payment_account = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
paid_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
reviewed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
review_note = table.Column<string>(type: "text", nullable: true),
|
||||
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_commission_settlement_proofs", x => x.id);
|
||||
table.UniqueConstraint("ak_commission_settlement_proofs_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.CheckConstraint("ck_commission_settlement_proofs_amount", "amount_cents is null or amount_cents >= 0");
|
||||
table.ForeignKey(
|
||||
name: "fk_commission_settlement_proofs_commission_settlements_tenant_~",
|
||||
columns: x => new { x.tenant_id, x.settlement_id },
|
||||
principalTable: "commission_settlements",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_commission_settlement_proofs_content_assets_tenant_id_asset~",
|
||||
columns: x => new { x.tenant_id, x.asset_id },
|
||||
principalTable: "content_assets",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "fk_commission_settlement_proofs_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_commission_settlement_proofs_users_reviewed_by",
|
||||
column: x => x.reviewed_by,
|
||||
principalTable: "users",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "fk_commission_settlement_proofs_users_submitted_by",
|
||||
column: x => x.submitted_by,
|
||||
principalTable: "users",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_commission_settlement_export_events_exported_by",
|
||||
table: "commission_settlement_export_events",
|
||||
column: "exported_by");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_commission_settlement_export_events_tenant_id_settlement_id~",
|
||||
table: "commission_settlement_export_events",
|
||||
columns: new[] { "tenant_id", "settlement_id", "created_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_commission_settlement_proofs_reviewed_by",
|
||||
table: "commission_settlement_proofs",
|
||||
column: "reviewed_by");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_commission_settlement_proofs_submitted_by",
|
||||
table: "commission_settlement_proofs",
|
||||
column: "submitted_by");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_commission_settlement_proofs_tenant_id_asset_id",
|
||||
table: "commission_settlement_proofs",
|
||||
columns: new[] { "tenant_id", "asset_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_commission_settlement_proofs_tenant_id_settlement_id_status~",
|
||||
table: "commission_settlement_proofs",
|
||||
columns: new[] { "tenant_id", "settlement_id", "status", "created_at" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "commission_settlement_export_events");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "commission_settlement_proofs");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class EncryptTenantSecretPayloads : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "secret_payload",
|
||||
table: "tenant_secrets");
|
||||
|
||||
migrationBuilder.AddColumn<byte[]>(
|
||||
name: "encrypted_payload",
|
||||
table: "tenant_secrets",
|
||||
type: "bytea",
|
||||
nullable: false,
|
||||
defaultValue: new byte[0]);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "encryption_key_id",
|
||||
table: "tenant_secrets",
|
||||
type: "character varying(100)",
|
||||
maxLength: 100,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<byte[]>(
|
||||
name: "encryption_nonce",
|
||||
table: "tenant_secrets",
|
||||
type: "bytea",
|
||||
nullable: false,
|
||||
defaultValue: new byte[0]);
|
||||
|
||||
migrationBuilder.AddColumn<byte[]>(
|
||||
name: "encryption_tag",
|
||||
table: "tenant_secrets",
|
||||
type: "bytea",
|
||||
nullable: false,
|
||||
defaultValue: new byte[0]);
|
||||
|
||||
migrationBuilder.AddCheckConstraint(
|
||||
name: "ck_tenant_secrets_encryption_envelope",
|
||||
table: "tenant_secrets",
|
||||
sql: "octet_length(encrypted_payload) > 0 and octet_length(encryption_nonce) = 12 and octet_length(encryption_tag) = 16 and encryption_key_id <> ''");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropCheckConstraint(
|
||||
name: "ck_tenant_secrets_encryption_envelope",
|
||||
table: "tenant_secrets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "encrypted_payload",
|
||||
table: "tenant_secrets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "encryption_key_id",
|
||||
table: "tenant_secrets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "encryption_nonce",
|
||||
table: "tenant_secrets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "encryption_tag",
|
||||
table: "tenant_secrets");
|
||||
|
||||
migrationBuilder.AddColumn<JsonElement>(
|
||||
name: "secret_payload",
|
||||
table: "tenant_secrets",
|
||||
type: "jsonb",
|
||||
nullable: false,
|
||||
defaultValueSql: "'{}'::jsonb");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Tiku.Application.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence;
|
||||
|
||||
public sealed class TenantIsolationSaveChangesInterceptor(ITenantContext tenantContext) : SaveChangesInterceptor
|
||||
{
|
||||
public override InterceptionResult<int> SavingChanges(
|
||||
DbContextEventData eventData,
|
||||
InterceptionResult<int> result)
|
||||
{
|
||||
Enforce(eventData.Context);
|
||||
return result;
|
||||
}
|
||||
|
||||
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
|
||||
DbContextEventData eventData,
|
||||
InterceptionResult<int> result,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Enforce(eventData.Context);
|
||||
return ValueTask.FromResult(result);
|
||||
}
|
||||
|
||||
private void Enforce(DbContext? dbContext)
|
||||
{
|
||||
if (dbContext is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var entry in dbContext.ChangeTracker.Entries()
|
||||
.Where(entry => entry.State is EntityState.Added or EntityState.Modified or EntityState.Deleted))
|
||||
{
|
||||
var tenantProperty = entry.Metadata.FindProperty("TenantId");
|
||||
if (tenantProperty?.ClrType != typeof(Guid))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var property = entry.Property("TenantId");
|
||||
var currentTenantId = (Guid)(property.CurrentValue ?? Guid.Empty);
|
||||
var originalTenantId = (Guid)(property.OriginalValue ?? Guid.Empty);
|
||||
if (entry.State == EntityState.Added)
|
||||
{
|
||||
if (!tenantContext.TenantId.HasValue && !tenantContext.IsSystem)
|
||||
{
|
||||
throw new TenantIsolationException(
|
||||
entry.Metadata.ClrType,
|
||||
"Cannot add tenant-owned data without a resolved tenant.");
|
||||
}
|
||||
|
||||
if (currentTenantId == Guid.Empty && tenantContext.TenantId.HasValue)
|
||||
{
|
||||
property.CurrentValue = tenantContext.TenantId.Value;
|
||||
currentTenantId = tenantContext.TenantId.Value;
|
||||
}
|
||||
|
||||
if (!tenantContext.IsSystem && currentTenantId != tenantContext.TenantId)
|
||||
{
|
||||
throw new TenantIsolationException(
|
||||
entry.Metadata.ClrType,
|
||||
"Cannot add data for another tenant.");
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (property.IsModified || currentTenantId != originalTenantId)
|
||||
{
|
||||
throw new TenantIsolationException(
|
||||
entry.Metadata.ClrType,
|
||||
"Tenant ownership cannot be changed.");
|
||||
}
|
||||
|
||||
if (!tenantContext.IsSystem &&
|
||||
(!tenantContext.TenantId.HasValue || originalTenantId != tenantContext.TenantId.Value))
|
||||
{
|
||||
throw new TenantIsolationException(
|
||||
entry.Metadata.ClrType,
|
||||
"Cannot modify or delete data owned by another tenant.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TenantIsolationException(Type entityType, string message)
|
||||
: InvalidOperationException($"Tenant isolation rejected {entityType.Name}: {message}");
|
||||
@@ -1,4 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Reflection;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
@@ -14,8 +16,26 @@ using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence;
|
||||
|
||||
public sealed class TikuDbContext(DbContextOptions<TikuDbContext> options) : DbContext(options)
|
||||
public sealed class TikuDbContext(
|
||||
DbContextOptions<TikuDbContext> options,
|
||||
ITenantContext tenantContext) : DbContext(options)
|
||||
{
|
||||
public TikuDbContext(DbContextOptions<TikuDbContext> options)
|
||||
: this(options, CreateToolingTenantContext())
|
||||
{
|
||||
}
|
||||
|
||||
public Guid? CurrentTenantId => tenantContext.TenantId;
|
||||
public Guid CurrentTenantIdOrEmpty => tenantContext.TenantId ?? Guid.Empty;
|
||||
public bool IsTenantResolved => tenantContext.IsResolved;
|
||||
public bool IsSystemScope => tenantContext.IsSystem;
|
||||
|
||||
private static ITenantContext CreateToolingTenantContext()
|
||||
{
|
||||
var context = new TenantContext();
|
||||
context.InitializeSystem(null, "Direct DbContext construction for model tooling");
|
||||
return context;
|
||||
}
|
||||
public DbSet<Tenant> Tenants => Set<Tenant>();
|
||||
public DbSet<User> Users => Set<User>();
|
||||
public DbSet<UserIdentity> UserIdentities => Set<UserIdentity>();
|
||||
@@ -23,6 +43,7 @@ public sealed class TikuDbContext(DbContextOptions<TikuDbContext> options) : DbC
|
||||
public DbSet<TenantDomain> TenantDomains => Set<TenantDomain>();
|
||||
public DbSet<TenantBranding> TenantBrandings => Set<TenantBranding>();
|
||||
public DbSet<TenantSettings> TenantSettings => Set<TenantSettings>();
|
||||
public DbSet<TenantFrontendConfig> TenantFrontendConfigs => Set<TenantFrontendConfig>();
|
||||
public DbSet<TenantAuthProvider> TenantAuthProviders => Set<TenantAuthProvider>();
|
||||
public DbSet<TenantSecret> TenantSecrets => Set<TenantSecret>();
|
||||
public DbSet<SmsVerificationCode> SmsVerificationCodes => Set<SmsVerificationCode>();
|
||||
@@ -42,6 +63,8 @@ public sealed class TikuDbContext(DbContextOptions<TikuDbContext> options) : DbC
|
||||
public DbSet<Major> Majors => Set<Major>();
|
||||
public DbSet<Subject> Subjects => Set<Subject>();
|
||||
public DbSet<Category> Categories => Set<Category>();
|
||||
public DbSet<TaxonomyNode> TaxonomyNodes => Set<TaxonomyNode>();
|
||||
public DbSet<QuestionTaxonomyAssignment> QuestionTaxonomyAssignments => Set<QuestionTaxonomyAssignment>();
|
||||
public DbSet<ScorelineField> ScorelineFields => Set<ScorelineField>();
|
||||
public DbSet<ScorelineRecord> ScorelineRecords => Set<ScorelineRecord>();
|
||||
public DbSet<QuestionBank> QuestionBanks => Set<QuestionBank>();
|
||||
@@ -71,10 +94,11 @@ public sealed class TikuDbContext(DbContextOptions<TikuDbContext> options) : DbC
|
||||
public DbSet<AppAsset> AppAssets => Set<AppAsset>();
|
||||
public DbSet<VideoExplanation> VideoExplanations => Set<VideoExplanation>();
|
||||
public DbSet<QuestionVideo> QuestionVideos => Set<QuestionVideo>();
|
||||
public DbSet<QuestionBankGrant> QuestionBankGrants => Set<QuestionBankGrant>();
|
||||
public DbSet<TenantQuestionBankAdoption> TenantQuestionBankAdoptions => Set<TenantQuestionBankAdoption>();
|
||||
public DbSet<TenantQuestionBankPreference> TenantQuestionBankPreferences => Set<TenantQuestionBankPreference>();
|
||||
public DbSet<TenantQuestionReference> TenantQuestionReferences => Set<TenantQuestionReference>();
|
||||
public DbSet<AiRecommendationReport> AiRecommendationReports => Set<AiRecommendationReport>();
|
||||
public DbSet<PracticeSession> PracticeSessions => Set<PracticeSession>();
|
||||
public DbSet<PracticeSessionQuestion> PracticeSessionQuestions => Set<PracticeSessionQuestion>();
|
||||
public DbSet<AnswerRecord> AnswerRecords => Set<AnswerRecord>();
|
||||
public DbSet<FavoriteQuestion> FavoriteQuestions => Set<FavoriteQuestion>();
|
||||
public DbSet<WrongQuestion> WrongQuestions => Set<WrongQuestion>();
|
||||
@@ -155,9 +179,97 @@ public sealed class TikuDbContext(DbContextOptions<TikuDbContext> options) : DbC
|
||||
modelBuilder.HasPostgresExtension("citext");
|
||||
modelBuilder.HasPostgresExtension("ltree");
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(TikuDbContext).Assembly);
|
||||
ApplyTenantQueryFilters(modelBuilder);
|
||||
ValidateTenantModel(modelBuilder);
|
||||
modelBuilder.UseSnakeCaseIdentifiers();
|
||||
}
|
||||
|
||||
private void ApplyTenantQueryFilters(ModelBuilder modelBuilder)
|
||||
{
|
||||
var applyMethod = typeof(TikuDbContext)
|
||||
.GetMethod(nameof(ApplyTenantQueryFilter), BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||
|
||||
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
||||
{
|
||||
var tenantProperty = entityType.FindProperty("TenantId");
|
||||
if (tenantProperty?.ClrType != typeof(Guid) || entityType.BaseType is not null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
applyMethod.MakeGenericMethod(entityType.ClrType).Invoke(this, [modelBuilder]);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyTenantQueryFilter<TEntity>(ModelBuilder modelBuilder)
|
||||
where TEntity : class
|
||||
{
|
||||
modelBuilder.Entity<TEntity>().HasQueryFilter(entity =>
|
||||
IsSystemScope ||
|
||||
(IsTenantResolved &&
|
||||
EF.Property<Guid>(entity, "TenantId") == CurrentTenantIdOrEmpty));
|
||||
}
|
||||
|
||||
private static void ValidateTenantModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
var invalidUniqueIndexes = new List<string>();
|
||||
var invalidTenantForeignKeys = new List<string>();
|
||||
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
||||
{
|
||||
var tenantProperty = entityType.FindProperty("TenantId");
|
||||
if (tenantProperty?.ClrType != typeof(Guid))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!typeof(ITenantOwned).IsAssignableFrom(entityType.ClrType))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Tenant entity '{entityType.ClrType.Name}' must implement {nameof(ITenantOwned)}.");
|
||||
}
|
||||
|
||||
if (!entityType.GetDeclaredQueryFilters().Any())
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Tenant entity '{entityType.ClrType.Name}' does not have a tenant query filter.");
|
||||
}
|
||||
|
||||
foreach (var index in entityType.GetDeclaredIndexes().Where(index => index.IsUnique))
|
||||
{
|
||||
if (index.Properties.All(property => property.Name != "TenantId") &&
|
||||
index.FindAnnotation("Tiku:GlobalUnique")?.Value is not true)
|
||||
{
|
||||
invalidUniqueIndexes.Add(
|
||||
$"{entityType.ClrType.Name}({string.Join(",", index.Properties.Select(property => property.Name))})");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
foreach (var foreignKey in entityType.GetDeclaredForeignKeys().Where(foreignKey =>
|
||||
typeof(ITenantOwned).IsAssignableFrom(foreignKey.PrincipalEntityType.ClrType)))
|
||||
{
|
||||
if (foreignKey.PrincipalKey.Properties.All(property => property.Name != "TenantId"))
|
||||
{
|
||||
invalidTenantForeignKeys.Add(
|
||||
$"{entityType.ClrType.Name}->{foreignKey.PrincipalEntityType.ClrType.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (invalidUniqueIndexes.Count > 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Unique indexes on tenant entities must include TenantId: {string.Join("; ", invalidUniqueIndexes)}");
|
||||
}
|
||||
|
||||
|
||||
if (invalidTenantForeignKeys.Count > 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Foreign keys between tenant entities must use a tenant-qualified principal key: {string.Join("; ", invalidTenantForeignKeys)}");
|
||||
}
|
||||
}
|
||||
|
||||
public override int SaveChanges(bool acceptAllChangesOnSuccess)
|
||||
{
|
||||
UpdateTimestamps();
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.QuestionBanks;
|
||||
|
||||
public sealed class PublicQuestionAccessPolicy(TikuDbContext dbContext) : IPublicQuestionAccessPolicy
|
||||
{
|
||||
public async Task EnsureCanStartAsync(Guid tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenantIsActive = await dbContext.Tenants.AsNoTracking().AnyAsync(
|
||||
tenant => tenant.Id == tenantId && tenant.Status == TenantStatus.Active,
|
||||
cancellationToken);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var subscriptionIsActive = await dbContext.TenantSubscriptions.AsNoTracking().AnyAsync(
|
||||
subscription =>
|
||||
subscription.TenantId == tenantId &&
|
||||
(subscription.Status == TenantSubscriptionStatus.Trial ||
|
||||
subscription.Status == TenantSubscriptionStatus.Active) &&
|
||||
(!subscription.StartsAt.HasValue || subscription.StartsAt <= now) &&
|
||||
(!subscription.ExpiresAt.HasValue || subscription.ExpiresAt > now),
|
||||
cancellationToken);
|
||||
|
||||
if (!tenantIsActive || !subscriptionIsActive)
|
||||
{
|
||||
throw new PublicQuestionAccessDeniedException(
|
||||
"public_question_subscription_required",
|
||||
"An active trial or subscription is required to start public question practice.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.QuestionBanks;
|
||||
|
||||
public sealed class QuestionBankQueryService(TikuDbContext dbContext) : IQuestionBankQueryService
|
||||
public sealed class QuestionBankQueryService(
|
||||
TikuDbContext dbContext,
|
||||
IPublicQuestionAccessPolicy accessPolicy,
|
||||
ITenantExecutionScope tenantExecutionScope) : IQuestionBankQueryService
|
||||
{
|
||||
private const int DefaultQuestionLimit = 200;
|
||||
private const int MaxQuestionLimit = 500;
|
||||
@@ -19,47 +25,84 @@ public sealed class QuestionBankQueryService(TikuDbContext dbContext) : IQuestio
|
||||
QuestionBankFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.QuestionBanks
|
||||
var limit = ResolveLimit(filter.Limit, DefaultBankLimit, MaxBankLimit);
|
||||
var tenantItems = filter.Source == QuestionSource.Platform
|
||||
? []
|
||||
: await dbContext.QuestionBanks
|
||||
.AsNoTracking()
|
||||
.Where(bank =>
|
||||
bank.TenantId == filter.TenantId &&
|
||||
bank.Status == QuestionBankStatus.Active);
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(bank => bank.RegionId == filter.RegionId.Value || bank.RegionId == null);
|
||||
}
|
||||
|
||||
query = ApplyKeyword(query, filter.Keyword);
|
||||
|
||||
var items = await query
|
||||
bank.Status == QuestionBankStatus.Active &&
|
||||
(!filter.RegionId.HasValue || bank.RegionId == filter.RegionId.Value || bank.RegionId == null) &&
|
||||
(string.IsNullOrWhiteSpace(filter.Keyword) || bank.Name.Contains(filter.Keyword.Trim())))
|
||||
.OrderBy(bank => bank.Name)
|
||||
.ThenBy(bank => bank.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit, DefaultBankLimit, MaxBankLimit))
|
||||
.Take(limit)
|
||||
.Select(bank => new QuestionBankCatalogItem(
|
||||
bank.Id,
|
||||
bank.RegionId,
|
||||
bank.Name,
|
||||
bank.SourceScope,
|
||||
QuestionSource.Tenant,
|
||||
bank.Status,
|
||||
bank.Metadata))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new CatalogList<QuestionBankCatalogItem>(items);
|
||||
var platformItems = filter.Source == QuestionSource.Tenant || !await CanAccessPlatformAsync(filter.TenantId, cancellationToken)
|
||||
? []
|
||||
: await tenantExecutionScope.ExecuteAsync(
|
||||
filter.TenantId,
|
||||
"List platform question banks for an entitled tenant",
|
||||
async (provider, token) =>
|
||||
{
|
||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||
return await systemDbContext.QuestionBanks.AsNoTracking()
|
||||
.Join(
|
||||
systemDbContext.Tenants.AsNoTracking().Where(tenant => tenant.Mode == TenantMode.PlatformOwned),
|
||||
bank => bank.TenantId,
|
||||
tenant => tenant.Id,
|
||||
(bank, tenant) => bank)
|
||||
.Where(bank =>
|
||||
bank.Status == QuestionBankStatus.Active &&
|
||||
(!filter.RegionId.HasValue || bank.RegionId == filter.RegionId.Value || bank.RegionId == null) &&
|
||||
(string.IsNullOrWhiteSpace(filter.Keyword) || bank.Name.Contains(filter.Keyword.Trim())))
|
||||
.OrderBy(bank => bank.Name)
|
||||
.Take(limit)
|
||||
.Select(bank => new QuestionBankCatalogItem(
|
||||
bank.Id,
|
||||
bank.RegionId,
|
||||
bank.Name,
|
||||
QuestionSource.Platform,
|
||||
bank.Status,
|
||||
bank.Metadata))
|
||||
.ToArrayAsync(token);
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return new CatalogList<QuestionBankCatalogItem>(tenantItems
|
||||
.Concat(platformItems)
|
||||
.OrderBy(item => item.Name)
|
||||
.Take(limit)
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
public async Task<CatalogList<QuestionCatalogItem>> GetQuestionsAsync(
|
||||
QuestionBankFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var questions = ApplyQuestionFilters(BaseQuestionQuery(), filter)
|
||||
.OrderByDescending(question => question.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit, DefaultQuestionLimit, MaxQuestionLimit));
|
||||
|
||||
var items = await ProjectQuestions(questions)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new CatalogList<QuestionCatalogItem>(items);
|
||||
var limit = ResolveLimit(filter.Limit, DefaultQuestionLimit, MaxQuestionLimit);
|
||||
var tenantItems = filter.Source == QuestionSource.Platform
|
||||
? []
|
||||
: await ProjectQuestions(
|
||||
dbContext,
|
||||
ApplyQuestionFilters(BaseQuestionQuery(), filter)
|
||||
.OrderByDescending(question => question.CreatedAt)
|
||||
.Take(limit),
|
||||
QuestionSource.Tenant)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var platformItems = filter.Source == QuestionSource.Tenant || !await CanAccessPlatformAsync(filter.TenantId, cancellationToken)
|
||||
? []
|
||||
: await GetPlatformQuestionsAsync(filter, limit, cancellationToken);
|
||||
return new CatalogList<QuestionCatalogItem>(tenantItems.Concat(platformItems).Take(limit).ToArray());
|
||||
}
|
||||
|
||||
public async Task<QuestionCatalogItem> GetQuestionAsync(
|
||||
@@ -71,13 +114,9 @@ public sealed class QuestionBankQueryService(TikuDbContext dbContext) : IQuestio
|
||||
throw new QuestionBankRequiredFieldException("questionId is required.");
|
||||
}
|
||||
|
||||
var questions = ApplyQuestionFilters(BaseQuestionQuery(), filter)
|
||||
.Where(question => question.Id == filter.QuestionId.Value);
|
||||
|
||||
var question = await ProjectQuestions(questions)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
return question ?? throw new QuestionBankNotFoundException("Question was not found.");
|
||||
var result = await GetQuestionsAsync(filter with { Limit = 2 }, cancellationToken);
|
||||
return result.Items.SingleOrDefault()
|
||||
?? throw new QuestionBankNotFoundException("Question was not found.");
|
||||
}
|
||||
|
||||
public async Task<CatalogList<QuestionVersionCatalogItem>> GetQuestionVersionsAsync(
|
||||
@@ -89,6 +128,13 @@ public sealed class QuestionBankQueryService(TikuDbContext dbContext) : IQuestio
|
||||
throw new QuestionBankRequiredFieldException("questionId is required.");
|
||||
}
|
||||
|
||||
if (filter.Source == QuestionSource.Platform)
|
||||
{
|
||||
await accessPolicy.EnsureCanStartAsync(filter.TenantId, cancellationToken);
|
||||
var platformItems = await GetPlatformVersionsAsync(filter.QuestionId.Value, filter.TenantId, cancellationToken);
|
||||
return new CatalogList<QuestionVersionCatalogItem>(platformItems);
|
||||
}
|
||||
|
||||
var questionExists = await dbContext.Questions
|
||||
.AsNoTracking()
|
||||
.AnyAsync(
|
||||
@@ -221,14 +267,17 @@ public sealed class QuestionBankQueryService(TikuDbContext dbContext) : IQuestio
|
||||
return query.Where(entity => EF.Property<string>(entity, nameof(QuestionBank.Name)).Contains(trimmed));
|
||||
}
|
||||
|
||||
private IQueryable<QuestionCatalogItem> ProjectQuestions(IQueryable<Question> questions)
|
||||
private static IQueryable<QuestionCatalogItem> ProjectQuestions(
|
||||
TikuDbContext context,
|
||||
IQueryable<Question> questions,
|
||||
QuestionSource source)
|
||||
{
|
||||
var emptyOptions = JsonDefaults.Array();
|
||||
var emptyCorrectOptionIndices = JsonDefaults.Array();
|
||||
var emptySubQuestions = JsonDefaults.Array();
|
||||
return
|
||||
from question in questions
|
||||
join version in dbContext.QuestionVersions.AsNoTracking()
|
||||
join version in context.QuestionVersions.AsNoTracking()
|
||||
on new { question.TenantId, QuestionId = question.Id, VersionId = question.CurrentVersionId }
|
||||
equals new { version.TenantId, version.QuestionId, VersionId = (Guid?)version.Id }
|
||||
into versions
|
||||
@@ -264,7 +313,113 @@ public sealed class QuestionBankQueryService(TikuDbContext dbContext) : IQuestio
|
||||
version == null ? null : version.Explanation,
|
||||
version == null ? emptySubQuestions : version.SubQuestions,
|
||||
version == null ? null : version.CodeLang,
|
||||
version == null ? null : version.CodeTemplate);
|
||||
version == null ? null : version.CodeTemplate,
|
||||
new QuestionLocator(source, question.Id));
|
||||
}
|
||||
|
||||
private async Task<bool> CanAccessPlatformAsync(Guid tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await accessPolicy.EnsureCanStartAsync(tenantId, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
catch (PublicQuestionAccessDeniedException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private Task<QuestionCatalogItem[]> GetPlatformQuestionsAsync(
|
||||
QuestionBankFilter filter,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return tenantExecutionScope.ExecuteAsync(
|
||||
filter.TenantId,
|
||||
"List platform questions for an entitled tenant",
|
||||
async (provider, token) =>
|
||||
{
|
||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||
var platformTenantId = await systemDbContext.Tenants.AsNoTracking()
|
||||
.Where(tenant => tenant.Mode == TenantMode.PlatformOwned)
|
||||
.Select(tenant => tenant.Id)
|
||||
.SingleAsync(token);
|
||||
var query = systemDbContext.Questions.AsNoTracking().Where(question =>
|
||||
question.TenantId == platformTenantId &&
|
||||
question.Status == QuestionStatus.Published &&
|
||||
(!filter.QuestionId.HasValue || question.Id == filter.QuestionId.Value) &&
|
||||
(!filter.QuestionBankId.HasValue || question.QuestionBankId == filter.QuestionBankId.Value) &&
|
||||
(!filter.SubjectId.HasValue || question.SubjectId == filter.SubjectId.Value) &&
|
||||
(!filter.CategoryId.HasValue || question.CategoryId == filter.CategoryId.Value) &&
|
||||
(!filter.NodeId.HasValue || question.NodeId == filter.NodeId.Value) &&
|
||||
(!filter.EntryId.HasValue || question.EntryId == filter.EntryId.Value) &&
|
||||
(!filter.ContentNodeId.HasValue || question.ContentNodeId == filter.ContentNodeId.Value) &&
|
||||
(filter.QuestionIds == null || filter.QuestionIds.Count == 0 || filter.QuestionIds.Contains(question.Id)) &&
|
||||
(string.IsNullOrWhiteSpace(filter.Type) || question.Type == filter.Type.Trim()));
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var keyword = filter.Keyword.Trim();
|
||||
query = query.Where(question =>
|
||||
question.Type.Contains(keyword) ||
|
||||
(question.TypeLabel != null && question.TypeLabel.Contains(keyword)));
|
||||
}
|
||||
|
||||
return await ProjectQuestions(
|
||||
systemDbContext,
|
||||
query.OrderByDescending(question => question.CreatedAt).Take(limit),
|
||||
QuestionSource.Platform)
|
||||
.ToArrayAsync(token);
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private Task<QuestionVersionCatalogItem[]> GetPlatformVersionsAsync(
|
||||
Guid questionId,
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return tenantExecutionScope.ExecuteAsync(
|
||||
tenantId,
|
||||
"Read platform question versions for an entitled tenant",
|
||||
async (provider, token) =>
|
||||
{
|
||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||
var platformQuestion = await systemDbContext.Questions.AsNoTracking()
|
||||
.Where(question => question.Id == questionId && question.Status == QuestionStatus.Published)
|
||||
.Join(
|
||||
systemDbContext.Tenants.AsNoTracking().Where(tenant => tenant.Mode == TenantMode.PlatformOwned),
|
||||
question => question.TenantId,
|
||||
tenant => tenant.Id,
|
||||
(question, tenant) => new { question.TenantId, question.Id })
|
||||
.SingleOrDefaultAsync(token);
|
||||
if (platformQuestion is null)
|
||||
{
|
||||
throw new QuestionBankNotFoundException("Question was not found.");
|
||||
}
|
||||
|
||||
return await systemDbContext.QuestionVersions.AsNoTracking()
|
||||
.Where(version =>
|
||||
version.TenantId == platformQuestion.TenantId &&
|
||||
version.QuestionId == platformQuestion.Id)
|
||||
.OrderByDescending(version => version.VersionNo)
|
||||
.Select(version => new QuestionVersionCatalogItem(
|
||||
version.Id,
|
||||
version.QuestionId,
|
||||
version.VersionNo,
|
||||
version.Content,
|
||||
version.Options,
|
||||
version.CorrectOptionIndex,
|
||||
version.CorrectOptionIndices,
|
||||
version.AnswerText,
|
||||
version.Explanation,
|
||||
version.SubQuestions,
|
||||
version.CodeLang,
|
||||
version.CodeTemplate,
|
||||
version.CreatedAt))
|
||||
.ToArrayAsync(token);
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static int ResolveLimit(int? limit, int defaultLimit, int maxLimit)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.QuestionBanks;
|
||||
|
||||
public sealed class QuestionReferenceService(
|
||||
TikuDbContext dbContext,
|
||||
IPublicQuestionAccessPolicy accessPolicy,
|
||||
ITenantExecutionScope tenantExecutionScope) : IQuestionReferenceService
|
||||
{
|
||||
public async Task<TenantQuestionReference> ResolveAsync(
|
||||
Guid tenantId,
|
||||
Guid? userId,
|
||||
QuestionLocator locator,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var ownerTenantId = locator.Source switch
|
||||
{
|
||||
QuestionSource.Tenant => await ResolveTenantQuestionAsync(tenantId, locator.QuestionId, cancellationToken),
|
||||
QuestionSource.Platform => await ResolvePlatformQuestionAsync(tenantId, locator.QuestionId, cancellationToken),
|
||||
_ => throw new QuestionLocatorException("question_source_invalid", "Question source is invalid.")
|
||||
};
|
||||
|
||||
var existing = await dbContext.TenantQuestionReferences.SingleOrDefaultAsync(
|
||||
reference =>
|
||||
reference.TenantId == tenantId &&
|
||||
reference.QuestionOwnerTenantId == ownerTenantId &&
|
||||
reference.QuestionId == locator.QuestionId,
|
||||
cancellationToken);
|
||||
if (existing is not null)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var reference = new TenantQuestionReference
|
||||
{
|
||||
TenantId = tenantId,
|
||||
QuestionOwnerTenantId = ownerTenantId,
|
||||
QuestionId = locator.QuestionId,
|
||||
Source = locator.Source,
|
||||
CreatedBy = userId
|
||||
};
|
||||
dbContext.TenantQuestionReferences.Add(reference);
|
||||
return reference;
|
||||
}
|
||||
|
||||
private async Task<Guid> ResolveTenantQuestionAsync(
|
||||
Guid tenantId,
|
||||
Guid questionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var exists = await dbContext.Questions.AsNoTracking().AnyAsync(
|
||||
question =>
|
||||
question.TenantId == tenantId &&
|
||||
question.Id == questionId &&
|
||||
question.Status == QuestionStatus.Published,
|
||||
cancellationToken);
|
||||
return exists
|
||||
? tenantId
|
||||
: throw new QuestionLocatorException("question_not_found", "Tenant question was not found.");
|
||||
}
|
||||
|
||||
private async Task<Guid> ResolvePlatformQuestionAsync(
|
||||
Guid tenantId,
|
||||
Guid questionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await accessPolicy.EnsureCanStartAsync(tenantId, cancellationToken);
|
||||
var ownerTenantId = await tenantExecutionScope.ExecuteAsync(
|
||||
tenantId,
|
||||
"Resolve a platform question for an entitled tenant",
|
||||
async (provider, token) =>
|
||||
{
|
||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||
return await systemDbContext.Questions.AsNoTracking()
|
||||
.Where(question =>
|
||||
question.Id == questionId &&
|
||||
question.Status == QuestionStatus.Published)
|
||||
.Join(
|
||||
systemDbContext.Tenants.AsNoTracking().Where(tenant => tenant.Mode == TenantMode.PlatformOwned),
|
||||
question => question.TenantId,
|
||||
tenant => tenant.Id,
|
||||
(question, tenant) => (Guid?)tenant.Id)
|
||||
.SingleOrDefaultAsync(token);
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return ownerTenantId ?? throw new QuestionLocatorException(
|
||||
"question_not_found",
|
||||
"Platform question was not found.");
|
||||
}
|
||||
}
|
||||
66
Tiku.Infrastructure/Tenancy/TenantDirectory.cs
Normal file
66
Tiku.Infrastructure/Tenancy/TenantDirectory.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using Npgsql;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Infrastructure.Tenancy;
|
||||
|
||||
public sealed class TenantDirectory(NpgsqlDataSource dataSource) : ITenantDirectory
|
||||
{
|
||||
public Task<TenantDirectoryEntry?> FindByHostAsync(
|
||||
string host,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
select t.id, t.slug, t.name, t.status, t.mode, d.host
|
||||
from tenant_domains d
|
||||
join tenants t on t.id = d.tenant_id
|
||||
where d.host = @lookup
|
||||
and d.status = 'active'
|
||||
and t.status = 'active'
|
||||
limit 1
|
||||
""";
|
||||
return FindAsync(sql, host, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<TenantDirectoryEntry?> FindByCodeAsync(
|
||||
string tenantCode,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
select t.id, t.slug, t.name, t.status, t.mode, null::text as host
|
||||
from tenants t
|
||||
where t.slug = @lookup
|
||||
and t.status = 'active'
|
||||
limit 1
|
||||
""";
|
||||
return FindAsync(sql, tenantCode, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<TenantDirectoryEntry?> FindAsync(
|
||||
string sql,
|
||||
string lookup,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = dataSource.CreateCommand(sql);
|
||||
command.Parameters.AddWithValue("lookup", lookup);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
if (!await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new TenantDirectoryEntry(
|
||||
reader.GetGuid(0),
|
||||
reader.GetString(1),
|
||||
reader.GetString(2),
|
||||
ParseEnum<TenantStatus>(reader.GetString(3)),
|
||||
ParseEnum<TenantMode>(reader.GetString(4)),
|
||||
reader.IsDBNull(5) ? null : reader.GetString(5));
|
||||
}
|
||||
|
||||
private static TEnum ParseEnum<TEnum>(string value)
|
||||
where TEnum : struct, Enum
|
||||
{
|
||||
return Enum.Parse<TEnum>(value.Replace("_", string.Empty, StringComparison.Ordinal), true);
|
||||
}
|
||||
}
|
||||
196
Tiku.Infrastructure/Tenancy/TenantDomainLifecycleService.cs
Normal file
196
Tiku.Infrastructure/Tenancy/TenantDomainLifecycleService.cs
Normal file
@@ -0,0 +1,196 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Tenancy;
|
||||
|
||||
public sealed class DnsDomainOwnershipVerifier(
|
||||
HttpClient httpClient,
|
||||
IOptions<DomainLifecycleOptions> options) : IDomainOwnershipVerifier
|
||||
{
|
||||
private readonly DomainLifecycleOptions options = options.Value;
|
||||
|
||||
public async Task<DomainOwnershipResult> VerifyAsync(
|
||||
string host,
|
||||
string verificationToken,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (options.AllowedCnameTargets.Length == 0 || string.IsNullOrWhiteSpace(options.DnsJsonEndpoint))
|
||||
{
|
||||
return new(false, false, "DNS verification is not configured.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var cnameAnswers = await QueryAsync(host, "CNAME", cancellationToken);
|
||||
var cnameMatches = cnameAnswers.Any(answer => options.AllowedCnameTargets.Any(target =>
|
||||
NormalizeDnsName(answer).Equals(NormalizeDnsName(target), StringComparison.OrdinalIgnoreCase)));
|
||||
if (!cnameMatches)
|
||||
{
|
||||
return new(false, true, "CNAME does not point to an allowed gateway target.");
|
||||
}
|
||||
|
||||
var verificationName = $"{options.VerificationRecordPrefix.Trim().TrimEnd('.')}.{host}";
|
||||
var txtAnswers = await QueryAsync(verificationName, "TXT", cancellationToken);
|
||||
var txtMatches = txtAnswers.Any(answer =>
|
||||
answer.Trim().Trim('"').Equals(verificationToken, StringComparison.Ordinal));
|
||||
return txtMatches
|
||||
? new(true, true, null)
|
||||
: new(false, true, "TXT ownership token was not found.");
|
||||
}
|
||||
catch (Exception exception) when (exception is HttpRequestException or JsonException or TaskCanceledException)
|
||||
{
|
||||
return new(false, true, $"DNS verification failed: {exception.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string[]> QueryAsync(string name, string type, CancellationToken cancellationToken)
|
||||
{
|
||||
var endpoint = options.DnsJsonEndpoint.TrimEnd('/');
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
$"{endpoint}?name={Uri.EscapeDataString(name)}&type={type}");
|
||||
request.Headers.Accept.ParseAdd("application/dns-json");
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
|
||||
if (!document.RootElement.TryGetProperty("Answer", out var answers) || answers.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return answers.EnumerateArray()
|
||||
.Where(answer => answer.TryGetProperty("data", out _))
|
||||
.Select(answer => answer.GetProperty("data").GetString() ?? string.Empty)
|
||||
.Where(answer => answer.Length > 0)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string NormalizeDnsName(string value) => value.Trim().Trim('"').TrimEnd('.');
|
||||
}
|
||||
|
||||
public sealed class HttpDomainGatewayProvisioner(
|
||||
HttpClient httpClient,
|
||||
IOptions<DomainLifecycleOptions> options) : IDomainGatewayProvisioner
|
||||
{
|
||||
private readonly DomainLifecycleOptions options = options.Value;
|
||||
|
||||
public async Task<DomainGatewayResult> EnsureTlsAsync(
|
||||
string host,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.GatewayBaseUrl) || string.IsNullOrWhiteSpace(options.GatewayApiKey))
|
||||
{
|
||||
return new(false, false, "Gateway TLS provisioning is not configured.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
$"{options.GatewayBaseUrl.TrimEnd('/')}/domains/ensure");
|
||||
request.Headers.Authorization = new("Bearer", options.GatewayApiKey);
|
||||
request.Content = JsonContent.Create(new { host });
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new(false, true, $"Gateway returned HTTP {(int)response.StatusCode}.");
|
||||
}
|
||||
|
||||
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
|
||||
var tlsReady = document.RootElement.TryGetProperty("tlsReady", out var value) && value.GetBoolean();
|
||||
return tlsReady
|
||||
? new(true, true, null)
|
||||
: new(false, true, "Gateway route exists but TLS is not ready.");
|
||||
}
|
||||
catch (Exception exception) when (exception is HttpRequestException or JsonException or TaskCanceledException)
|
||||
{
|
||||
return new(false, true, $"Gateway provisioning failed: {exception.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TenantRuntimeCacheInvalidator(IMemoryCache cache) : ITenantRuntimeCacheInvalidator
|
||||
{
|
||||
public void Invalidate(Guid tenantId) => cache.Remove($"tenant-runtime:{tenantId:N}");
|
||||
}
|
||||
|
||||
public sealed class TenantDomainLifecycleService(
|
||||
TikuDbContext dbContext,
|
||||
IDomainOwnershipVerifier ownershipVerifier,
|
||||
IDomainGatewayProvisioner gatewayProvisioner,
|
||||
ITenantRuntimeCacheInvalidator cacheInvalidator,
|
||||
IOptions<DomainLifecycleOptions> options) : ITenantDomainLifecycleService
|
||||
{
|
||||
private readonly DomainLifecycleOptions options = options.Value;
|
||||
|
||||
public async Task<int> ProcessPendingAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!options.Enabled)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var domains = await dbContext.TenantDomains
|
||||
.Where(domain =>
|
||||
domain.DomainType == TenantDomainType.Custom &&
|
||||
(domain.Status == TenantDomainStatus.Pending || domain.Status == TenantDomainStatus.Failed))
|
||||
.OrderBy(domain => domain.LastCheckedAt)
|
||||
.Take(Math.Clamp(options.BatchSize, 1, 500))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
foreach (var domain in domains)
|
||||
{
|
||||
await ProcessAsync(domain, cancellationToken);
|
||||
}
|
||||
|
||||
if (domains.Length > 0)
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return domains.Length;
|
||||
}
|
||||
|
||||
private async Task ProcessAsync(TenantDomain domain, CancellationToken cancellationToken)
|
||||
{
|
||||
domain.LastCheckedAt = DateTimeOffset.UtcNow;
|
||||
if (string.IsNullOrWhiteSpace(domain.VerificationToken))
|
||||
{
|
||||
Fail(domain, true, "Domain verification token is missing.");
|
||||
return;
|
||||
}
|
||||
|
||||
var ownership = await ownershipVerifier.VerifyAsync(domain.Host, domain.VerificationToken, cancellationToken);
|
||||
if (!ownership.Verified)
|
||||
{
|
||||
Fail(domain, ownership.Configured, ownership.FailureReason);
|
||||
return;
|
||||
}
|
||||
|
||||
domain.DnsVerifiedAt ??= DateTimeOffset.UtcNow;
|
||||
domain.VerifiedAt ??= domain.DnsVerifiedAt;
|
||||
var gateway = await gatewayProvisioner.EnsureTlsAsync(domain.Host, cancellationToken);
|
||||
if (!gateway.TlsReady)
|
||||
{
|
||||
Fail(domain, gateway.Configured, gateway.FailureReason);
|
||||
return;
|
||||
}
|
||||
|
||||
domain.TlsReadyAt ??= DateTimeOffset.UtcNow;
|
||||
domain.Status = TenantDomainStatus.Active;
|
||||
domain.LastFailureReason = null;
|
||||
cacheInvalidator.Invalidate(domain.TenantId);
|
||||
}
|
||||
|
||||
private static void Fail(TenantDomain domain, bool configured, string? reason)
|
||||
{
|
||||
domain.Status = configured ? TenantDomainStatus.Failed : TenantDomainStatus.Pending;
|
||||
domain.LastFailureReason = reason;
|
||||
}
|
||||
}
|
||||
50
Tiku.Infrastructure/Tenancy/TenantExecutionScope.cs
Normal file
50
Tiku.Infrastructure/Tenancy/TenantExecutionScope.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Tiku.Application.Security;
|
||||
|
||||
namespace Tiku.Infrastructure.Tenancy;
|
||||
|
||||
public sealed class TenantExecutionScope(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<TenantExecutionScope> logger) : ITenantExecutionScope
|
||||
{
|
||||
public Task ExecuteAsync(
|
||||
Guid? targetTenantId,
|
||||
string reason,
|
||||
Func<IServiceProvider, CancellationToken, Task> operation,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ExecuteAsync<object?>(
|
||||
targetTenantId,
|
||||
reason,
|
||||
async (provider, token) =>
|
||||
{
|
||||
await operation(provider, token);
|
||||
return null;
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<TResult> ExecuteAsync<TResult>(
|
||||
Guid? targetTenantId,
|
||||
string reason,
|
||||
Func<IServiceProvider, CancellationToken, Task<TResult>> operation,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(operation);
|
||||
if (string.IsNullOrWhiteSpace(reason))
|
||||
{
|
||||
throw new ArgumentException("A system scope requires an audit reason.", nameof(reason));
|
||||
}
|
||||
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var initializer = scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>();
|
||||
initializer.InitializeSystem(targetTenantId, reason);
|
||||
logger.LogWarning(
|
||||
"Entering audited system tenant scope. TargetTenantId={TargetTenantId} Reason={Reason}",
|
||||
targetTenantId,
|
||||
reason);
|
||||
|
||||
return await operation(scope.ServiceProvider, cancellationToken);
|
||||
}
|
||||
}
|
||||
207
Tiku.Infrastructure/Tenancy/TenantFrontendConfigService.cs
Normal file
207
Tiku.Infrastructure/Tenancy/TenantFrontendConfigService.cs
Normal file
@@ -0,0 +1,207 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Tenancy;
|
||||
|
||||
public sealed class TenantFrontendConfigService(
|
||||
TikuDbContext dbContext,
|
||||
IMemoryCache cache) : ITenantFrontendConfigService
|
||||
{
|
||||
private static readonly TimeSpan RuntimeCacheDuration = TimeSpan.FromMinutes(2);
|
||||
|
||||
public async Task<TenantFrontendConfigItem> GetAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var config = await dbContext.TenantFrontendConfigs.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken);
|
||||
return ToItem(config ?? CreateDefault(tenantId));
|
||||
}
|
||||
|
||||
public async Task<TenantFrontendConfigItem> SaveDraftAsync(
|
||||
Guid tenantId,
|
||||
TenantFrontendConfigDraft draft,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Validate(draft);
|
||||
var config = await dbContext.TenantFrontendConfigs
|
||||
.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken);
|
||||
if (config is null)
|
||||
{
|
||||
config = CreateDefault(tenantId);
|
||||
dbContext.TenantFrontendConfigs.Add(config);
|
||||
}
|
||||
|
||||
config.DraftBranding = draft.Branding.Clone();
|
||||
config.DraftTheme = draft.Theme.Clone();
|
||||
config.DraftFeatures = draft.Features.Clone();
|
||||
config.DraftNavigation = draft.Navigation.Clone();
|
||||
config.DraftHomeModules = draft.HomeModules.Clone();
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToItem(config);
|
||||
}
|
||||
|
||||
public async Task<TenantFrontendConfigItem> PublishAsync(
|
||||
Guid tenantId,
|
||||
int expectedVersion,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var config = await dbContext.TenantFrontendConfigs
|
||||
.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken)
|
||||
?? throw new TenantFrontendConfigException(
|
||||
"frontend_config_not_found",
|
||||
"Save a frontend configuration draft before publishing.");
|
||||
if (config.ConfigVersion != expectedVersion)
|
||||
{
|
||||
throw new TenantFrontendConfigException(
|
||||
"frontend_config_version_conflict",
|
||||
"Frontend configuration has changed; reload it before publishing.");
|
||||
}
|
||||
|
||||
var draft = Draft(config);
|
||||
Validate(draft);
|
||||
config.PublishedBranding = config.DraftBranding.Clone();
|
||||
config.PublishedTheme = config.DraftTheme.Clone();
|
||||
config.PublishedFeatures = config.DraftFeatures.Clone();
|
||||
config.PublishedNavigation = config.DraftNavigation.Clone();
|
||||
config.PublishedHomeModules = config.DraftHomeModules.Clone();
|
||||
config.ConfigVersion++;
|
||||
config.PublishedAt = DateTimeOffset.UtcNow;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
cache.Remove(CacheKey(tenantId));
|
||||
return ToItem(config);
|
||||
}
|
||||
|
||||
public async Task<TenantRuntimeBootstrap> GetRuntimeAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (cache.TryGetValue<TenantRuntimeBootstrap>(CacheKey(tenantId), out var cached) && cached is not null)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var tenant = await dbContext.Tenants.AsNoTracking().SingleOrDefaultAsync(
|
||||
item => item.Id == tenantId && item.Status == TenantStatus.Active,
|
||||
cancellationToken)
|
||||
?? throw new TenantFrontendConfigException("tenant_not_found", "Active tenant was not found.");
|
||||
var config = await dbContext.TenantFrontendConfigs.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken)
|
||||
?? CreateDefault(tenantId);
|
||||
var result = new TenantRuntimeBootstrap(
|
||||
config.SchemaVersion,
|
||||
config.ConfigVersion,
|
||||
tenant.Slug,
|
||||
tenant.Name,
|
||||
config.PublishedBranding.Clone(),
|
||||
config.PublishedTheme.Clone(),
|
||||
config.PublishedFeatures.Clone(),
|
||||
config.PublishedNavigation.Clone(),
|
||||
config.PublishedHomeModules.Clone());
|
||||
cache.Set(CacheKey(tenantId), result, RuntimeCacheDuration);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static TenantFrontendConfig CreateDefault(Guid tenantId)
|
||||
{
|
||||
return new TenantFrontendConfig
|
||||
{
|
||||
TenantId = tenantId,
|
||||
SchemaVersion = 1,
|
||||
ConfigVersion = 1
|
||||
};
|
||||
}
|
||||
|
||||
private static TenantFrontendConfigItem ToItem(TenantFrontendConfig config)
|
||||
{
|
||||
return new TenantFrontendConfigItem(
|
||||
config.SchemaVersion,
|
||||
config.ConfigVersion,
|
||||
new TenantFrontendConfigDraft(
|
||||
config.PublishedBranding.Clone(),
|
||||
config.PublishedTheme.Clone(),
|
||||
config.PublishedFeatures.Clone(),
|
||||
config.PublishedNavigation.Clone(),
|
||||
config.PublishedHomeModules.Clone()),
|
||||
Draft(config),
|
||||
config.PublishedAt);
|
||||
}
|
||||
|
||||
private static TenantFrontendConfigDraft Draft(TenantFrontendConfig config)
|
||||
{
|
||||
return new TenantFrontendConfigDraft(
|
||||
config.DraftBranding.Clone(),
|
||||
config.DraftTheme.Clone(),
|
||||
config.DraftFeatures.Clone(),
|
||||
config.DraftNavigation.Clone(),
|
||||
config.DraftHomeModules.Clone());
|
||||
}
|
||||
|
||||
private static void Validate(TenantFrontendConfigDraft draft)
|
||||
{
|
||||
RequireKind(draft.Branding, JsonValueKind.Object, "branding");
|
||||
RequireKind(draft.Theme, JsonValueKind.Object, "theme");
|
||||
RequireKind(draft.Features, JsonValueKind.Object, "features");
|
||||
RequireKind(draft.Navigation, JsonValueKind.Array, "navigation");
|
||||
RequireKind(draft.HomeModules, JsonValueKind.Array, "homeModules");
|
||||
|
||||
foreach (var root in new[] { draft.Branding, draft.Theme, draft.Features, draft.Navigation, draft.HomeModules })
|
||||
{
|
||||
RejectUnsafeContent(root);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RequireKind(JsonElement value, JsonValueKind expected, string field)
|
||||
{
|
||||
if (value.ValueKind != expected)
|
||||
{
|
||||
throw new TenantFrontendConfigException(
|
||||
"frontend_config_invalid",
|
||||
$"Frontend configuration field '{field}' must be a JSON {expected.ToString().ToLowerInvariant()}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void RejectUnsafeContent(JsonElement value)
|
||||
{
|
||||
if (value.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in value.EnumerateObject())
|
||||
{
|
||||
if (property.Name.Contains("script", StringComparison.OrdinalIgnoreCase) ||
|
||||
property.Name.Contains("html", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw UnsafeConfig();
|
||||
}
|
||||
|
||||
RejectUnsafeContent(property.Value);
|
||||
}
|
||||
}
|
||||
else if (value.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in value.EnumerateArray())
|
||||
{
|
||||
RejectUnsafeContent(item);
|
||||
}
|
||||
}
|
||||
else if (value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var text = value.GetString() ?? string.Empty;
|
||||
if (text.Contains("<script", StringComparison.OrdinalIgnoreCase) ||
|
||||
text.Contains("javascript:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw UnsafeConfig();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static TenantFrontendConfigException UnsafeConfig() => new(
|
||||
"frontend_config_unsafe",
|
||||
"Frontend configuration cannot contain HTML or executable scripts.");
|
||||
|
||||
private static string CacheKey(Guid tenantId) => $"tenant-runtime:{tenantId:N}";
|
||||
}
|
||||
@@ -1933,6 +1933,10 @@ public sealed class TenantAdminDirectService(TikuDbContext dbContext) : ITenantA
|
||||
item.IsPrimary,
|
||||
item.VerificationToken,
|
||||
item.VerifiedAt,
|
||||
item.LastCheckedAt,
|
||||
item.DnsVerifiedAt,
|
||||
item.TlsReadyAt,
|
||||
item.LastFailureReason,
|
||||
item.CreatedAt,
|
||||
item.UpdatedAt);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user