feat: enforce tenant isolation and shared question bank

This commit is contained in:
2026-07-27 16:59:12 +08:00
parent 28e9a9fa41
commit db4c7b4496
137 changed files with 6402 additions and 112274 deletions

View File

@@ -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)
{

View File

@@ -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);

View 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);
}

View File

@@ -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,

View File

@@ -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
});

View File

@@ -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>();

View File

@@ -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)

View File

@@ -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 =>
{

View File

@@ -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 });

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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 });

View File

@@ -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 })

View File

@@ -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);
}
}

View File

@@ -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");
});
}
}

View File

@@ -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");

View File

@@ -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" });
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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}");

View File

@@ -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();

View File

@@ -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.");
}
}
}

View File

@@ -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)

View File

@@ -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.");
}
}

View 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);
}
}

View 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;
}
}

View 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);
}
}

View 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}";
}

View File

@@ -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);
}