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
+8 -6
View File
@@ -34,7 +34,8 @@ Tiku.IntegrationTests # API / EF 模型集成测试
- ASP.NET Authentication 负责身份认证。
- ASP.NET Authorization 负责权限策略。
- JWT + 数据库 `auth_sessions` 负责 access/refresh/session 闭环。
- `ICurrentUser` / `ICurrentTenant` 统一当前用户和租户上下文。
- `ICurrentUser` / 只读 `ITenantContext` 统一当前用户和请求租户上下文。
- EF Core Query Filter、写入拦截器和 PostgreSQL 组合约束共同阻断跨租户读写。
- PostgreSQL FK / unique / check / index 负责数据完整性底线。
- 审计事件表记录关键行为。
@@ -64,7 +65,7 @@ Tiku.IntegrationTests # API / EF 模型集成测试
旧版对连接池、日志、跨域、限流、配置校验这些工程底座比较薄。新后端已经补上:
- 单例 `NpgsqlDataSource` + `AddDbContextPool<TikuDbContext>`
- 单例 `NpgsqlDataSource` + scoped `AddDbContext<TikuDbContext>`,避免租户状态跨请求复用
- Serilog 结构化日志和请求上下文日志。
- 集中 CORS 配置,生产默认不放开 Origin。
- ASP.NET RateLimiter 全局限流。
@@ -89,18 +90,19 @@ Tiku.IntegrationTests # API / EF 模型集成测试
数据库模型迁移已经完成到 greenfield 初始 schema
```text
Tiku.Infrastructure/Persistence/Migrations/20260725220742_InitialSchema.cs
Tiku.Infrastructure/Persistence/Migrations/20260727084726_InitialSchema.cs
```
当前 EF 模型覆盖:
- 租户、用户、成员、认证、Session、短信验证码
- 地区、模块、院校、专业、科目、分类
- 题库、题目题目版本
- 内容入口、内容节点、题集练习蓝图
- 平台公共题库、租户私有题库、题目引用和题目版本
- 公共分类主干、租户扩展分类、内容入口、题集练习蓝图
- 词汇、手册、用户单词进度
- 资源、图片、App 资源、视频解析、导入任务
- 练习、答题、收藏、错题、报告、统计
- 版本锁定练习、答题、收藏、错题、报告、统计
- 自定义域名 DNS/TLS 生命周期和版本化前端运行时配置
- 商品、订单、支付、权益、兑换码、优惠券
- 推广、CRM、佣金
- Banner、FAQ、公告、通知、徽章、审计
+12 -12
View File
@@ -10,11 +10,11 @@ namespace Tiku.Api.Contracts;
public sealed class PasswordLoginDto
{
/// <summary>
/// 租户 ID。登录阶段允许客户端指定租户,登录后的业务接口以 JWT/session 解析出的当前租户为准
/// 平台控制域名登录时使用的租户代码;自定义域名登录可省略
/// </summary>
[Required]
[Description("租户 ID。登录阶段允许客户端指定租户,登录后的业务接口以 JWT/session 解析出的当前租户为准。")]
public Guid TenantId { get; set; }
[StringLength(100)]
[Description("平台控制域名登录时使用的租户代码;自定义域名登录可省略。")]
public string? TenantCode { get; set; }
/// <summary>
/// 手机号,建议前端提交规范化后的中国大陆手机号。
@@ -39,11 +39,11 @@ public sealed class PasswordLoginDto
public sealed class SmsLoginDto
{
/// <summary>
/// 租户 ID
/// 平台控制域名登录时使用的租户代码;自定义域名登录可省略
/// </summary>
[Required]
[Description("租户 ID。")]
public Guid TenantId { get; set; }
[StringLength(100)]
[Description("平台控制域名登录时使用的租户代码;自定义域名登录可省略。")]
public string? TenantCode { get; set; }
/// <summary>
/// 中国大陆手机号。
@@ -68,11 +68,11 @@ public sealed class SmsLoginDto
public sealed class OAuthCodeDto
{
/// <summary>
/// 租户 ID
/// 平台控制域名登录时使用的租户代码;自定义域名登录可省略
/// </summary>
[Required]
[Description("租户 ID。")]
public Guid TenantId { get; set; }
[StringLength(100)]
[Description("平台控制域名登录时使用的租户代码;自定义域名登录可省略。")]
public string? TenantCode { get; set; }
/// <summary>
/// OAuth 平台返回的一次性授权 code。
+12 -1
View File
@@ -1,7 +1,9 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Tiku.Application.Content;
using Tiku.Application.QuestionBanks;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
namespace Tiku.Api.Contracts;
@@ -252,6 +254,9 @@ public sealed class CollectionQuestionDto
[Required]
public Guid QuestionId { get; set; }
[Required]
public QuestionSource Source { get; set; } = QuestionSource.Tenant;
[StringLength(100)]
public string? SectionKey { get; set; }
@@ -265,7 +270,13 @@ public sealed class CollectionQuestionDto
public CollectionQuestionCommand ToCommand()
{
return new CollectionQuestionCommand(QuestionId, SectionKey, Order, Score, Required, Metadata);
return new CollectionQuestionCommand(
new QuestionLocator(Source, QuestionId),
SectionKey,
Order,
Score,
Required,
Metadata);
}
}
+8 -6
View File
@@ -1,7 +1,9 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Tiku.Application.Learning;
using Tiku.Application.QuestionBanks;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
namespace Tiku.Api.Contracts;
@@ -107,9 +109,7 @@ public sealed class SubmitPracticeSessionDto
public sealed class SubmitAnswerDto
{
[Required]
public Guid QuestionId { get; set; }
public Guid? PracticeSessionId { get; set; }
public Guid SessionQuestionId { get; set; }
public IReadOnlyCollection<string>? SelectedOptions { get; set; }
@@ -121,8 +121,7 @@ public sealed class SubmitAnswerDto
public SubmitAnswerCommand ToCommand()
{
return new SubmitAnswerCommand(
QuestionId,
PracticeSessionId,
SessionQuestionId,
SelectedOptions,
AnswerText,
SelfJudgedCorrect);
@@ -134,11 +133,14 @@ public sealed class QuestionActionDto
[Required]
public Guid QuestionId { get; set; }
[Required]
public QuestionSource Source { get; set; } = QuestionSource.Tenant;
public bool? Favorite { get; set; }
public QuestionActionCommand ToCommand()
{
return new QuestionActionCommand(QuestionId, Favorite);
return new QuestionActionCommand(new QuestionLocator(Source, QuestionId), Favorite);
}
}
+5 -1
View File
@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations;
using Tiku.Application.QuestionBanks;
using Tiku.Domain.Content;
namespace Tiku.Api.Contracts;
@@ -24,6 +25,8 @@ public sealed class QuestionBankQueryDto
public Guid? CollectionId { get; set; }
public QuestionSource? Source { get; set; }
[StringLength(50)]
public string? Type { get; set; }
@@ -54,7 +57,8 @@ public sealed class QuestionBankQueryDto
ParseQuestionIds(),
Type,
Keyword,
Limit);
Limit,
Source);
}
private Guid[] ParseQuestionIds()
+31
View File
@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Tiku.Application.Catalog;
using Tiku.Domain.Catalog;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
namespace Tiku.Api.Contracts;
public sealed class CreateTaxonomyNodeDto
{
public Guid? ParentId { get; set; }
public QuestionSource? ParentSource { get; set; }
[Required]
public TaxonomyNodeType NodeType { get; set; }
[Required, StringLength(100)]
public string Code { get; set; } = string.Empty;
[Required, StringLength(300)]
public string Name { get; set; } = string.Empty;
public int SortOrder { get; set; }
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
public CreateTaxonomyNodeCommand ToCommand() => new(
ParentId,
ParentSource,
NodeType,
Code,
Name,
SortOrder,
Metadata);
}
@@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Tiku.Application.Tenancy;
using Tiku.Domain.Common;
namespace Tiku.Api.Contracts;
public sealed class SaveTenantFrontendConfigDraftDto
{
public JsonElement Branding { get; set; } = JsonDefaults.Object();
public JsonElement Theme { get; set; } = JsonDefaults.Object();
public JsonElement Features { get; set; } = JsonDefaults.Object();
public JsonElement Navigation { get; set; } = JsonDefaults.Array();
public JsonElement HomeModules { get; set; } = JsonDefaults.Array();
public TenantFrontendConfigDraft ToDraft() => new(
Branding,
Theme,
Features,
Navigation,
HomeModules);
}
public sealed class PublishTenantFrontendConfigDto
{
[Range(1, int.MaxValue)]
public int ExpectedVersion { get; set; }
}
+1 -1
View File
@@ -15,7 +15,7 @@ namespace Tiku.Api.Controllers;
[Route("api/assets")]
public sealed class AssetsController(
IAssetAccessService assetAccessService,
ICurrentTenant currentTenant,
ITenantContext currentTenant,
ICurrentUser currentUser,
TikuDbContext dbContext) : ControllerBase
{
+58 -5
View File
@@ -2,13 +2,21 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Tiku.Application.Auth;
using Tiku.Api.Contracts;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Infrastructure.Content;
namespace Tiku.Api.Controllers;
[ApiController]
[Route("api/auth")]
[Produces("application/json")]
public sealed class AuthController(IAuthService authService) : ControllerBase
public sealed class AuthController(
IAuthService authService,
ISessionService sessionService,
ITenantContext tenantContext,
ITenantContextInitializer tenantContextInitializer,
ITenantDirectory tenantDirectory) : ControllerBase
{
[AllowAnonymous]
[HttpPost("login/password")]
@@ -22,7 +30,7 @@ public sealed class AuthController(IAuthService authService) : ControllerBase
{
var result = await authService.LoginWithPasswordAsync(
new PasswordLoginRequest(
request.TenantId,
await ResolveTenantIdAsync(request.TenantCode, cancellationToken),
request.Phone,
request.Password,
GetIpAddress(),
@@ -44,7 +52,7 @@ public sealed class AuthController(IAuthService authService) : ControllerBase
{
var result = await authService.LoginWithSmsAsync(
new SmsLoginRequest(
request.TenantId,
await ResolveTenantIdAsync(request.TenantCode, cancellationToken),
request.Phone,
request.Code,
GetIpAddress(),
@@ -67,7 +75,7 @@ public sealed class AuthController(IAuthService authService) : ControllerBase
{
var result = await authService.LoginWithWechatWebAsync(
new WechatLoginRequest(
request.TenantId,
await ResolveTenantIdAsync(request.TenantCode, cancellationToken),
request.Code,
GetIpAddress(),
Request.Headers.UserAgent.ToString()),
@@ -89,7 +97,7 @@ public sealed class AuthController(IAuthService authService) : ControllerBase
{
var result = await authService.LoginWithWechatMiniAppAsync(
new WechatLoginRequest(
request.TenantId,
await ResolveTenantIdAsync(request.TenantCode, cancellationToken),
request.Code,
GetIpAddress(),
Request.Headers.UserAgent.ToString()),
@@ -106,6 +114,7 @@ public sealed class AuthController(IAuthService authService) : ControllerBase
[FromBody] RefreshSessionDto request,
CancellationToken cancellationToken)
{
ResolveRefreshTokenTenant(request.RefreshToken);
var result = await authService.RefreshAsync(
new RefreshSessionRequest(
request.RefreshToken,
@@ -125,6 +134,7 @@ public sealed class AuthController(IAuthService authService) : ControllerBase
[FromBody] RefreshSessionDto request,
CancellationToken cancellationToken)
{
ResolveRefreshTokenTenant(request.RefreshToken);
await authService.LogoutAsync(
new LogoutSessionRequest(request.RefreshToken),
cancellationToken);
@@ -132,8 +142,51 @@ public sealed class AuthController(IAuthService authService) : ControllerBase
return NoContent();
}
private void ResolveRefreshTokenTenant(string refreshToken)
{
if (!sessionService.TryParseRefreshToken(refreshToken, out var locator))
{
return;
}
tenantContextInitializer.Initialize(locator.TenantId, null, TenantResolutionSource.RefreshToken);
}
private string? GetIpAddress()
{
return HttpContext.Connection.RemoteIpAddress?.ToString();
}
private async Task<Guid> ResolveTenantIdAsync(string? tenantCode, CancellationToken cancellationToken)
{
if (tenantContext.TenantId.HasValue)
{
if (!string.IsNullOrWhiteSpace(tenantCode) &&
!string.Equals(tenantContext.TenantCode, tenantCode.Trim(), StringComparison.OrdinalIgnoreCase))
{
var supplied = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken);
if (supplied?.TenantId != tenantContext.TenantId.Value)
{
throw new TenantContextConflictException(
tenantContext.TenantId.Value,
supplied?.TenantId ?? Guid.Empty);
}
}
return tenantContext.TenantId.Value;
}
if (string.IsNullOrWhiteSpace(tenantCode))
{
throw new RequiredFieldException("tenantCode is required when the request host does not resolve a tenant.");
}
var tenant = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken)
?? throw new TenantNotFoundException();
tenantContextInitializer.Initialize(
tenant.TenantId,
tenant.TenantCode,
TenantResolutionSource.TenantCode);
return tenant.TenantId;
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ public sealed class CatalogController(
IQuestionBankQueryService questionBankQueryService,
IStudyContentQueryService studyContentQueryService,
IAssetQueryService assetQueryService,
ICurrentTenant currentTenant,
ITenantContext currentTenant,
TikuDbContext dbContext) : ControllerBase
{
[HttpGet("regions")]
+34 -5
View File
@@ -6,6 +6,7 @@ using System.Text.Json;
using Tiku.Api.Contracts;
using Tiku.Application.Commerce;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
namespace Tiku.Api.Controllers;
@@ -16,7 +17,9 @@ namespace Tiku.Api.Controllers;
public sealed class CommerceController(
ICommerceService commerceService,
ICurrentUser currentUser,
ICurrentTenant currentTenant) : ControllerBase
ITenantContext currentTenant,
ITenantContextInitializer tenantInitializer,
ITenantDirectory tenantDirectory) : ControllerBase
{
[HttpPost("orders")]
[EndpointSummary("创建学生端订单")]
@@ -125,10 +128,13 @@ public sealed class CommerceController(
[EndpointSummary("微信支付回调")]
[ProducesResponseType<PaymentNotificationProcessResult>(StatusCodes.Status200OK)]
public async Task<ActionResult<PaymentNotificationProcessResult>> WechatPayNotify(
[FromQuery] Guid tenantId,
[FromQuery] string? tenantCode,
CancellationToken cancellationToken)
{
return Ok(await ProcessNotificationAsync(tenantId, PaymentProviders.WechatPay, cancellationToken));
return Ok(await ProcessNotificationAsync(
await ResolveNotificationTenantAsync(tenantCode, cancellationToken),
PaymentProviders.WechatPay,
cancellationToken));
}
[AllowAnonymous]
@@ -136,10 +142,33 @@ public sealed class CommerceController(
[EndpointSummary("支付宝支付回调")]
[ProducesResponseType<PaymentNotificationProcessResult>(StatusCodes.Status200OK)]
public async Task<ActionResult<PaymentNotificationProcessResult>> AlipayNotify(
[FromQuery] Guid tenantId,
[FromQuery] string? tenantCode,
CancellationToken cancellationToken)
{
return Ok(await ProcessNotificationAsync(tenantId, PaymentProviders.Alipay, cancellationToken));
return Ok(await ProcessNotificationAsync(
await ResolveNotificationTenantAsync(tenantCode, cancellationToken),
PaymentProviders.Alipay,
cancellationToken));
}
private async Task<Guid> ResolveNotificationTenantAsync(
string? tenantCode,
CancellationToken cancellationToken)
{
if (currentTenant.TenantId.HasValue)
{
return currentTenant.TenantId.Value;
}
if (string.IsNullOrWhiteSpace(tenantCode))
{
throw new CommerceException("Tenant code is required for payment notification.", "tenant_required");
}
var tenant = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken)
?? throw new CommerceException("Tenant was not found.", "tenant_not_found");
tenantInitializer.Initialize(tenant.TenantId, tenant.TenantCode, TenantResolutionSource.TenantCode);
return tenant.TenantId;
}
private CommerceActor ResolveActor()
+1 -1
View File
@@ -13,7 +13,7 @@ namespace Tiku.Api.Controllers;
public sealed class CommissionController(
ICommissionService commissionService,
ICurrentUser currentUser,
ICurrentTenant currentTenant) : ControllerBase
ITenantContext currentTenant) : ControllerBase
{
[HttpGet("settings")]
public async Task<ActionResult<CommissionSettingsItem>> Settings(CancellationToken cancellationToken) =>
+1 -1
View File
@@ -13,7 +13,7 @@ namespace Tiku.Api.Controllers;
public sealed class CrmController(
ICrmService crmService,
ICurrentUser currentUser,
ICurrentTenant currentTenant) : ControllerBase
ITenantContext currentTenant) : ControllerBase
{
[HttpGet("config")]
[EndpointSummary("查询 CRM 推送配置")]
+1 -1
View File
@@ -13,7 +13,7 @@ namespace Tiku.Api.Controllers;
public sealed class LearningController(
ILearningActivityService learningActivityService,
ICurrentUser currentUser,
ICurrentTenant currentTenant) : ControllerBase
ITenantContext currentTenant) : ControllerBase
{
[HttpGet("stats")]
[EndpointSummary("查询学习统计")]
+1 -1
View File
@@ -13,7 +13,7 @@ namespace Tiku.Api.Controllers;
public sealed class PointsController(
IPointService pointService,
ICurrentUser currentUser,
ICurrentTenant currentTenant) : ControllerBase
ITenantContext currentTenant) : ControllerBase
{
[HttpGet("summary")]
[EndpointSummary("查询当前用户积分摘要")]
+1 -1
View File
@@ -14,7 +14,7 @@ namespace Tiku.Api.Controllers;
public sealed class ProfileController(
IProfileService profileService,
ICurrentUser currentUser,
ICurrentTenant currentTenant) : ControllerBase
ITenantContext currentTenant) : ControllerBase
{
[HttpGet("me")]
[EndpointSummary("获取当前学生资料")]
+8 -10
View File
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
using Tiku.Api.Contracts;
using Tiku.Application.Growth;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
@@ -15,8 +16,9 @@ namespace Tiku.Api.Controllers;
public sealed class ReferralController(
IReferralService referralService,
ICurrentUser currentUser,
ICurrentTenant currentTenant,
TikuDbContext dbContext) : ControllerBase
ITenantContext currentTenant,
ITenantContextInitializer tenantInitializer,
ITenantDirectory tenantDirectory) : ControllerBase
{
[HttpPost("invite-code")]
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
@@ -223,13 +225,9 @@ public sealed class ReferralController(
throw new TenantNotFoundException();
}
var tenantId = await dbContext.Tenants
.Where(tenant =>
tenant.Slug == resolvedTenantCode.Trim() &&
tenant.Status == TenantStatus.Active)
.Select(tenant => (Guid?)tenant.Id)
.SingleOrDefaultAsync(cancellationToken);
return tenantId ?? throw new TenantNotFoundException();
var tenant = await tenantDirectory.FindByCodeAsync(resolvedTenantCode.Trim(), cancellationToken)
?? throw new TenantNotFoundException();
tenantInitializer.Initialize(tenant.TenantId, tenant.TenantCode, TenantResolutionSource.TenantCode);
return tenant.TenantId;
}
}
+45
View File
@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
namespace Tiku.Api.Controllers;
[ApiController]
[AllowAnonymous]
[Produces("application/json")]
[Route("api/runtime")]
public sealed class RuntimeController(
ITenantContext tenantContext,
ITenantFrontendConfigService frontendConfigService) : ControllerBase
{
[HttpGet("bootstrap")]
[EndpointSummary("获取租户前端运行时配置")]
[ProducesResponseType<TenantRuntimeBootstrap>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status304NotModified)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TenantRuntimeBootstrap>> Bootstrap(CancellationToken cancellationToken)
{
if (!tenantContext.TenantId.HasValue)
{
return NotFound(new ProblemDetails
{
Title = "Tenant was not found.",
Status = StatusCodes.Status404NotFound
});
}
var runtime = await frontendConfigService.GetRuntimeAsync(
tenantContext.TenantId.Value,
cancellationToken);
var etag = $"\"{runtime.TenantCode}-{runtime.ConfigVersion}\"";
if (Request.Headers.IfNoneMatch.Any(value => string.Equals(value, etag, StringComparison.Ordinal)))
{
return StatusCode(StatusCodes.Status304NotModified);
}
Response.Headers.ETag = etag;
Response.Headers.CacheControl = "public,max-age=60,must-revalidate";
return Ok(runtime);
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ namespace Tiku.Api.Controllers;
[Route("api/scoreline")]
public sealed class ScorelineController(
IScorelineQueryService scorelineQueryService,
ICurrentTenant currentTenant,
ITenantContext currentTenant,
TikuDbContext dbContext) : ControllerBase
{
private static readonly string[] DynamicPrefixes = ["field.", "min.", "max."];
@@ -9,7 +9,7 @@ namespace Tiku.Api.Controllers;
[Route("api/_security")]
public sealed class SecurityDiagnosticsController(
ICurrentUser currentUser,
ICurrentTenant currentTenant) : ControllerBase
ITenantContext currentTenant) : ControllerBase
{
[Authorize(Policy = TikuPolicies.AuthenticatedUser)]
[HttpGet("authenticated")]
@@ -29,7 +29,7 @@ public sealed class SecurityDiagnosticsController(
return Ok(new
{
currentTenant.TenantId,
currentTenant.Role
currentUser.TenantRole
});
}
@@ -40,7 +40,7 @@ public sealed class SecurityDiagnosticsController(
return Ok(new
{
currentTenant.TenantId,
currentTenant.Role
currentUser.TenantRole
});
}
}
@@ -0,0 +1,36 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Tiku.Api.Contracts;
using Tiku.Application.Catalog;
using Tiku.Application.Security;
namespace Tiku.Api.Controllers;
[ApiController]
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
[Produces("application/json")]
[Route("api/taxonomy/nodes")]
public sealed class TaxonomyController(
ITenantContext tenantContext,
ITaxonomyService taxonomyService) : ControllerBase
{
[HttpGet]
public Task<IReadOnlyCollection<TaxonomyNodeItem>> List(CancellationToken cancellationToken)
{
return taxonomyService.ListAsync(RequireTenantId(), cancellationToken);
}
[HttpPost]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
public Task<TaxonomyNodeItem> Create(
CreateTaxonomyNodeDto request,
CancellationToken cancellationToken)
{
return taxonomyService.CreateAsync(RequireTenantId(), request.ToCommand(), cancellationToken);
}
private Guid RequireTenantId()
{
return tenantContext.TenantId ?? throw new InvalidOperationException("Tenant was not resolved.");
}
}
@@ -16,7 +16,7 @@ namespace Tiku.Api.Controllers;
public sealed class TenantAdminDirectController(
ITenantAdminDirectService tenantAdminService,
ICurrentUser currentUser,
ICurrentTenant currentTenant) : ControllerBase
ITenantContext currentTenant) : ControllerBase
{
[HttpGet("classes")]
[EndpointSummary("查询租户班级")]
@@ -406,7 +406,7 @@ public sealed class TenantAdminDirectController(
}
var role = Enum.TryParse<TenantRole>(
currentTenant.Role?.Replace("_", string.Empty, StringComparison.Ordinal),
currentUser.TenantRole?.Replace("_", string.Empty, StringComparison.Ordinal),
ignoreCase: true,
out var parsedRole)
? parsedRole
@@ -13,7 +13,7 @@ namespace Tiku.Api.Controllers;
public sealed class TenantCommerceController(
ICommerceAdminService commerceAdminService,
ICurrentUser currentUser,
ICurrentTenant currentTenant) : ControllerBase
ITenantContext currentTenant) : ControllerBase
{
[HttpGet("payment-accounts")]
[EndpointSummary("查询租户支付账号")]
@@ -16,7 +16,7 @@ public sealed class TenantContentController(
IAssetManagementService assetManagementService,
IContentManagementService contentManagementService,
ICurrentUser currentUser,
ICurrentTenant currentTenant) : ControllerBase
ITenantContext currentTenant) : ControllerBase
{
[HttpGet("entries")]
[EndpointSummary("查询租户内容入口")]
@@ -17,7 +17,7 @@ namespace Tiku.Api.Controllers;
public sealed class TenantContentDirectController(
IDirectContentService directContentService,
ICurrentUser currentUser,
ICurrentTenant currentTenant) : ControllerBase
ITenantContext currentTenant) : ControllerBase
{
[HttpPost("questions")]
[EndpointSummary("创建题目及首个版本")]
@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Tiku.Api.Contracts;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
namespace Tiku.Api.Controllers;
[ApiController]
[Authorize(Policy = TikuPolicies.TenantAdmin)]
[Produces("application/json")]
[Route("api/tenant-admin/frontend-config")]
public sealed class TenantFrontendConfigController(
ITenantContext tenantContext,
ITenantFrontendConfigService frontendConfigService) : ControllerBase
{
[HttpGet]
public Task<TenantFrontendConfigItem> Get(CancellationToken cancellationToken)
{
return frontendConfigService.GetAsync(RequireTenantId(), cancellationToken);
}
[HttpPut("draft")]
public Task<TenantFrontendConfigItem> SaveDraft(
SaveTenantFrontendConfigDraftDto request,
CancellationToken cancellationToken)
{
return frontendConfigService.SaveDraftAsync(
RequireTenantId(),
request.ToDraft(),
cancellationToken);
}
[HttpPost("publish")]
public Task<TenantFrontendConfigItem> Publish(
PublishTenantFrontendConfigDto request,
CancellationToken cancellationToken)
{
return frontendConfigService.PublishAsync(
RequireTenantId(),
request.ExpectedVersion,
cancellationToken);
}
private Guid RequireTenantId()
{
return tenantContext.TenantId ?? throw new TenantFrontendConfigException(
"tenant_not_resolved",
"Tenant was not resolved.");
}
}
+29 -4
View File
@@ -7,6 +7,8 @@ using Tiku.Domain.Common;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
namespace Tiku.Api.Controllers;
@@ -14,7 +16,11 @@ namespace Tiku.Api.Controllers;
[AllowAnonymous]
[Produces("application/json")]
[Route("api/tenant")]
public sealed class TenantPublicController(TikuDbContext dbContext) : ControllerBase
public sealed class TenantPublicController(
TikuDbContext dbContext,
ITenantContext tenantContext,
ITenantContextInitializer tenantContextInitializer,
ITenantDirectory tenantDirectory) : ControllerBase
{
[HttpGet("resolve")]
[EndpointSummary("解析当前租户")]
@@ -28,9 +34,23 @@ public sealed class TenantPublicController(TikuDbContext dbContext) : Controller
var tenantCode = NormalizeTenantCode(query.TenantCode ?? Request.Headers["x-tenant-code"].FirstOrDefault());
var host = NormalizeHost(query.Host ?? Request.Host.Host);
var tenant = !string.IsNullOrWhiteSpace(tenantCode)
? await FindActiveTenantByCodeAsync(tenantCode, cancellationToken)
: await FindActiveTenantByHostAsync(host, cancellationToken);
var directoryEntry = tenantContext.ResolutionSource == TenantResolutionSource.Host
? await tenantDirectory.FindByHostAsync(Request.Host.Host, cancellationToken)
: !string.IsNullOrWhiteSpace(tenantCode)
? await tenantDirectory.FindByCodeAsync(tenantCode, cancellationToken)
: string.IsNullOrWhiteSpace(host)
? null
: await tenantDirectory.FindByHostAsync(host, cancellationToken);
TenantLookupResult? tenant = directoryEntry is null
? null
: new TenantLookupResult(
directoryEntry.TenantId,
directoryEntry.TenantCode,
directoryEntry.Name,
directoryEntry.Status,
directoryEntry.Mode,
directoryEntry.Host);
if (tenant is null)
{
@@ -42,6 +62,11 @@ public sealed class TenantPublicController(TikuDbContext dbContext) : Controller
});
}
tenantContextInitializer.Initialize(
tenant.Id,
tenant.Slug,
tenant.Host is null ? TenantResolutionSource.TenantCode : TenantResolutionSource.Host);
return Ok(await BuildResponseAsync(tenant, tenant.Host, cancellationToken));
}
+1 -1
View File
@@ -14,7 +14,7 @@ namespace Tiku.Api.Controllers;
[Route("api/tenants")]
public sealed class TenantsController(
ICurrentUser currentUser,
ICurrentTenant currentTenant,
ITenantContext currentTenant,
TikuDbContext dbContext) : ControllerBase
{
[HttpGet("current")]
@@ -6,11 +6,9 @@ public sealed class CurrentPrincipalMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(
HttpContext context,
ICurrentUser currentUser,
ICurrentTenant currentTenant)
ICurrentUser currentUser)
{
currentUser.Load(context.User);
currentTenant.Load(context.User);
await next(context);
}
}
@@ -2,10 +2,12 @@ using Microsoft.AspNetCore.Mvc;
using Tiku.Api.Controllers;
using Tiku.Application.Assets;
using Tiku.Application.Auth;
using Tiku.Application.Security;
using Tiku.Application.Commerce;
using Tiku.Application.Content;
using Tiku.Application.Growth;
using Tiku.Application.Points;
using Tiku.Application.QuestionBanks;
using Tiku.Application.Storage;
using Tiku.Infrastructure.Content;
using Tiku.Infrastructure.Learning;
@@ -13,6 +15,7 @@ using Tiku.Infrastructure.Profile;
using Tiku.Infrastructure.QuestionBanks;
using Tiku.Infrastructure.Scoreline;
using Tiku.Application.TenantAdmin;
using Tiku.Application.Tenancy;
namespace Tiku.Api.Middleware;
@@ -45,6 +48,52 @@ public sealed class ExceptionHandlingMiddleware(
return;
}
if (exception is TenantContextConflictException)
{
await WriteProblemAsync(
context,
exception.Message,
StatusCodes.Status403Forbidden,
"tenant_context_conflict");
return;
}
if (exception is TenantFrontendConfigException frontendConfigException)
{
var status = frontendConfigException.Code switch
{
"tenant_not_found" or "frontend_config_not_found" => StatusCodes.Status404NotFound,
"frontend_config_version_conflict" => StatusCodes.Status409Conflict,
_ => StatusCodes.Status400BadRequest
};
await WriteProblemAsync(
context,
frontendConfigException.Message,
status,
frontendConfigException.Code);
return;
}
if (exception is PublicQuestionAccessDeniedException publicQuestionAccessDeniedException)
{
await WriteProblemAsync(
context,
publicQuestionAccessDeniedException.Message,
StatusCodes.Status403Forbidden,
publicQuestionAccessDeniedException.Code);
return;
}
if (exception is QuestionLocatorException questionLocatorException)
{
await WriteProblemAsync(
context,
questionLocatorException.Message,
StatusCodes.Status404NotFound,
questionLocatorException.Code);
return;
}
if (exception is RequiredFieldException)
{
await WriteProblemAsync(
@@ -0,0 +1,74 @@
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.Mvc;
using Tiku.Api.Options;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
namespace Tiku.Api.Middleware;
public sealed class TenantResolutionMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(
HttpContext context,
ITenantDirectory tenantDirectory,
ITenantContextInitializer tenantInitializer,
IOptions<TenantResolutionOptions> options)
{
var host = NormalizeHost(context.Request.Host.Host);
var isPlatformHost = options.Value.PlatformHosts.Any(candidate =>
string.Equals(NormalizeHost(candidate), host, StringComparison.OrdinalIgnoreCase));
TenantDirectoryEntry? tenant = null;
if (!isPlatformHost && host is not null)
{
tenant = await tenantDirectory.FindByHostAsync(host, context.RequestAborted);
if (tenant is null && !IsExemptPath(context.Request.Path, options.Value.ExemptPathPrefixes))
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Title = "Tenant was not found.",
Status = StatusCodes.Status404NotFound,
Detail = "The request host is not assigned to an active tenant."
}, context.RequestAborted);
return;
}
}
else if (isPlatformHost && IsAllowedTenantCodePath(
context.Request.Path,
options.Value.TenantCodePathPrefixes))
{
var tenantCode = context.Request.Headers["x-tenant-code"].FirstOrDefault()
?? context.Request.Query["tenantCode"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(tenantCode))
{
tenant = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), context.RequestAborted);
}
}
if (tenant is not null)
{
tenantInitializer.Initialize(
tenant.TenantId,
tenant.TenantCode,
tenant.Host is null ? TenantResolutionSource.TenantCode : TenantResolutionSource.Host);
}
await next(context);
}
private static string? NormalizeHost(string? host)
{
return string.IsNullOrWhiteSpace(host) ? null : host.Trim().TrimEnd('.').ToLowerInvariant();
}
private static bool IsExemptPath(PathString path, IEnumerable<string> prefixes)
{
return prefixes.Any(prefix => path.StartsWithSegments(prefix, StringComparison.OrdinalIgnoreCase));
}
private static bool IsAllowedTenantCodePath(PathString path, IEnumerable<string> prefixes)
{
return prefixes.Any(prefix => path.StartsWithSegments(prefix, StringComparison.OrdinalIgnoreCase));
}
}
@@ -0,0 +1,12 @@
namespace Tiku.Api.Options;
public sealed class TenantResolutionOptions
{
public const string SectionName = "Tenancy:Resolution";
public string[] PlatformHosts { get; set; } = ["localhost", "127.0.0.1"];
public string[] ExemptPathPrefixes { get; set; } = ["/health", "/openapi", "/scalar"];
public string[] TenantCodePathPrefixes { get; set; } =
["/api/auth", "/api/tenant", "/api/catalog", "/api/assets", "/api/scoreline", "/api/referral", "/api/commerce/payments/notify"];
public string[] TrustedProxyAddresses { get; set; } = [];
}
+63
View File
@@ -1,6 +1,8 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.HttpOverrides;
using System.Net;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Scalar.AspNetCore;
@@ -16,6 +18,7 @@ using Tiku.Api.Options;
using Tiku.Api.Security;
using Tiku.Application;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Infrastructure;
using Tiku.Infrastructure.Commerce;
using Tiku.Infrastructure.Persistence;
@@ -49,6 +52,30 @@ try
});
builder.Services.AddProblemDetails();
builder.Services.AddApplication();
builder.Services.AddOptions<TenantResolutionOptions>()
.Bind(builder.Configuration.GetSection(TenantResolutionOptions.SectionName));
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders =
ForwardedHeaders.XForwardedFor |
ForwardedHeaders.XForwardedHost |
ForwardedHeaders.XForwardedProto;
options.ForwardLimit = 1;
options.KnownProxies.Clear();
options.KnownIPNetworks.Clear();
var resolution = builder.Configuration
.GetSection(TenantResolutionOptions.SectionName)
.Get<TenantResolutionOptions>() ?? new TenantResolutionOptions();
foreach (var address in resolution.TrustedProxyAddresses)
{
if (IPAddress.TryParse(address, out var proxy))
{
options.KnownProxies.Add(proxy);
}
}
});
builder.Services.AddOptions<DomainLifecycleOptions>()
.Bind(builder.Configuration.GetSection("TenantDomains"));
builder.Services.AddOptions<CorsOptions>()
.Bind(builder.Configuration.GetSection(CorsOptions.SectionName))
.ValidateDataAnnotations()
@@ -218,6 +245,26 @@ try
{
OnTokenValidated = async context =>
{
var tenantIdValue = context.Principal?.FindFirst(TikuClaimTypes.TenantId)?.Value;
if (!Guid.TryParse(tenantIdValue, out var tenantId))
{
context.Fail("Missing tenant claim.");
return;
}
var tenantInitializer = context.HttpContext.RequestServices
.GetRequiredService<ITenantContextInitializer>();
try
{
tenantInitializer.Initialize(tenantId, null, TenantResolutionSource.Jwt);
}
catch (TenantContextConflictException)
{
context.HttpContext.Items["tenant_context_conflict"] = true;
context.Fail("Authenticated tenant does not match the request host.");
return;
}
if (!jwtOptions.ValidateSessions)
{
return;
@@ -242,6 +289,20 @@ try
{
context.Fail("Session has been revoked or expired.");
}
},
OnChallenge = async context =>
{
if (context.HttpContext.Items.ContainsKey("tenant_context_conflict"))
{
context.HandleResponse();
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Title = "Authenticated tenant does not match the request host.",
Status = StatusCodes.Status403Forbidden,
Extensions = { ["code"] = "tenant_context_conflict" }
});
}
}
};
});
@@ -276,9 +337,11 @@ try
app.UseSerilogRequestLogging(SerilogRequestLogging.ConfigureRequestLogging);
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseForwardedHeaders();
app.UseHttpsRedirection();
app.UseRouting();
app.UseCors(CorsOptions.PolicyName);
app.UseMiddleware<TenantResolutionMiddleware>();
app.UseAuthentication();
if (rateLimitOptions.Enabled)
{
+4 -1
View File
@@ -4,7 +4,8 @@ namespace Tiku.Application.Auth;
public interface ISessionService
{
string GenerateRefreshToken();
string GenerateRefreshToken(Guid tenantId, Guid sessionId);
bool TryParseRefreshToken(string refreshToken, out RefreshTokenLocator locator);
string HashRefreshToken(string refreshToken);
Task<AuthTokenPair> IssueAsync(
@@ -17,3 +18,5 @@ public interface ISessionService
string? userAgent,
CancellationToken cancellationToken = default);
}
public readonly record struct RefreshTokenLocator(Guid TenantId, Guid SessionId);
@@ -0,0 +1,36 @@
using System.Text.Json;
using Tiku.Domain.Catalog;
using Tiku.Domain.Content;
namespace Tiku.Application.Catalog;
public sealed record TaxonomyNodeItem(
Guid Id,
QuestionSource Source,
Guid? ParentId,
QuestionSource? ParentSource,
TaxonomyNodeType NodeType,
string Code,
string Name,
string? Path,
int Depth,
int SortOrder,
JsonElement Metadata);
public sealed record CreateTaxonomyNodeCommand(
Guid? ParentId,
QuestionSource? ParentSource,
TaxonomyNodeType NodeType,
string Code,
string Name,
int SortOrder,
JsonElement Metadata);
public interface ITaxonomyService
{
Task<IReadOnlyCollection<TaxonomyNodeItem>> ListAsync(Guid tenantId, CancellationToken cancellationToken = default);
Task<TaxonomyNodeItem> CreateAsync(
Guid tenantId,
CreateTaxonomyNodeCommand command,
CancellationToken cancellationToken = default);
}
@@ -1,6 +1,7 @@
using System.Text.Json;
using Tiku.Application.Catalog;
using Tiku.Domain.Content;
using Tiku.Application.QuestionBanks;
namespace Tiku.Application.Content;
@@ -79,7 +80,7 @@ public sealed record ReplaceCollectionItemsCommand(
IReadOnlyCollection<CollectionQuestionCommand> Questions);
public sealed record CollectionQuestionCommand(
Guid QuestionId,
QuestionLocator Locator,
string? SectionKey,
int? Order,
decimal? Score,
@@ -173,6 +174,7 @@ public sealed record QuestionCollectionItemManagementItem(
Guid Id,
Guid CollectionId,
Guid QuestionId,
QuestionLocator Locator,
string? SectionKey,
int Order,
decimal? Score,
+3 -1
View File
@@ -8,7 +8,9 @@ public static class DependencyInjection
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddScoped<ICurrentUser, CurrentUser>();
services.AddScoped<ICurrentTenant, CurrentTenant>();
services.AddScoped<TenantContext>();
services.AddScoped<ITenantContext>(provider => provider.GetRequiredService<TenantContext>());
services.AddScoped<ITenantContextInitializer>(provider => provider.GetRequiredService<TenantContext>());
return services;
}
@@ -1,6 +1,7 @@
using System.Text.Json;
using Tiku.Domain.Content;
using Tiku.Domain.Learning;
using Tiku.Application.QuestionBanks;
namespace Tiku.Application.Learning;
@@ -9,13 +10,12 @@ public sealed record LearningActor(Guid TenantId, Guid UserId);
public sealed record LearningList<TItem>(IReadOnlyCollection<TItem> Items);
public sealed record SubmitAnswerCommand(
Guid QuestionId,
Guid? PracticeSessionId,
Guid SessionQuestionId,
IReadOnlyCollection<string>? SelectedOptions,
string? AnswerText,
bool? SelfJudgedCorrect);
public sealed record QuestionActionCommand(Guid QuestionId, bool? Favorite);
public sealed record QuestionActionCommand(QuestionLocator Locator, bool? Favorite);
public sealed record WordProgressCommand(
Guid WordId,
@@ -55,7 +55,11 @@ public sealed record LearningLeaderboardResult(
LearningLeaderboardItem? CurrentUser,
DateTimeOffset GeneratedAt);
public sealed record WrongQuestionReviewPlanItem(Guid QuestionId, int WrongCount, DateTimeOffset LastWrongAt);
public sealed record WrongQuestionReviewPlanItem(
Guid QuestionReferenceId,
QuestionLocator Locator,
int WrongCount,
DateTimeOffset LastWrongAt);
public sealed record WrongQuestionReviewPlan(
IReadOnlyCollection<WrongQuestionReviewPlanItem> Items,
@@ -106,21 +110,21 @@ public sealed record PracticeSessionFilter(
public sealed record AnswerRecordItem(
Guid Id,
Guid QuestionId,
Guid? QuestionVersionId,
Guid? PracticeSessionId,
Guid SessionQuestionId,
Guid PracticeSessionId,
JsonElement SelectedOptions,
string? AnswerText,
bool? IsCorrect,
DateTimeOffset AnsweredAt);
public sealed record FavoriteQuestionItem(
Guid QuestionId,
string Source,
Guid QuestionReferenceId,
QuestionLocator Locator,
DateTimeOffset CreatedAt);
public sealed record WrongQuestionItem(
Guid QuestionId,
Guid QuestionReferenceId,
QuestionLocator Locator,
int WrongCount,
DateTimeOffset LastWrongAt,
DateTimeOffset? ResolvedAt);
@@ -154,7 +158,6 @@ public sealed record PracticeSessionItem(
Guid? CollectionId,
Guid? EntryId,
Guid? ContentNodeId,
JsonElement QuestionIds,
int QuestionCount,
int? DurationMinutes,
decimal? TotalScore,
@@ -171,10 +174,13 @@ public sealed record PracticeSessionItem(
public sealed record PracticeSessionDetailItem(
PracticeSessionItem Session,
IReadOnlyCollection<PracticeSessionQuestionItem> Questions,
IReadOnlyDictionary<Guid, AnswerRecordItem> AnswersByQuestion);
IReadOnlyDictionary<Guid, AnswerRecordItem> AnswersBySessionQuestion);
public sealed record PracticeSessionQuestionItem(
Guid Id,
Guid SessionQuestionId,
Guid QuestionReferenceId,
QuestionLocator Locator,
Guid QuestionId,
string Type,
string? TypeLabel,
int? Difficulty,
@@ -0,0 +1,27 @@
using Tiku.Domain.Content;
namespace Tiku.Application.QuestionBanks;
public interface IPublicQuestionAccessPolicy
{
Task EnsureCanStartAsync(Guid tenantId, CancellationToken cancellationToken = default);
}
public interface IQuestionReferenceService
{
Task<TenantQuestionReference> ResolveAsync(
Guid tenantId,
Guid? userId,
QuestionLocator locator,
CancellationToken cancellationToken = default);
}
public sealed class PublicQuestionAccessDeniedException(string code, string message) : Exception(message)
{
public string Code { get; } = code;
}
public sealed class QuestionLocatorException(string code, string message) : Exception(message)
{
public string Code { get; } = code;
}
@@ -1,5 +1,6 @@
using System.Text.Json;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Content;
namespace Tiku.Application.QuestionBanks;
@@ -17,13 +18,14 @@ public sealed record QuestionBankFilter(
IReadOnlyCollection<Guid>? QuestionIds = null,
string? Type = null,
string? Keyword = null,
int? Limit = null);
int? Limit = null,
QuestionSource? Source = null);
public sealed record QuestionBankCatalogItem(
Guid Id,
Guid? RegionId,
string Name,
QuestionBankScope SourceScope,
QuestionSource Source,
QuestionBankStatus Status,
JsonElement Metadata);
@@ -58,7 +60,10 @@ public sealed record QuestionCatalogItem(
string? Explanation,
JsonElement SubQuestions,
string? CodeLang,
string? CodeTemplate);
string? CodeTemplate,
QuestionLocator Locator);
public sealed record QuestionLocator(QuestionSource Source, Guid QuestionId);
public sealed record QuestionVersionCatalogItem(
Guid Id,
@@ -1,16 +0,0 @@
using System.Security.Claims;
namespace Tiku.Application.Security;
public sealed class CurrentTenant : ICurrentTenant
{
public Guid? TenantId { get; private set; }
public string? Role { get; private set; }
public bool IsResolved => TenantId.HasValue;
public void Load(ClaimsPrincipal principal)
{
TenantId = principal.FindGuid(TikuClaimTypes.TenantId);
Role = principal.FindValue(TikuClaimTypes.TenantRole);
}
}
+2
View File
@@ -8,6 +8,7 @@ public sealed class CurrentUser : ICurrentUser
public Guid? SessionId { get; private set; }
public string? Phone { get; private set; }
public string? Email { get; private set; }
public string? TenantRole { get; private set; }
public bool IsAuthenticated { get; private set; }
public void Load(ClaimsPrincipal principal)
@@ -17,5 +18,6 @@ public sealed class CurrentUser : ICurrentUser
SessionId = principal.FindGuid(TikuClaimTypes.SessionId);
Phone = principal.FindValue(TikuClaimTypes.Phone);
Email = principal.FindValue(TikuClaimTypes.Email);
TenantRole = principal.FindValue(TikuClaimTypes.TenantRole);
}
}
@@ -1,11 +0,0 @@
using System.Security.Claims;
namespace Tiku.Application.Security;
public interface ICurrentTenant
{
Guid? TenantId { get; }
string? Role { get; }
bool IsResolved { get; }
void Load(ClaimsPrincipal principal);
}
@@ -8,6 +8,7 @@ public interface ICurrentUser
Guid? SessionId { get; }
string? Phone { get; }
string? Email { get; }
string? TenantRole { get; }
bool IsAuthenticated { get; }
void Load(ClaimsPrincipal principal);
}
@@ -0,0 +1,87 @@
namespace Tiku.Application.Security;
public enum TenantResolutionSource
{
None,
Host,
TenantCode,
Jwt,
RefreshToken,
System
}
public interface ITenantContext
{
Guid? TenantId { get; }
string? TenantCode { get; }
TenantResolutionSource ResolutionSource { get; }
bool IsResolved { get; }
bool IsSystem { get; }
}
public interface ITenantContextInitializer
{
void Initialize(Guid tenantId, string? tenantCode, TenantResolutionSource source);
void InitializeSystem(Guid? targetTenantId, string reason);
}
public sealed class TenantContext : ITenantContext, ITenantContextInitializer
{
public Guid? TenantId { get; private set; }
public string? TenantCode { get; private set; }
public TenantResolutionSource ResolutionSource { get; private set; }
public bool IsResolved => TenantId.HasValue;
public bool IsSystem { get; private set; }
internal string? SystemReason { get; private set; }
public void Initialize(Guid tenantId, string? tenantCode, TenantResolutionSource source)
{
if (tenantId == Guid.Empty)
{
throw new ArgumentException("Tenant ID cannot be empty.", nameof(tenantId));
}
if (IsSystem)
{
throw new InvalidOperationException("A system tenant context cannot be replaced.");
}
if (TenantId.HasValue && TenantId.Value != tenantId)
{
throw new TenantContextConflictException(TenantId.Value, tenantId);
}
var wasResolved = TenantId.HasValue;
TenantId = tenantId;
TenantCode = string.IsNullOrWhiteSpace(tenantCode) ? TenantCode : tenantCode.Trim();
if (!wasResolved)
{
ResolutionSource = source;
}
}
public void InitializeSystem(Guid? targetTenantId, string reason)
{
if (IsResolved || IsSystem)
{
throw new InvalidOperationException("Tenant context has already been initialized.");
}
if (string.IsNullOrWhiteSpace(reason))
{
throw new ArgumentException("A system scope requires an audit reason.", nameof(reason));
}
TenantId = targetTenantId;
ResolutionSource = TenantResolutionSource.System;
IsSystem = true;
SystemReason = reason.Trim();
}
}
public sealed class TenantContextConflictException(Guid expectedTenantId, Guid actualTenantId)
: Exception($"Resolved tenant '{expectedTenantId}' conflicts with authenticated tenant '{actualTenantId}'.")
{
public Guid ExpectedTenantId { get; } = expectedTenantId;
public Guid ActualTenantId { get; } = actualTenantId;
}
@@ -0,0 +1,16 @@
namespace Tiku.Application.Security;
public interface ITenantExecutionScope
{
Task ExecuteAsync(
Guid? targetTenantId,
string reason,
Func<IServiceProvider, CancellationToken, Task> operation,
CancellationToken cancellationToken = default);
Task<TResult> ExecuteAsync<TResult>(
Guid? targetTenantId,
string reason,
Func<IServiceProvider, CancellationToken, Task<TResult>> operation,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,22 @@
using Tiku.Domain.Tenancy;
namespace Tiku.Application.Tenancy;
public sealed record TenantDirectoryEntry(
Guid TenantId,
string TenantCode,
string Name,
TenantStatus Status,
TenantMode Mode,
string? Host);
public interface ITenantDirectory
{
Task<TenantDirectoryEntry?> FindByHostAsync(
string host,
CancellationToken cancellationToken = default);
Task<TenantDirectoryEntry?> FindByCodeAsync(
string tenantCode,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,39 @@
namespace Tiku.Application.Tenancy;
public sealed class DomainLifecycleOptions
{
public bool Enabled { get; set; } = true;
public int PollSeconds { get; set; } = 60;
public int BatchSize { get; set; } = 50;
public string DnsJsonEndpoint { get; set; } = "https://cloudflare-dns.com/dns-query";
public string VerificationRecordPrefix { get; set; } = "_tiku-verification";
public string[] AllowedCnameTargets { get; set; } = [];
public string? GatewayBaseUrl { get; set; }
public string? GatewayApiKey { get; set; }
}
public sealed record DomainOwnershipResult(bool Verified, bool Configured, string? FailureReason);
public sealed record DomainGatewayResult(bool TlsReady, bool Configured, string? FailureReason);
public interface IDomainOwnershipVerifier
{
Task<DomainOwnershipResult> VerifyAsync(
string host,
string verificationToken,
CancellationToken cancellationToken = default);
}
public interface IDomainGatewayProvisioner
{
Task<DomainGatewayResult> EnsureTlsAsync(string host, CancellationToken cancellationToken = default);
}
public interface ITenantDomainLifecycleService
{
Task<int> ProcessPendingAsync(CancellationToken cancellationToken = default);
}
public interface ITenantRuntimeCacheInvalidator
{
void Invalidate(Guid tenantId);
}
@@ -0,0 +1,47 @@
using System.Text.Json;
namespace Tiku.Application.Tenancy;
public sealed record TenantFrontendConfigDraft(
JsonElement Branding,
JsonElement Theme,
JsonElement Features,
JsonElement Navigation,
JsonElement HomeModules);
public sealed record TenantFrontendConfigItem(
int SchemaVersion,
int ConfigVersion,
TenantFrontendConfigDraft Published,
TenantFrontendConfigDraft Draft,
DateTimeOffset? PublishedAt);
public sealed record TenantRuntimeBootstrap(
int SchemaVersion,
int ConfigVersion,
string TenantCode,
string TenantName,
JsonElement Branding,
JsonElement Theme,
JsonElement Features,
JsonElement Navigation,
JsonElement HomeModules);
public interface ITenantFrontendConfigService
{
Task<TenantFrontendConfigItem> GetAsync(Guid tenantId, CancellationToken cancellationToken = default);
Task<TenantFrontendConfigItem> SaveDraftAsync(
Guid tenantId,
TenantFrontendConfigDraft draft,
CancellationToken cancellationToken = default);
Task<TenantFrontendConfigItem> PublishAsync(
Guid tenantId,
int expectedVersion,
CancellationToken cancellationToken = default);
Task<TenantRuntimeBootstrap> GetRuntimeAsync(Guid tenantId, CancellationToken cancellationToken = default);
}
public sealed class TenantFrontendConfigException(string code, string message) : Exception(message)
{
public string Code { get; } = code;
}
@@ -460,6 +460,10 @@ public sealed record TenantDomainItem(
bool IsPrimary,
string? VerificationToken,
DateTimeOffset? VerifiedAt,
DateTimeOffset? LastCheckedAt,
DateTimeOffset? DnsVerifiedAt,
DateTimeOffset? TlsReadyAt,
string? LastFailureReason,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Tiku.Application.Security;
using Tiku.Infrastructure.Persistence;
namespace Tiku.DbMigrator;
@@ -17,6 +18,8 @@ public sealed class DesignTimeTikuDbContextFactory : IDesignTimeDbContextFactory
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName))
.Options;
return new TikuDbContext(options);
var tenantContext = new TenantContext();
tenantContext.InitializeSystem(null, "EF Core design-time model generation");
return new TikuDbContext(options, tenantContext);
}
}
+2
View File
@@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Tiku.Infrastructure;
using Tiku.Infrastructure.Persistence;
using Tiku.Application;
var builder = Host.CreateApplicationBuilder(args);
var connectionString =
@@ -12,6 +13,7 @@ var connectionString =
throw new InvalidOperationException(
"Database connection is required. Configure ConnectionStrings:Database or DATABASE_URL.");
builder.Services.AddApplication();
builder.Services.AddInfrastructure(connectionString);
using var host = builder.Build();
+37
View File
@@ -0,0 +1,37 @@
using System.Text.Json;
using Tiku.Domain.Common;
namespace Tiku.Domain.Catalog;
public sealed class TaxonomyNode : AuditableTenantEntity
{
public Guid? ParentOwnerTenantId { get; set; }
public Guid? ParentId { get; set; }
public TaxonomyNodeType NodeType { get; set; } = TaxonomyNodeType.KnowledgePoint;
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Path { get; set; }
public int Depth { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}
public sealed class QuestionTaxonomyAssignment : Entity, ITenantOwned
{
public Guid TenantId { get; set; }
public Guid QuestionId { get; set; }
public Guid TaxonomyOwnerTenantId { get; set; }
public Guid TaxonomyNodeId { get; set; }
public bool IsPrimary { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public enum TaxonomyNodeType
{
Subject,
Chapter,
KnowledgePoint,
Paper,
Custom
}
+6 -6
View File
@@ -89,7 +89,7 @@ public sealed class Payment : AuditableTenantEntity
public JsonElement RawPayload { get; set; } = JsonDefaults.Object();
}
public sealed class PaymentEvent : Entity
public sealed class PaymentEvent : Entity, ITenantOwned
{
public Guid TenantId { get; set; }
public Guid? PaymentId { get; set; }
@@ -103,7 +103,7 @@ public sealed class PaymentEvent : Entity
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class Entitlement : Entity
public sealed class Entitlement : Entity, ITenantOwned
{
public Guid TenantId { get; set; }
public Guid UserId { get; set; }
@@ -213,7 +213,7 @@ public sealed class TenantSubscription : AuditableTenantEntity
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}
public sealed class TenantUsageRecord : Entity
public sealed class TenantUsageRecord : Entity, ITenantOwned
{
public Guid TenantId { get; set; }
public string MetricKey { get; set; } = string.Empty;
@@ -248,7 +248,7 @@ public sealed class CommerceRefundRequest : AuditableTenantEntity
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}
public sealed class CommerceRefundEvent : Entity
public sealed class CommerceRefundEvent : Entity, ITenantOwned
{
public Guid TenantId { get; set; }
public Guid RefundRequestId { get; set; }
@@ -285,7 +285,7 @@ public sealed class CommerceReconciliationBatch : AuditableTenantEntity
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}
public sealed class CommerceReconciliationItem : Entity
public sealed class CommerceReconciliationItem : Entity, ITenantOwned
{
public Guid TenantId { get; set; }
public Guid BatchId { get; set; }
@@ -344,7 +344,7 @@ public sealed class CommerceReconciliationIssue : AuditableTenantEntity
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}
public sealed class CommerceReconciliationIssueEvent : Entity
public sealed class CommerceReconciliationIssueEvent : Entity, ITenantOwned
{
public Guid TenantId { get; set; }
public Guid IssueId { get; set; }
+6 -1
View File
@@ -13,13 +13,18 @@ public interface IHasTimestamps
DateTimeOffset UpdatedAt { get; set; }
}
public interface ITenantOwned
{
Guid TenantId { get; set; }
}
public abstract class AuditableEntity : Entity, IHasTimestamps
{
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public abstract class TenantEntity : Entity
public abstract class TenantEntity : Entity, ITenantOwned
{
public Guid TenantId { get; set; }
}
+2
View File
@@ -141,6 +141,8 @@ public enum ContentStatus
public sealed class QuestionCollectionItem : AuditableTenantEntity
{
public Guid CollectionId { get; set; }
public Guid QuestionReferenceId { get; set; }
public Guid QuestionOwnerTenantId { get; set; }
public Guid QuestionId { get; set; }
public string? SectionKey { get; set; }
public int SortOrder { get; set; }
+14 -31
View File
@@ -3,7 +3,7 @@ using Tiku.Domain.Common;
namespace Tiku.Domain.Content;
public sealed class ContentAssetAccessEvent : Entity
public sealed class ContentAssetAccessEvent : Entity, ITenantOwned
{
public Guid TenantId { get; set; }
public Guid? AssetId { get; set; }
@@ -24,7 +24,7 @@ public sealed class ContentAssetAccessEvent : Entity
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class ContentAssetSecurityScanEvent : Entity
public sealed class ContentAssetSecurityScanEvent : Entity, ITenantOwned
{
public Guid TenantId { get; set; }
public Guid? AssetId { get; set; }
@@ -36,38 +36,25 @@ public sealed class ContentAssetSecurityScanEvent : Entity
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class QuestionBankGrant : AuditableEntity
public sealed class TenantQuestionBankPreference : AuditableTenantEntity
{
public Guid SourceQuestionBankId { get; set; }
public Guid QuestionBankOwnerTenantId { get; set; }
public Guid QuestionBankId { get; set; }
public bool IsVisible { get; set; } = true;
public string? Alias { get; set; }
public int SortOrder { get; set; }
public string? NavigationLocation { get; set; }
public Guid? CreatedBy { get; set; }
public Guid? UpdatedBy { get; set; }
public QuestionBankGrantScope GrantScope { get; set; } = QuestionBankGrantScope.Plans;
public string[] AllowedPlanCodes { get; set; } = [];
public Guid[] AllowedTenantIds { get; set; } = [];
public Guid[] AllowedRegionIds { get; set; } = [];
public Guid[] AllowedSubjectIds { get; set; } = [];
public QuestionBankGrantStatus Status { get; set; } = QuestionBankGrantStatus.Active;
public DateTimeOffset? StartsAt { get; set; }
public DateTimeOffset? ExpiresAt { get; set; }
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}
public sealed class TenantQuestionBankAdoption : AuditableTenantEntity
public sealed class TenantQuestionReference : AuditableTenantEntity
{
public Guid SourceQuestionBankId { get; set; }
public Guid? GrantId { get; set; }
public Guid? TargetQuestionBankId { get; set; }
public Guid? TargetEntryId { get; set; }
public Guid? TargetCollectionId { get; set; }
public Guid QuestionOwnerTenantId { get; set; }
public Guid QuestionId { get; set; }
public QuestionSource Source { get; set; } = QuestionSource.Tenant;
public Guid? CreatedBy { get; set; }
public Guid? UpdatedBy { get; set; }
public QuestionBankAdoptionMode AdoptionMode { get; set; } = QuestionBankAdoptionMode.CopiedSnapshot;
public QuestionBankAdoptionStatus Status { get; set; } = QuestionBankAdoptionStatus.Active;
public QuestionBankAdoptionSyncStatus SyncStatus { get; set; } = QuestionBankAdoptionSyncStatus.Synced;
public JsonElement SourceSnapshot { get; set; } = JsonDefaults.Object();
public int CopiedQuestionCount { get; set; }
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
public DateTimeOffset? LastSyncedAt { get; set; }
}
public sealed class AiRecommendationReport : AuditableTenantEntity
@@ -90,9 +77,5 @@ public enum AssetAccessType { Download, Preview, AdminDownload, AdminPreview, Up
public enum AssetAccessDisposition { Attachment, Inline }
public enum AssetAccessResult { Granted, Denied }
public enum AssetSecurityRiskLevel { None, Low, Medium, High, Critical }
public enum QuestionBankGrantScope { AllActiveTenants, Plans, Tenants, Mixed }
public enum QuestionBankGrantStatus { Active, Disabled, Expired }
public enum QuestionBankAdoptionMode { CopiedSnapshot, Reference }
public enum QuestionBankAdoptionStatus { Active, SyncPending, Suspended, Archived }
public enum QuestionBankAdoptionSyncStatus { Pending, Synced, Failed }
public enum QuestionSource { Platform, Tenant }
public enum AiRecommendationReportStatus { Draft, Generated, Failed }
+1 -1
View File
@@ -188,7 +188,7 @@ public sealed class CommissionSettlementProof : AuditableTenantEntity
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}
public sealed class CommissionSettlementExportEvent : Entity
public sealed class CommissionSettlementExportEvent : Entity, ITenantOwned
{
public Guid TenantId { get; set; }
public Guid SettlementId { get; set; }
@@ -3,7 +3,7 @@ using Tiku.Domain.Common;
namespace Tiku.Domain.Import;
public sealed class PocketBaseImportRun : Entity
public sealed class PocketBaseImportRun : Entity, ITenantOwned
{
public Guid TenantId { get; set; }
public string SourceName { get; set; } = string.Empty;
@@ -14,7 +14,7 @@ public sealed class PocketBaseImportRun : Entity
public DateTimeOffset? FinishedAt { get; set; }
}
public sealed class PocketBaseRawRecord
public sealed class PocketBaseRawRecord : ITenantOwned
{
public Guid RunId { get; set; }
public Guid TenantId { get; set; }
@@ -26,7 +26,7 @@ public sealed class PocketBaseRawRecord
public DateTimeOffset ImportedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class PocketBaseImportIssue : Entity
public sealed class PocketBaseImportIssue : Entity, ITenantOwned
{
public Guid? RunId { get; set; }
public Guid TenantId { get; set; }
+19 -6
View File
@@ -15,7 +15,6 @@ public sealed class PracticeSession : TenantEntity
public Guid? TargetId { get; set; }
public DateTimeOffset StartedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? FinishedAt { get; set; }
public JsonElement QuestionIds { get; set; } = JsonDefaults.Array();
public int QuestionCount { get; set; }
public int? DurationMinutes { get; set; }
public decimal? TotalScore { get; set; }
@@ -27,6 +26,17 @@ public sealed class PracticeSession : TenantEntity
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}
public sealed class PracticeSessionQuestion : TenantEntity
{
public Guid PracticeSessionId { get; set; }
public Guid QuestionReferenceId { get; set; }
public Guid QuestionOwnerTenantId { get; set; }
public Guid QuestionId { get; set; }
public Guid QuestionVersionId { get; set; }
public int Position { get; set; }
public decimal? Score { get; set; }
}
public enum PracticeAccessMode
{
Free,
@@ -37,9 +47,8 @@ public enum PracticeAccessMode
public sealed class AnswerRecord : TenantEntity
{
public Guid UserId { get; set; }
public Guid? QuestionId { get; set; }
public Guid? QuestionVersionId { get; set; }
public Guid? PracticeSessionId { get; set; }
public Guid PracticeSessionId { get; set; }
public Guid SessionQuestionId { get; set; }
public string? LegacyId { get; set; }
public string? LegacyQuestionId { get; set; }
public string? LegacyCategoryId { get; set; }
@@ -50,19 +59,23 @@ public sealed class AnswerRecord : TenantEntity
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class FavoriteQuestion
public sealed class FavoriteQuestion : ITenantOwned
{
public Guid TenantId { get; set; }
public Guid UserId { get; set; }
public Guid QuestionReferenceId { get; set; }
public Guid QuestionOwnerTenantId { get; set; }
public Guid QuestionId { get; set; }
public string Source { get; set; } = "imported";
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class WrongQuestion
public sealed class WrongQuestion : ITenantOwned
{
public Guid TenantId { get; set; }
public Guid UserId { get; set; }
public Guid QuestionReferenceId { get; set; }
public Guid QuestionOwnerTenantId { get; set; }
public Guid QuestionId { get; set; }
public int WrongCount { get; set; } = 1;
public DateTimeOffset LastWrongAt { get; set; } = DateTimeOffset.UtcNow;
@@ -36,7 +36,7 @@ public sealed class Report : AuditableTenantEntity
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}
public sealed class ReportStatusEvent : Entity
public sealed class ReportStatusEvent : Entity, ITenantOwned
{
public Guid TenantId { get; set; }
public Guid ReportId { get; set; }
@@ -48,7 +48,7 @@ public sealed class ReportStatusEvent : Entity
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class UserScoreEvent : Entity
public sealed class UserScoreEvent : Entity, ITenantOwned
{
public Guid TenantId { get; set; }
public Guid UserId { get; set; }
@@ -73,7 +73,7 @@ public sealed class PracticeDailyUsage : AuditableTenantEntity
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}
public sealed class PracticeAccessEvent : Entity
public sealed class PracticeAccessEvent : Entity, ITenantOwned
{
public Guid TenantId { get; set; }
public Guid? UserId { get; set; }
@@ -18,7 +18,7 @@ public sealed class PlatformSaasPlan : AuditableEntity
public int SortOrder { get; set; }
}
public sealed class TenantBillingProfile : IHasTimestamps
public sealed class TenantBillingProfile : IHasTimestamps, ITenantOwned
{
public Guid TenantId { get; set; }
public string? BillingName { get; set; }
@@ -58,7 +58,7 @@ public sealed class TenantInvoice : AuditableTenantEntity
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}
public sealed class TenantInvoiceItem : Entity
public sealed class TenantInvoiceItem : Entity, ITenantOwned
{
public Guid TenantId { get; set; }
public Guid InvoiceId { get; set; }
@@ -7,17 +7,10 @@ public sealed class QuestionBank : AuditableTenantEntity
{
public Guid? RegionId { get; set; }
public string Name { get; set; } = string.Empty;
public QuestionBankScope SourceScope { get; set; } = QuestionBankScope.Tenant;
public QuestionBankStatus Status { get; set; } = QuestionBankStatus.Active;
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}
public enum QuestionBankScope
{
Platform,
Tenant
}
public enum QuestionBankStatus
{
Active,
@@ -55,7 +48,7 @@ public enum QuestionStatus
Archived
}
public sealed class QuestionVersion : Entity
public sealed class QuestionVersion : Entity, ITenantOwned
{
public Guid TenantId { get; set; }
public Guid QuestionId { get; set; }
@@ -11,6 +11,10 @@ public sealed class TenantDomain : AuditableTenantEntity
public bool IsPrimary { get; set; }
public string? VerificationToken { get; set; }
public DateTimeOffset? VerifiedAt { get; set; }
public DateTimeOffset? LastCheckedAt { get; set; }
public DateTimeOffset? DnsVerifiedAt { get; set; }
public DateTimeOffset? TlsReadyAt { get; set; }
public string? LastFailureReason { get; set; }
}
public enum TenantDomainType
@@ -28,7 +32,7 @@ public enum TenantDomainStatus
Disabled
}
public sealed class TenantBranding : IHasTimestamps
public sealed class TenantBranding : IHasTimestamps, ITenantOwned
{
public Guid TenantId { get; set; }
public string BrandName { get; set; } = string.Empty;
@@ -45,7 +49,7 @@ public sealed class TenantBranding : IHasTimestamps
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class TenantSettings : IHasTimestamps
public sealed class TenantSettings : IHasTimestamps, ITenantOwned
{
public Guid TenantId { get; set; }
public JsonElement FeatureFlags { get; set; } = JsonDefaults.Object();
@@ -54,3 +58,20 @@ public sealed class TenantSettings : IHasTimestamps
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class TenantFrontendConfig : AuditableTenantEntity
{
public int SchemaVersion { get; set; } = 1;
public int ConfigVersion { get; set; } = 1;
public JsonElement PublishedBranding { get; set; } = JsonDefaults.Object();
public JsonElement PublishedTheme { get; set; } = JsonDefaults.Object();
public JsonElement PublishedFeatures { get; set; } = JsonDefaults.Object();
public JsonElement PublishedNavigation { get; set; } = JsonDefaults.Array();
public JsonElement PublishedHomeModules { get; set; } = JsonDefaults.Array();
public JsonElement DraftBranding { get; set; } = JsonDefaults.Object();
public JsonElement DraftTheme { get; set; } = JsonDefaults.Object();
public JsonElement DraftFeatures { get; set; } = JsonDefaults.Object();
public JsonElement DraftNavigation { get; set; } = JsonDefaults.Array();
public JsonElement DraftHomeModules { get; set; } = JsonDefaults.Array();
public DateTimeOffset? PublishedAt { get; set; }
}
@@ -26,7 +26,7 @@ public sealed class TenantSecret : AuditableTenantEntity
public DateTimeOffset? ExpiresAt { get; set; }
}
public sealed class SmsVerificationCode : Entity
public sealed class SmsVerificationCode : Entity, ITenantOwned
{
public Guid TenantId { get; set; }
public string Phone { get; set; } = string.Empty;
@@ -43,7 +43,7 @@ public sealed class SmsVerificationCode : Entity
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class AuthLoginEvent : Entity
public sealed class AuthLoginEvent : Entity, ITenantOwned
{
public Guid TenantId { get; set; }
public Guid? UserId { get; set; }
@@ -69,7 +69,7 @@ public sealed class AuthSession : AuditableTenantEntity
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}
public sealed class SmsSendRateLimit
public sealed class SmsSendRateLimit : ITenantOwned
{
public Guid TenantId { get; set; }
public SmsRateLimitDimension Dimension { get; set; } = SmsRateLimitDimension.Phone;
+20 -2
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)
{
+26 -4
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);
@@ -0,0 +1,151 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.Catalog;
using Tiku.Application.QuestionBanks;
using Tiku.Application.Security;
using Tiku.Domain.Catalog;
using Tiku.Domain.Content;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Catalog;
public sealed class TaxonomyService(
TikuDbContext dbContext,
IPublicQuestionAccessPolicy accessPolicy,
ITenantExecutionScope tenantExecutionScope) : ITaxonomyService
{
public async Task<IReadOnlyCollection<TaxonomyNodeItem>> ListAsync(
Guid tenantId,
CancellationToken cancellationToken = default)
{
var includePlatform = true;
try
{
await accessPolicy.EnsureCanStartAsync(tenantId, cancellationToken);
}
catch (PublicQuestionAccessDeniedException)
{
includePlatform = false;
}
return await tenantExecutionScope.ExecuteAsync(
tenantId,
"List platform taxonomy with tenant extensions",
async (provider, token) =>
{
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
var platformTenantId = includePlatform
? await systemDbContext.Tenants.AsNoTracking()
.Where(tenant => tenant.Mode == TenantMode.PlatformOwned)
.Select(tenant => (Guid?)tenant.Id)
.SingleOrDefaultAsync(token)
: null;
return await systemDbContext.TaxonomyNodes.AsNoTracking()
.Where(node =>
node.IsActive &&
(node.TenantId == tenantId ||
(platformTenantId.HasValue && node.TenantId == platformTenantId.Value)))
.OrderBy(node => node.Depth)
.ThenBy(node => node.SortOrder)
.Select(node => new TaxonomyNodeItem(
node.Id,
node.TenantId == tenantId ? QuestionSource.Tenant : QuestionSource.Platform,
node.ParentId,
!node.ParentOwnerTenantId.HasValue
? null
: node.ParentOwnerTenantId == tenantId
? QuestionSource.Tenant
: QuestionSource.Platform,
node.NodeType,
node.Code,
node.Name,
node.Path,
node.Depth,
node.SortOrder,
node.Metadata))
.ToArrayAsync(token);
},
cancellationToken);
}
public async Task<TaxonomyNodeItem> CreateAsync(
Guid tenantId,
CreateTaxonomyNodeCommand command,
CancellationToken cancellationToken = default)
{
Guid? parentOwnerTenantId = null;
TaxonomyParent? parent = null;
if (command.ParentId.HasValue)
{
parentOwnerTenantId = command.ParentSource switch
{
QuestionSource.Tenant => tenantId,
QuestionSource.Platform => await ResolvePlatformTenantIdAsync(tenantId, cancellationToken),
_ => throw new InvalidOperationException("A parent source is required when parentId is provided.")
};
parent = await tenantExecutionScope.ExecuteAsync(
tenantId,
"Validate taxonomy extension parent ownership",
async (provider, token) =>
{
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
return await systemDbContext.TaxonomyNodes.AsNoTracking()
.Where(node =>
node.TenantId == parentOwnerTenantId &&
node.Id == command.ParentId.Value &&
node.IsActive)
.Select(node => new TaxonomyParent(node.Path, node.Depth))
.SingleOrDefaultAsync(token);
},
cancellationToken)
?? throw new InvalidOperationException("Taxonomy parent was not found.");
}
var node = new TaxonomyNode
{
TenantId = tenantId,
ParentOwnerTenantId = parentOwnerTenantId,
ParentId = command.ParentId,
NodeType = command.NodeType,
Code = command.Code.Trim(),
Name = command.Name.Trim(),
Depth = parent is null ? 0 : parent.Depth + 1,
SortOrder = command.SortOrder,
Metadata = command.Metadata.Clone()
};
node.Path = parent is null
? $"n{node.Id:N}"
: $"{parent.Path}.n{node.Id:N}";
dbContext.TaxonomyNodes.Add(node);
await dbContext.SaveChangesAsync(cancellationToken);
return new TaxonomyNodeItem(
node.Id,
QuestionSource.Tenant,
node.ParentId,
node.ParentId.HasValue ? command.ParentSource : null,
node.NodeType,
node.Code,
node.Name,
node.Path,
node.Depth,
node.SortOrder,
node.Metadata);
}
private async Task<Guid> ResolvePlatformTenantIdAsync(Guid tenantId, CancellationToken cancellationToken)
{
await accessPolicy.EnsureCanStartAsync(tenantId, cancellationToken);
return await tenantExecutionScope.ExecuteAsync(
tenantId,
"Resolve platform taxonomy owner",
async (provider, token) => await provider.GetRequiredService<TikuDbContext>()
.Tenants.AsNoTracking()
.Where(tenant => tenant.Mode == TenantMode.PlatformOwned)
.Select(tenant => tenant.Id)
.SingleAsync(token),
cancellationToken);
}
private sealed record TaxonomyParent(string? Path, int Depth);
}
@@ -3,6 +3,7 @@ using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Catalog;
using Tiku.Application.Content;
using Tiku.Application.QuestionBanks;
using Tiku.Domain.Catalog;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
@@ -11,7 +12,9 @@ using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Content;
public sealed class ContentManagementService(TikuDbContext dbContext) : IContentManagementService
public sealed class ContentManagementService(
TikuDbContext dbContext,
IQuestionReferenceService questionReferenceService) : IContentManagementService
{
private const int DefaultLimit = 100;
private const int MaxLimit = 1000;
@@ -358,15 +361,15 @@ public sealed class ContentManagementService(TikuDbContext dbContext) : IContent
throw new ContentManagementException("Collection was not found.", "collection_not_found");
}
var questionIds = command.Questions.Select(item => item.QuestionId).Distinct().ToArray();
var existingQuestions = await dbContext.Questions
.Where(question => question.TenantId == actor.TenantId && questionIds.Contains(question.Id))
.Select(question => question.Id)
.ToArrayAsync(cancellationToken);
if (existingQuestions.Length != questionIds.Length)
var resolvedQuestions = new List<(CollectionQuestionCommand Command, TenantQuestionReference Reference)>();
foreach (var question in command.Questions)
{
throw new ContentManagementException("One or more questions are not in this tenant.", "question_not_found");
var reference = await questionReferenceService.ResolveAsync(
actor.TenantId,
actor.UserId,
question.Locator,
cancellationToken);
resolvedQuestions.Add((question, reference));
}
var oldItems = await dbContext.QuestionCollectionItems
@@ -374,17 +377,19 @@ public sealed class ContentManagementService(TikuDbContext dbContext) : IContent
.ToArrayAsync(cancellationToken);
dbContext.QuestionCollectionItems.RemoveRange(oldItems);
var items = command.Questions
.Select((question, index) => new QuestionCollectionItem
var items = resolvedQuestions
.Select((resolved, index) => new QuestionCollectionItem
{
TenantId = actor.TenantId,
CollectionId = command.CollectionId,
QuestionId = question.QuestionId,
SectionKey = Normalize(question.SectionKey),
SortOrder = question.Order ?? index,
Score = question.Score,
Required = question.Required ?? true,
Metadata = JsonObjectOrDefault(question.Metadata)
QuestionReferenceId = resolved.Reference.Id,
QuestionOwnerTenantId = resolved.Reference.QuestionOwnerTenantId,
QuestionId = resolved.Reference.QuestionId,
SectionKey = Normalize(resolved.Command.SectionKey),
SortOrder = resolved.Command.Order ?? index,
Score = resolved.Command.Score,
Required = resolved.Command.Required ?? true,
Metadata = JsonObjectOrDefault(resolved.Command.Metadata)
})
.ToArray();
@@ -743,6 +748,9 @@ public sealed class ContentManagementService(TikuDbContext dbContext) : IContent
item.Id,
item.CollectionId,
item.QuestionId,
new QuestionLocator(
item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform,
item.QuestionId),
item.SectionKey,
item.SortOrder,
item.Score,
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
using Tiku.Application.Assets;
using Tiku.Application.Catalog;
using Tiku.Application.Content;
using Tiku.Application.QuestionBanks;
using Tiku.Domain.Catalog;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
@@ -14,7 +15,9 @@ using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Content;
public sealed class DirectContentService(TikuDbContext dbContext) : IDirectContentService
public sealed class DirectContentService(
TikuDbContext dbContext,
IQuestionReferenceService questionReferenceService) : IDirectContentService
{
private const int DefaultLimit = 100;
private const int MaxLimit = 1000;
@@ -1424,6 +1427,11 @@ public sealed class DirectContentService(TikuDbContext dbContext) : IDirectConte
cancellationToken);
if (existing is null)
{
var reference = await questionReferenceService.ResolveAsync(
actor.TenantId,
actor.UserId,
new QuestionLocator(QuestionSource.Tenant, question.Id),
cancellationToken);
var nextOrder = await dbContext.QuestionCollectionItems
.Where(item => item.TenantId == actor.TenantId && item.CollectionId == question.PrimaryCollectionId.Value)
.Select(item => (int?)item.SortOrder)
@@ -1432,6 +1440,8 @@ public sealed class DirectContentService(TikuDbContext dbContext) : IDirectConte
{
TenantId = actor.TenantId,
CollectionId = question.PrimaryCollectionId.Value,
QuestionReferenceId = reference.Id,
QuestionOwnerTenantId = reference.QuestionOwnerTenantId,
QuestionId = question.Id,
SortOrder = nextOrder + 1
});
+18 -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>();
@@ -1,6 +1,9 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System.Text.Json;
using Tiku.Application.Learning;
using Tiku.Application.QuestionBanks;
using Tiku.Application.Security;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.Learning;
@@ -9,7 +12,11 @@ using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Learning;
public sealed class LearningActivityService(TikuDbContext dbContext) : ILearningActivityService
public sealed class LearningActivityService(
TikuDbContext dbContext,
IQuestionReferenceService questionReferenceService,
IPublicQuestionAccessPolicy publicQuestionAccessPolicy,
ITenantExecutionScope tenantExecutionScope) : ILearningActivityService
{
private const int DefaultLimit = 100;
private const int MaxLimit = 500;
@@ -113,47 +120,36 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
SubmitAnswerCommand command,
CancellationToken cancellationToken = default)
{
var question = await dbContext.Questions
var now = DateTimeOffset.UtcNow;
var sessionQuestion = await dbContext.PracticeSessionQuestions
.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.Id == command.QuestionId &&
item.Status == QuestionStatus.Published)
.Select(item => new
{
item.Id,
item.CurrentVersionId
})
.SingleOrDefaultAsync(cancellationToken);
if (question is null)
{
throw new LearningResourceNotFoundException("question_not_found", "Question was not found.");
}
if (command.PracticeSessionId.HasValue)
{
var sessionExists = await dbContext.PracticeSessions.AnyAsync(
session =>
item.Id == command.SessionQuestionId)
.Join(
dbContext.PracticeSessions.AsNoTracking().Where(session =>
session.TenantId == actor.TenantId &&
session.UserId == actor.UserId &&
session.Id == command.PracticeSessionId.Value,
cancellationToken);
session.FinishedAt == null &&
(!session.ExpiresAt.HasValue || session.ExpiresAt > now)),
item => new { item.TenantId, Id = item.PracticeSessionId },
session => new { session.TenantId, session.Id },
(item, session) => item)
.SingleOrDefaultAsync(cancellationToken);
if (!sessionExists)
{
throw new LearningResourceNotFoundException("practice_session_not_found", "Practice session was not found.");
}
if (sessionQuestion is null)
{
throw new LearningResourceNotFoundException(
"session_question_not_found",
"An active practice session question was not found.");
}
var now = DateTimeOffset.UtcNow;
var record = new AnswerRecord
{
TenantId = actor.TenantId,
UserId = actor.UserId,
QuestionId = question.Id,
QuestionVersionId = question.CurrentVersionId,
PracticeSessionId = command.PracticeSessionId,
PracticeSessionId = sessionQuestion.PracticeSessionId,
SessionQuestionId = sessionQuestion.Id,
SelectedOptions = JsonSerializer.SerializeToElement(command.SelectedOptions ?? []),
AnswerText = command.AnswerText,
IsCorrect = command.SelfJudgedCorrect,
@@ -165,7 +161,7 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
if (command.SelfJudgedCorrect == false)
{
var wrongQuestion = await dbContext.WrongQuestions.FindAsync(
[actor.TenantId, actor.UserId, question.Id],
[actor.TenantId, actor.UserId, sessionQuestion.QuestionReferenceId],
cancellationToken);
if (wrongQuestion is null)
@@ -174,7 +170,9 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
{
TenantId = actor.TenantId,
UserId = actor.UserId,
QuestionId = question.Id,
QuestionReferenceId = sessionQuestion.QuestionReferenceId,
QuestionOwnerTenantId = sessionQuestion.QuestionOwnerTenantId,
QuestionId = sessionQuestion.QuestionId,
WrongCount = 1,
LastWrongAt = now
});
@@ -204,8 +202,10 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
.OrderByDescending(item => item.CreatedAt)
.Take(ResolveLimit(filter.Limit))
.Select(item => new FavoriteQuestionItem(
item.QuestionId,
item.Source,
item.QuestionReferenceId,
new QuestionLocator(
item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform,
item.QuestionId),
item.CreatedAt))
.ToArrayAsync(cancellationToken);
@@ -217,11 +217,15 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
QuestionActionCommand command,
CancellationToken cancellationToken = default)
{
await EnsureQuestionExistsAsync(actor.TenantId, command.QuestionId, cancellationToken);
var reference = await questionReferenceService.ResolveAsync(
actor.TenantId,
actor.UserId,
command.Locator,
cancellationToken);
var favorite = command.Favorite ?? true;
var item = await dbContext.FavoriteQuestions.FindAsync(
[actor.TenantId, actor.UserId, command.QuestionId],
[actor.TenantId, actor.UserId, reference.Id],
cancellationToken);
if (favorite)
@@ -232,8 +236,10 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
{
TenantId = actor.TenantId,
UserId = actor.UserId,
QuestionId = command.QuestionId,
Source = "api",
QuestionReferenceId = reference.Id,
QuestionOwnerTenantId = reference.QuestionOwnerTenantId,
QuestionId = reference.QuestionId,
Source = reference.Source.ToString().ToLowerInvariant(),
CreatedAt = DateTimeOffset.UtcNow
});
}
@@ -267,7 +273,10 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
.OrderByDescending(item => item.LastWrongAt)
.Take(ResolveLimit(filter.Limit))
.Select(item => new WrongQuestionItem(
item.QuestionId,
item.QuestionReferenceId,
new QuestionLocator(
item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform,
item.QuestionId),
item.WrongCount,
item.LastWrongAt,
item.ResolvedAt))
@@ -281,8 +290,13 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
QuestionActionCommand command,
CancellationToken cancellationToken = default)
{
var reference = await questionReferenceService.ResolveAsync(
actor.TenantId,
actor.UserId,
command.Locator,
cancellationToken);
var item = await dbContext.WrongQuestions.FindAsync(
[actor.TenantId, actor.UserId, command.QuestionId],
[actor.TenantId, actor.UserId, reference.Id],
cancellationToken);
if (item is null)
@@ -308,7 +322,13 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
.OrderByDescending(item => item.WrongCount)
.ThenBy(item => item.LastWrongAt)
.Take(ResolveLimit(filter.Limit))
.Select(item => new WrongQuestionReviewPlanItem(item.QuestionId, item.WrongCount, item.LastWrongAt))
.Select(item => new WrongQuestionReviewPlanItem(
item.QuestionReferenceId,
new QuestionLocator(
item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform,
item.QuestionId),
item.WrongCount,
item.LastWrongAt))
.ToArrayAsync(cancellationToken);
return new WrongQuestionReviewPlan(
items,
@@ -594,12 +614,24 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
CancellationToken cancellationToken = default)
{
var assembly = await BuildPracticeAssemblyAsync(actor.TenantId, command, cancellationToken);
var questionIds = await CollectQuestionIdsAsync(actor, assembly, cancellationToken);
if (questionIds.Count == 0)
var questionReferenceIds = await CollectQuestionReferenceIdsAsync(actor, assembly, cancellationToken);
if (questionReferenceIds.Count == 0)
{
throw new LearningValidationException("no_practice_questions", "No published questions are available for this practice target.");
}
var containsPlatformQuestion = await dbContext.TenantQuestionReferences.AsNoTracking().AnyAsync(
reference =>
reference.TenantId == actor.TenantId &&
questionReferenceIds.Contains(reference.Id) &&
reference.Source == QuestionSource.Platform,
cancellationToken);
if (containsPlatformQuestion)
{
await publicQuestionAccessPolicy.EnsureCanStartAsync(actor.TenantId, cancellationToken);
}
var now = DateTimeOffset.UtcNow;
var session = new PracticeSession
{
@@ -612,26 +644,46 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
CollectionId = assembly.CollectionId,
EntryId = assembly.EntryId,
ContentNodeId = assembly.ContentNodeId,
QuestionIds = JsonSerializer.SerializeToElement(questionIds),
QuestionCount = questionIds.Count,
QuestionCount = questionReferenceIds.Count,
DurationMinutes = assembly.DurationMinutes,
TotalScore = assembly.TotalScore,
ExpiresAt = assembly.DurationMinutes.HasValue
? now.AddMinutes(assembly.DurationMinutes.Value)
: null,
AccessMode = PracticeAccessMode.Free,
ConsumedFreeQuota = questionIds.Count,
ConsumedFreeQuota = questionReferenceIds.Count,
AccessSnapshot = JsonSerializer.SerializeToElement(new
{
strategy = "v1_free",
requestedCount = assembly.QuestionLimit,
grantedCount = questionIds.Count
grantedCount = questionReferenceIds.Count
}),
Metadata = command.Metadata.ValueKind is JsonValueKind.Undefined
? JsonDefaults.Object()
: command.Metadata
};
dbContext.PracticeSessions.Add(session);
await dbContext.SaveChangesAsync(cancellationToken);
var selections = await LoadQuestionSelectionsAsync(
actor.TenantId,
questionReferenceIds,
cancellationToken);
var scorePerQuestion = session.TotalScore.HasValue && selections.Count > 0
? session.TotalScore.Value / selections.Count
: (decimal?)null;
dbContext.PracticeSessionQuestions.AddRange(selections.Select((selection, index) =>
new PracticeSessionQuestion
{
TenantId = actor.TenantId,
PracticeSessionId = session.Id,
QuestionReferenceId = selection.QuestionReferenceId,
QuestionOwnerTenantId = selection.QuestionOwnerTenantId,
QuestionId = selection.QuestionId,
QuestionVersionId = selection.QuestionVersionId,
Position = index,
Score = scorePerQuestion
}));
dbContext.PracticeAccessEvents.Add(new PracticeAccessEvent
{
TenantId = actor.TenantId,
@@ -640,8 +692,8 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
EventType = PracticeAccessEventType.SessionCreated,
AccessMode = PracticeAccessEventMode.Free,
RequestedCount = assembly.QuestionLimit,
GrantedCount = questionIds.Count,
ConsumedFreeQuota = questionIds.Count,
GrantedCount = selections.Count,
ConsumedFreeQuota = selections.Count,
Metadata = session.AccessSnapshot
});
@@ -655,44 +707,20 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
CancellationToken cancellationToken = default)
{
var session = await GetPracticeSessionAsync(actor, filter.PracticeSessionId, cancellationToken);
var questionIds = ReadGuidArray(session.QuestionIds);
var questions = await dbContext.Questions
.AsNoTracking()
.Where(question =>
question.TenantId == actor.TenantId &&
questionIds.Contains(question.Id))
.GroupJoin(
dbContext.QuestionVersions.AsNoTracking(),
question => new { question.TenantId, QuestionId = question.Id, VersionId = question.CurrentVersionId },
version => new { version.TenantId, version.QuestionId, VersionId = (Guid?)version.Id },
(question, versions) => new { question, version = versions.FirstOrDefault() })
.Select(row => new PracticeSessionQuestionItem(
row.question.Id,
row.question.Type,
row.question.TypeLabel,
row.question.Difficulty,
row.question.Tags,
row.version == null ? null : row.version.Id,
row.version == null ? null : row.version.Content,
row.version == null ? JsonDefaults.Array() : row.version.Options,
row.version == null ? null : row.version.Explanation))
.ToArrayAsync(cancellationToken);
var questionById = questions.ToDictionary(question => question.Id);
var orderedQuestions = questionIds
.Where(questionById.ContainsKey)
.Select(questionId => questionById[questionId])
.ToArray();
var orderedQuestions = await LoadSessionQuestionItemsAsync(
actor.TenantId,
session.Id,
cancellationToken);
var answers = await dbContext.AnswerRecords
.AsNoTracking()
.Where(answer =>
answer.TenantId == actor.TenantId &&
answer.UserId == actor.UserId &&
answer.PracticeSessionId == session.Id &&
answer.QuestionId != null)
answer.PracticeSessionId == session.Id)
.ToArrayAsync(cancellationToken);
var answersByQuestion = answers
.GroupBy(answer => answer.QuestionId!.Value)
.GroupBy(answer => answer.SessionQuestionId)
.ToDictionary(
group => group.Key,
group => ToItem(group.OrderByDescending(answer => answer.AnsweredAt).First()));
@@ -885,7 +913,7 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
};
}
private async Task<List<Guid>> CollectQuestionIdsAsync(
private async Task<List<Guid>> CollectQuestionReferenceIdsAsync(
LearningActor actor,
PracticeAssembly assembly,
CancellationToken cancellationToken)
@@ -898,16 +926,10 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.ResolvedAt == null)
.Join(
dbContext.Questions.AsNoTracking(),
item => new { item.TenantId, item.QuestionId },
question => new { question.TenantId, QuestionId = question.Id },
(item, question) => new { item, question })
.Where(row => row.question.Status == QuestionStatus.Published)
.OrderByDescending(row => row.item.WrongCount)
.ThenBy(row => row.item.LastWrongAt)
.OrderByDescending(item => item.WrongCount)
.ThenBy(item => item.LastWrongAt)
.Take(assembly.QuestionLimit)
.Select(row => row.question.Id)
.Select(item => item.QuestionReferenceId)
.ToListAsync(cancellationToken);
}
@@ -918,15 +940,9 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId)
.Join(
dbContext.Questions.AsNoTracking(),
item => new { item.TenantId, item.QuestionId },
question => new { question.TenantId, QuestionId = question.Id },
(item, question) => new { item, question })
.Where(row => row.question.Status == QuestionStatus.Published)
.OrderByDescending(row => row.item.CreatedAt)
.OrderByDescending(item => item.CreatedAt)
.Take(assembly.QuestionLimit)
.Select(row => row.question.Id)
.Select(item => item.QuestionReferenceId)
.ToListAsync(cancellationToken);
}
@@ -937,16 +953,9 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
.Where(item =>
item.TenantId == actor.TenantId &&
item.CollectionId == assembly.CollectionId.Value)
.Join(
dbContext.Questions.AsNoTracking(),
item => new { item.TenantId, item.QuestionId },
question => new { question.TenantId, QuestionId = question.Id },
(item, question) => new { item, question })
.Where(row => row.question.Status == QuestionStatus.Published)
.OrderBy(row => row.item.SortOrder)
.ThenBy(row => row.question.CreatedAt)
.OrderBy(item => item.SortOrder)
.Take(assembly.QuestionLimit)
.Select(row => row.question.Id)
.Select(item => item.QuestionReferenceId)
.ToListAsync(cancellationToken);
}
@@ -973,11 +982,23 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
throw new LearningValidationException("practice_target_required", "Practice target is required.");
}
return await query
var questionIds = await query
.OrderBy(question => question.CreatedAt)
.Take(assembly.QuestionLimit)
.Select(question => question.Id)
.ToListAsync(cancellationToken);
var referenceIds = new List<Guid>(questionIds.Count);
foreach (var questionId in questionIds)
{
var reference = await questionReferenceService.ResolveAsync(
actor.TenantId,
actor.UserId,
new QuestionLocator(QuestionSource.Tenant, questionId),
cancellationToken);
referenceIds.Add(reference.Id);
}
return referenceIds;
}
private static IQueryable<Question> ApplyLegacyTargetFilter(
@@ -997,6 +1018,111 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
};
}
private async Task<IReadOnlyList<QuestionSelection>> LoadQuestionSelectionsAsync(
Guid tenantId,
IReadOnlyCollection<Guid> questionReferenceIds,
CancellationToken cancellationToken)
{
var rows = await tenantExecutionScope.ExecuteAsync(
tenantId,
"Lock published question versions for a new practice session",
async (provider, token) =>
{
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
return await (
from reference in systemDbContext.TenantQuestionReferences.AsNoTracking()
join question in systemDbContext.Questions.AsNoTracking()
on new { TenantId = reference.QuestionOwnerTenantId, Id = reference.QuestionId }
equals new { question.TenantId, question.Id }
join version in systemDbContext.QuestionVersions.AsNoTracking()
on new
{
TenantId = reference.QuestionOwnerTenantId,
reference.QuestionId,
Id = question.CurrentVersionId
}
equals new
{
version.TenantId,
version.QuestionId,
Id = (Guid?)version.Id
}
where reference.TenantId == tenantId &&
questionReferenceIds.Contains(reference.Id) &&
question.Status == QuestionStatus.Published
select new QuestionSelection(
reference.Id,
reference.QuestionOwnerTenantId,
reference.QuestionId,
version.Id))
.ToArrayAsync(token);
},
cancellationToken);
var byReference = rows.ToDictionary(row => row.QuestionReferenceId);
if (byReference.Count != questionReferenceIds.Distinct().Count())
{
throw new LearningValidationException(
"practice_question_unavailable",
"One or more practice questions have no published version.");
}
return questionReferenceIds.Select(referenceId => byReference[referenceId]).ToArray();
}
private Task<PracticeSessionQuestionItem[]> LoadSessionQuestionItemsAsync(
Guid tenantId,
Guid practiceSessionId,
CancellationToken cancellationToken)
{
return tenantExecutionScope.ExecuteAsync(
tenantId,
"Read locked question versions for a tenant practice session",
async (provider, token) =>
{
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
return await (
from sessionQuestion in systemDbContext.PracticeSessionQuestions.AsNoTracking()
join question in systemDbContext.Questions.AsNoTracking()
on new
{
TenantId = sessionQuestion.QuestionOwnerTenantId,
Id = sessionQuestion.QuestionId
}
equals new { question.TenantId, question.Id }
join version in systemDbContext.QuestionVersions.AsNoTracking()
on new
{
TenantId = sessionQuestion.QuestionOwnerTenantId,
sessionQuestion.QuestionId,
Id = sessionQuestion.QuestionVersionId
}
equals new { version.TenantId, version.QuestionId, version.Id }
where sessionQuestion.TenantId == tenantId &&
sessionQuestion.PracticeSessionId == practiceSessionId
orderby sessionQuestion.Position
select new PracticeSessionQuestionItem(
sessionQuestion.Id,
sessionQuestion.QuestionReferenceId,
new QuestionLocator(
sessionQuestion.QuestionOwnerTenantId == tenantId
? QuestionSource.Tenant
: QuestionSource.Platform,
sessionQuestion.QuestionId),
question.Id,
question.Type,
question.TypeLabel,
question.Difficulty,
question.Tags,
version.Id,
version.Content,
version.Options,
version.Explanation))
.ToArrayAsync(token);
},
cancellationToken);
}
private async Task<PracticeSession> GetPracticeSessionAsync(
LearningActor actor,
Guid? practiceSessionId,
@@ -1028,8 +1154,13 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
PracticeSession session,
CancellationToken cancellationToken)
{
var questionIds = ReadGuidArray(session.QuestionIds);
if (questionIds.Count == 0)
var sessionQuestions = await dbContext.PracticeSessionQuestions.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.PracticeSessionId == session.Id)
.OrderBy(item => item.Position)
.ToArrayAsync(cancellationToken);
if (sessionQuestions.Length == 0)
{
throw new LearningValidationException("practice_session_empty", "Practice session has no question snapshot.");
}
@@ -1039,21 +1170,20 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
.Where(answer =>
answer.TenantId == actor.TenantId &&
answer.UserId == actor.UserId &&
answer.PracticeSessionId == session.Id &&
answer.QuestionId != null)
answer.PracticeSessionId == session.Id)
.ToArrayAsync(cancellationToken);
var latestAnswers = answers
.GroupBy(answer => answer.QuestionId!.Value)
.GroupBy(answer => answer.SessionQuestionId)
.ToDictionary(
group => group.Key,
group => group.OrderByDescending(answer => answer.AnsweredAt).First());
var totalQuestions = questionIds.Count;
var answeredCount = questionIds.Count(latestAnswers.ContainsKey);
var correctCount = questionIds.Count(questionId =>
latestAnswers.TryGetValue(questionId, out var answer) &&
var totalQuestions = sessionQuestions.Length;
var answeredCount = sessionQuestions.Count(question => latestAnswers.ContainsKey(question.Id));
var correctCount = sessionQuestions.Count(question =>
latestAnswers.TryGetValue(question.Id, out var answer) &&
answer.IsCorrect == true);
var wrongCount = questionIds.Count(questionId =>
latestAnswers.TryGetValue(questionId, out var answer) &&
var wrongCount = sessionQuestions.Count(question =>
latestAnswers.TryGetValue(question.Id, out var answer) &&
answer.IsCorrect != true);
var unansweredCount = Math.Max(0, totalQuestions - answeredCount);
var totalScore = session.TotalScore ?? totalQuestions;
@@ -1062,18 +1192,22 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
var accuracy = totalQuestions == 0 ? 0 : Math.Round((decimal)correctCount / totalQuestions, 4);
var submittedAt = DateTimeOffset.UtcNow;
var durationSeconds = Math.Max(0, (int)(submittedAt - session.StartedAt).TotalSeconds);
var wrongQuestionIds = questionIds
.Where(questionId =>
latestAnswers.TryGetValue(questionId, out var answer) &&
var wrongQuestionIds = sessionQuestions
.Where(question =>
latestAnswers.TryGetValue(question.Id, out var answer) &&
answer.IsCorrect != true)
.Select(question => question.QuestionReferenceId)
.ToArray();
var questionResults = questionIds
.Select(questionId =>
var questionResults = sessionQuestions
.Select(question =>
{
latestAnswers.TryGetValue(questionId, out var answer);
latestAnswers.TryGetValue(question.Id, out var answer);
return new
{
questionId,
sessionQuestionId = question.Id,
questionReferenceId = question.QuestionReferenceId,
questionId = question.QuestionId,
source = question.QuestionOwnerTenantId == actor.TenantId ? "tenant" : "platform",
answered = answer is not null,
isCorrect = answer?.IsCorrect,
score = answer?.IsCorrect == true ? scorePerQuestion : 0,
@@ -1190,8 +1324,7 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
{
return new AnswerRecordItem(
record.Id,
record.QuestionId!.Value,
record.QuestionVersionId,
record.SessionQuestionId,
record.PracticeSessionId,
record.SelectedOptions,
record.AnswerText,
@@ -1226,7 +1359,6 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
item.CollectionId,
item.EntryId,
item.ContentNodeId,
item.QuestionIds,
item.QuestionCount,
item.DurationMinutes,
item.TotalScore,
@@ -1340,6 +1472,12 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
int QuestionLimit,
int? DurationMinutes,
decimal? TotalScore);
private sealed record QuestionSelection(
Guid QuestionReferenceId,
Guid QuestionOwnerTenantId,
Guid QuestionId,
Guid QuestionVersionId);
}
public class LearningException(string code, string message) : Exception(message)
@@ -182,7 +182,7 @@ internal sealed class ContentImportItemConfiguration : IEntityTypeConfiguration<
builder.Property(entity => entity.SourcePayload).IsJson("{}");
builder.Property(entity => entity.NormalizedPayload).IsJson("{}");
builder.Property(entity => entity.ContentHash).HasMaxLength(128);
builder.HasIndex(entity => new { entity.JobId, entity.RowNo }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.JobId, entity.RowNo }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.JobId, entity.Status, entity.RowNo });
builder.ToTable(table =>
{
@@ -134,7 +134,7 @@ internal sealed class PaymentConfiguration : IEntityTypeConfiguration<Payment>
builder.Property(entity => entity.RawPayload).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.OrderId, entity.UpdatedAt });
builder.HasIndex(entity => new { entity.Provider, entity.ProviderTradeNo })
builder.HasIndex(entity => new { entity.TenantId, entity.Provider, entity.ProviderTradeNo })
.IsUnique()
.HasFilter("provider_trade_no is not null");
builder.ToTable(table =>
@@ -160,7 +160,7 @@ internal sealed class PaymentEventConfiguration : IEntityTypeConfiguration<Payme
builder.Property(entity => entity.EventId).HasMaxLength(200);
builder.Property(entity => entity.Payload).IsJson("{}");
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new { entity.Provider, entity.EventId }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.Provider, entity.EventId }).IsUnique();
builder.HasOne<Tenant>().WithMany().HasForeignKey(entity => entity.TenantId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne<Payment>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.PaymentId })
@@ -438,7 +438,7 @@ internal sealed class CommerceReconciliationItemConfiguration : IEntityTypeConfi
builder.Property(entity => entity.IssueCode).HasMaxLength(100);
builder.Property(entity => entity.Details).IsJson("{}");
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new { entity.BatchId, entity.RowNo }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.BatchId, entity.RowNo }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.BatchId, entity.MatchStatus, entity.RowNo });
builder.HasIndex(entity => new { entity.TenantId, entity.OrderNo, entity.CreatedAt });
builder.HasIndex(entity => new { entity.TenantId, entity.RefundNo, entity.ProviderRefundNo, entity.CreatedAt });
@@ -180,7 +180,7 @@ internal sealed class QuestionCollectionItemConfiguration :
{
entity.TenantId,
entity.CollectionId,
entity.QuestionId
entity.QuestionReferenceId
}).IsUnique();
builder.HasIndex(entity => new
{
@@ -194,10 +194,14 @@ internal sealed class QuestionCollectionItemConfiguration :
.HasForeignKey(entity => new { entity.TenantId, entity.CollectionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<Question>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
builder.HasOne<TenantQuestionReference>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionReferenceId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<Question>().WithMany()
.HasForeignKey(entity => new { entity.QuestionOwnerTenantId, entity.QuestionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -65,72 +65,48 @@ internal sealed class ContentAssetSecurityScanEventConfiguration : IEntityTypeCo
}
}
internal sealed class QuestionBankGrantConfiguration : IEntityTypeConfiguration<QuestionBankGrant>
internal sealed class TenantQuestionBankPreferenceConfiguration : IEntityTypeConfiguration<TenantQuestionBankPreference>
{
public void Configure(EntityTypeBuilder<QuestionBankGrant> builder)
public void Configure(EntityTypeBuilder<TenantQuestionBankPreference> builder)
{
builder.ConfigureEntity("question_bank_grants");
builder.ConfigureTenantEntity("tenant_question_bank_preferences");
builder.ConfigureTimestamps();
builder.Property(entity => entity.GrantScope).HasSnakeCaseEnum();
builder.Property(entity => entity.AllowedPlanCodes)
.HasColumnType("text[]")
.HasDefaultValueSql("'{}'::text[]");
builder.Property(entity => entity.AllowedTenantIds)
.HasColumnType("uuid[]")
.HasDefaultValueSql("'{}'::uuid[]");
builder.Property(entity => entity.AllowedRegionIds)
.HasColumnType("uuid[]")
.HasDefaultValueSql("'{}'::uuid[]");
builder.Property(entity => entity.AllowedSubjectIds)
.HasColumnType("uuid[]")
.HasDefaultValueSql("'{}'::uuid[]");
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.Alias).HasMaxLength(300);
builder.Property(entity => entity.NavigationLocation).HasMaxLength(100);
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.SourceQuestionBankId, entity.Status, entity.StartsAt, entity.ExpiresAt });
builder.HasIndex(entity => entity.AllowedTenantIds).HasMethod("gin");
builder.HasIndex(entity => entity.AllowedPlanCodes).HasMethod("gin");
builder.HasIndex(entity => new
{
entity.TenantId,
entity.QuestionBankOwnerTenantId,
entity.QuestionBankId
}).IsUnique();
builder.HasOne<QuestionBank>().WithMany()
.HasForeignKey(entity => entity.SourceQuestionBankId)
.OnDelete(DeleteBehavior.Cascade);
.HasForeignKey(entity => new { entity.QuestionBankOwnerTenantId, entity.QuestionBankId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.CreatedBy).OnDelete(DeleteBehavior.SetNull);
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.UpdatedBy).OnDelete(DeleteBehavior.SetNull);
}
}
internal sealed class TenantQuestionBankAdoptionConfiguration : IEntityTypeConfiguration<TenantQuestionBankAdoption>
internal sealed class TenantQuestionReferenceConfiguration : IEntityTypeConfiguration<TenantQuestionReference>
{
public void Configure(EntityTypeBuilder<TenantQuestionBankAdoption> builder)
public void Configure(EntityTypeBuilder<TenantQuestionReference> builder)
{
builder.ConfigureTenantEntity("tenant_question_bank_adoptions");
builder.ConfigureTenantEntity("tenant_question_references");
builder.ConfigureTimestamps();
builder.Property(entity => entity.AdoptionMode).HasSnakeCaseEnum();
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.SyncStatus).HasSnakeCaseEnum();
builder.Property(entity => entity.SourceSnapshot).IsJson("{}");
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.SourceQuestionBankId }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.UpdatedAt });
builder.ToTable(table => table.HasCheckConstraint("ck_tenant_question_bank_adoptions_copied_count", "copied_question_count >= 0"));
builder.HasOne<QuestionBank>().WithMany()
.HasForeignKey(entity => entity.SourceQuestionBankId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<QuestionBankGrant>().WithMany()
.HasForeignKey(entity => entity.GrantId)
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne<QuestionBank>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.TargetQuestionBankId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<ContentEntry>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.TargetEntryId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<QuestionCollection>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.TargetCollectionId })
builder.Property(entity => entity.Source).HasSnakeCaseEnum();
builder.HasAlternateKey(entity => new
{
entity.TenantId,
entity.QuestionOwnerTenantId,
entity.QuestionId
});
builder.HasOne<Question>().WithMany()
.HasForeignKey(entity => new { entity.QuestionOwnerTenantId, entity.QuestionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.CreatedBy).OnDelete(DeleteBehavior.SetNull);
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.UpdatedBy).OnDelete(DeleteBehavior.SetNull);
}
}
@@ -17,7 +17,6 @@ internal sealed class PracticeSessionConfiguration : IEntityTypeConfiguration<Pr
builder.Property(entity => entity.Mode).HasMaxLength(50);
builder.Property(entity => entity.TargetType).HasMaxLength(50);
builder.Property(entity => entity.StartedAt).HasDefaultValueSql("now()");
builder.Property(entity => entity.QuestionIds).IsJson("[]");
builder.Property(entity => entity.TotalScore).HasPrecision(8, 2);
builder.Property(entity => entity.AccessMode)
.HasSnakeCaseEnum()
@@ -55,6 +54,45 @@ internal sealed class PracticeSessionConfiguration : IEntityTypeConfiguration<Pr
}
}
internal sealed class PracticeSessionQuestionConfiguration : IEntityTypeConfiguration<PracticeSessionQuestion>
{
public void Configure(EntityTypeBuilder<PracticeSessionQuestion> builder)
{
builder.ConfigureTenantEntity("practice_session_questions");
builder.HasAlternateKey(entity => new { entity.TenantId, entity.PracticeSessionId, entity.Id });
builder.HasIndex(entity => new { entity.TenantId, entity.PracticeSessionId, entity.Position }).IsUnique();
builder.Property(entity => entity.Score).HasPrecision(8, 2);
builder.HasOne<PracticeSession>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.PracticeSessionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<TenantQuestionReference>().WithMany()
.HasForeignKey(entity => new
{
entity.TenantId,
entity.QuestionOwnerTenantId,
entity.QuestionId
})
.HasPrincipalKey(entity => new
{
entity.TenantId,
entity.QuestionOwnerTenantId,
entity.QuestionId
})
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<QuestionVersion>().WithMany()
.HasForeignKey(entity => new
{
TenantId = entity.QuestionOwnerTenantId,
entity.QuestionId,
Id = entity.QuestionVersionId
})
.HasPrincipalKey(entity => new { entity.TenantId, entity.QuestionId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class AnswerRecordConfiguration : IEntityTypeConfiguration<AnswerRecord>
{
public void Configure(EntityTypeBuilder<AnswerRecord> builder)
@@ -67,13 +105,6 @@ internal sealed class AnswerRecordConfiguration : IEntityTypeConfiguration<Answe
builder.Property(entity => entity.AnsweredAt).HasDefaultValueSql("now()");
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.QuestionId });
builder.HasIndex(entity => new
{
entity.TenantId,
entity.QuestionId,
entity.QuestionVersionId
});
builder.HasIndex(entity => new
{
entity.TenantId,
@@ -81,31 +112,9 @@ internal sealed class AnswerRecordConfiguration : IEntityTypeConfiguration<Answe
entity.PracticeSessionId
});
builder.ToTable(table => table.HasCheckConstraint(
"ck_answer_records_version_requires_question",
"question_version_id is null or question_id is not null"));
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<Question>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<QuestionVersion>().WithMany()
.HasForeignKey(entity => new
{
entity.TenantId,
entity.QuestionId,
entity.QuestionVersionId
})
.HasPrincipalKey(entity => new
{
entity.TenantId,
entity.QuestionId,
entity.Id
})
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<PracticeSession>().WithMany()
.HasForeignKey(entity => new
{
@@ -120,6 +129,20 @@ internal sealed class AnswerRecordConfiguration : IEntityTypeConfiguration<Answe
entity.Id
})
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<PracticeSessionQuestion>().WithMany()
.HasForeignKey(entity => new
{
entity.TenantId,
entity.PracticeSessionId,
Id = entity.SessionQuestionId
})
.HasPrincipalKey(entity => new
{
entity.TenantId,
entity.PracticeSessionId,
entity.Id
})
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -128,7 +151,7 @@ internal sealed class FavoriteQuestionConfiguration : IEntityTypeConfiguration<F
public void Configure(EntityTypeBuilder<FavoriteQuestion> builder)
{
builder.ToTable("favorite_questions");
builder.HasKey(entity => new { entity.TenantId, entity.UserId, entity.QuestionId });
builder.HasKey(entity => new { entity.TenantId, entity.UserId, entity.QuestionReferenceId });
builder.Property(entity => entity.Source).HasMaxLength(50);
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
@@ -138,10 +161,14 @@ internal sealed class FavoriteQuestionConfiguration : IEntityTypeConfiguration<F
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<Question>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
builder.HasOne<TenantQuestionReference>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionReferenceId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<Question>().WithMany()
.HasForeignKey(entity => new { entity.QuestionOwnerTenantId, entity.QuestionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -150,7 +177,7 @@ internal sealed class WrongQuestionConfiguration : IEntityTypeConfiguration<Wron
public void Configure(EntityTypeBuilder<WrongQuestion> builder)
{
builder.ToTable("wrong_questions");
builder.HasKey(entity => new { entity.TenantId, entity.UserId, entity.QuestionId });
builder.HasKey(entity => new { entity.TenantId, entity.UserId, entity.QuestionReferenceId });
builder.Property(entity => entity.WrongCount).HasDefaultValue(1);
builder.Property(entity => entity.LastWrongAt).HasDefaultValueSql("now()");
@@ -160,10 +187,14 @@ internal sealed class WrongQuestionConfiguration : IEntityTypeConfiguration<Wron
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<Question>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
builder.HasOne<TenantQuestionReference>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionReferenceId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<Question>().WithMany()
.HasForeignKey(entity => new { entity.QuestionOwnerTenantId, entity.QuestionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -59,7 +59,7 @@ internal sealed class TenantInvoiceConfiguration : IEntityTypeConfiguration<Tena
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.Currency).HasMaxLength(10);
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => entity.InvoiceNo).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.InvoiceNo }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.DueDate });
builder.HasIndex(entity => new { entity.TenantId, entity.BillingPeriodStart, entity.BillingPeriodEnd })
.IsUnique()
@@ -110,7 +110,7 @@ internal sealed class TenantInvoicePaymentConfiguration : IEntityTypeConfigurati
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.ProviderTradeNo).HasMaxLength(200);
builder.Property(entity => entity.RawPayload).IsJson("{}");
builder.HasIndex(entity => entity.PaymentNo).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.PaymentNo }).IsUnique();
builder.HasIndex(entity => new { entity.InvoiceId, entity.Status });
builder.ToTable(table => table.HasCheckConstraint("ck_tenant_invoice_payments_amount", "amount_cents >= 0"));
builder.HasOne<TenantInvoice>().WithMany()
@@ -229,7 +229,7 @@ internal sealed class PlatformDunningNotificationEventConfiguration : IEntityTyp
builder.Property(entity => entity.RequestPayload).IsJson("{}");
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.Property(entity => entity.ScheduledAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new { entity.ChannelId, entity.ReminderId }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.ChannelId, entity.ReminderId }).IsUnique();
builder.HasIndex(entity => new { entity.Status, entity.NextAttemptAt, entity.ScheduledAt, entity.CreatedAt });
builder.HasIndex(entity => new { entity.ReminderId, entity.Status, entity.CreatedAt });
builder.HasIndex(entity => new { entity.InvoiceId, entity.Status, entity.CreatedAt });
@@ -14,7 +14,6 @@ internal sealed class QuestionBankConfiguration : IEntityTypeConfiguration<Quest
builder.ConfigureTenantEntity("question_banks");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Name).HasMaxLength(300);
builder.Property(entity => entity.SourceScope).HasSnakeCaseEnum();
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.Metadata).IsJson("{}");
@@ -94,7 +93,7 @@ internal sealed class QuestionVersionConfiguration : IEntityTypeConfiguration<Qu
builder.Property(entity => entity.SourceHash).HasMaxLength(128);
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new { entity.QuestionId, entity.VersionNo }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.QuestionId, entity.VersionNo }).IsUnique();
builder.HasOne<Question>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
@@ -0,0 +1,53 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tiku.Domain.Catalog;
using Tiku.Domain.QuestionBanks;
namespace Tiku.Infrastructure.Persistence.Configurations;
internal sealed class TaxonomyNodeConfiguration : IEntityTypeConfiguration<TaxonomyNode>
{
public void Configure(EntityTypeBuilder<TaxonomyNode> builder)
{
builder.ConfigureTenantEntity("taxonomy_nodes");
builder.ConfigureTimestamps();
builder.Property(entity => entity.NodeType).HasSnakeCaseEnum();
builder.Property(entity => entity.Code).HasMaxLength(100);
builder.Property(entity => entity.Name).HasMaxLength(300);
builder.Property(entity => entity.Path).HasColumnType("ltree");
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.Code }).IsUnique();
builder.HasOne<TaxonomyNode>().WithMany()
.HasForeignKey(entity => new { TenantId = entity.ParentOwnerTenantId, Id = entity.ParentId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.ToTable(table => table.HasCheckConstraint(
"ck_taxonomy_nodes_parent_pair",
"(parent_owner_tenant_id is null) = (parent_id is null)"));
}
}
internal sealed class QuestionTaxonomyAssignmentConfiguration : IEntityTypeConfiguration<QuestionTaxonomyAssignment>
{
public void Configure(EntityTypeBuilder<QuestionTaxonomyAssignment> builder)
{
builder.ConfigureEntity("question_taxonomy_assignments");
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
builder.HasIndex(entity => new
{
entity.TenantId,
entity.QuestionId,
entity.TaxonomyOwnerTenantId,
entity.TaxonomyNodeId
}).IsUnique();
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasOne<Question>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<TaxonomyNode>().WithMany()
.HasForeignKey(entity => new { TenantId = entity.TaxonomyOwnerTenantId, Id = entity.TaxonomyNodeId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -23,6 +23,9 @@ internal sealed class TenantConfiguration : IEntityTypeConfiguration<Tenant>
builder.HasIndex(entity => entity.Slug).IsUnique();
builder.HasIndex(entity => entity.LegacyId).IsUnique();
builder.HasIndex(entity => entity.Mode)
.IsUnique()
.HasFilter("mode = 'platform_owned'");
builder.HasOne<User>()
.WithMany()
@@ -67,7 +70,10 @@ internal sealed class TenantDomainConfiguration : IEntityTypeConfiguration<Tenan
builder.Property(entity => entity.DomainType).HasSnakeCaseEnum();
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.VerificationToken).HasMaxLength(256);
builder.HasIndex(entity => entity.Host).IsUnique();
builder.Property(entity => entity.LastFailureReason).HasMaxLength(2000);
builder.HasIndex(entity => entity.Host)
.IsUnique()
.HasAnnotation("Tiku:GlobalUnique", true);
builder.HasIndex(entity => new { entity.TenantId, entity.IsPrimary })
.IsUnique()
.HasFilter("is_primary");
@@ -114,3 +120,29 @@ internal sealed class TenantSettingsConfiguration : IEntityTypeConfiguration<Ten
.OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class TenantFrontendConfigConfiguration : IEntityTypeConfiguration<TenantFrontendConfig>
{
public void Configure(EntityTypeBuilder<TenantFrontendConfig> builder)
{
builder.ConfigureTenantEntity("tenant_frontend_configs");
builder.ConfigureTimestamps();
builder.HasIndex(entity => entity.TenantId).IsUnique();
builder.Property(entity => entity.ConfigVersion).IsConcurrencyToken();
builder.Property(entity => entity.PublishedBranding).IsJson("{}");
builder.Property(entity => entity.PublishedTheme).IsJson("{}");
builder.Property(entity => entity.PublishedFeatures).IsJson("{}");
builder.Property(entity => entity.PublishedNavigation).IsJson("[]");
builder.Property(entity => entity.PublishedHomeModules).IsJson("[]");
builder.Property(entity => entity.DraftBranding).IsJson("{}");
builder.Property(entity => entity.DraftTheme).IsJson("{}");
builder.Property(entity => entity.DraftFeatures).IsJson("{}");
builder.Property(entity => entity.DraftNavigation).IsJson("[]");
builder.Property(entity => entity.DraftHomeModules).IsJson("[]");
builder.ToTable(table =>
{
table.HasCheckConstraint("ck_tenant_frontend_configs_schema_version", "schema_version > 0");
table.HasCheckConstraint("ck_tenant_frontend_configs_config_version", "config_version > 0");
});
}
}
@@ -111,7 +111,9 @@ internal sealed class AuthSessionConfiguration : IEntityTypeConfiguration<AuthSe
builder.Property(entity => entity.IpAddress).HasMaxLength(64);
builder.Property(entity => entity.UserAgent).HasMaxLength(1000);
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => entity.TokenHash).IsUnique();
builder.HasIndex(entity => entity.TokenHash)
.IsUnique()
.HasAnnotation("Tiku:GlobalUnique", true);
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.ExpiresAt })
.HasFilter("revoked_at is null");
File diff suppressed because it is too large Load Diff
@@ -1,47 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddProductActiveAndOperationsCatalog : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "ix_products_tenant_id_region_id_type_sort_order",
table: "products");
migrationBuilder.AddColumn<bool>(
name: "is_active",
table: "products",
type: "boolean",
nullable: false,
defaultValue: true);
migrationBuilder.CreateIndex(
name: "ix_products_tenant_id_region_id_type_is_active_sort_order",
table: "products",
columns: new[] { "tenant_id", "region_id", "type", "is_active", "sort_order" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "ix_products_tenant_id_region_id_type_is_active_sort_order",
table: "products");
migrationBuilder.DropColumn(
name: "is_active",
table: "products");
migrationBuilder.CreateIndex(
name: "ix_products_tenant_id_region_id_type_sort_order",
table: "products",
columns: new[] { "tenant_id", "region_id", "type", "sort_order" });
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,157 +0,0 @@
using System;
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddScorelineDynamicRecords : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "scoreline_fields",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
region_id = table.Column<Guid>(type: "uuid", nullable: true),
legacy_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
field_key = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
field_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
field_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
unit = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
is_filter = table.Column<bool>(type: "boolean", nullable: false),
is_required = table.Column<bool>(type: "boolean", nullable: false),
is_visible = table.Column<bool>(type: "boolean", nullable: false),
is_trend = table.Column<bool>(type: "boolean", nullable: false),
options = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'[]'::jsonb"),
placeholder = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
sort_order = table.Column<int>(type: "integer", nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_scoreline_fields", x => x.id);
table.UniqueConstraint("ak_scoreline_fields_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_scoreline_fields_regions_tenant_id_region_id",
columns: x => new { x.tenant_id, x.region_id },
principalTable: "regions",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_scoreline_fields_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "scoreline_records",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
region_id = table.Column<Guid>(type: "uuid", nullable: true),
school_id = table.Column<Guid>(type: "uuid", nullable: true),
major_id = table.Column<Guid>(type: "uuid", nullable: true),
legacy_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
year = table.Column<int>(type: "integer", nullable: false),
school_name = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
major_name = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
field_values = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_scoreline_records", x => x.id);
table.UniqueConstraint("ak_scoreline_records_tenant_id_id", x => new { x.tenant_id, x.id });
table.ForeignKey(
name: "fk_scoreline_records_majors_tenant_id_major_id",
columns: x => new { x.tenant_id, x.major_id },
principalTable: "majors",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_scoreline_records_regions_tenant_id_region_id",
columns: x => new { x.tenant_id, x.region_id },
principalTable: "regions",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_scoreline_records_schools_tenant_id_school_id",
columns: x => new { x.tenant_id, x.school_id },
principalTable: "schools",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "fk_scoreline_records_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_scoreline_fields_tenant_id_legacy_id",
table: "scoreline_fields",
columns: new[] { "tenant_id", "legacy_id" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_scoreline_fields_tenant_id_region_id_field_key",
table: "scoreline_fields",
columns: new[] { "tenant_id", "region_id", "field_key" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_scoreline_fields_tenant_id_region_id_is_filter_sort_order",
table: "scoreline_fields",
columns: new[] { "tenant_id", "region_id", "is_filter", "sort_order" });
migrationBuilder.CreateIndex(
name: "ix_scoreline_records_tenant_id_legacy_id",
table: "scoreline_records",
columns: new[] { "tenant_id", "legacy_id" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_scoreline_records_tenant_id_major_id",
table: "scoreline_records",
columns: new[] { "tenant_id", "major_id" });
migrationBuilder.CreateIndex(
name: "ix_scoreline_records_tenant_id_region_id_school_id_major_id_ye~",
table: "scoreline_records",
columns: new[] { "tenant_id", "region_id", "school_id", "major_id", "year" });
migrationBuilder.CreateIndex(
name: "ix_scoreline_records_tenant_id_school_id",
table: "scoreline_records",
columns: new[] { "tenant_id", "school_id" });
migrationBuilder.CreateIndex(
name: "ix_scoreline_records_tenant_id_year",
table: "scoreline_records",
columns: new[] { "tenant_id", "year" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "scoreline_fields");
migrationBuilder.DropTable(
name: "scoreline_records");
}
}
}
@@ -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");
}
}
}
@@ -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");
}
}
}
@@ -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");
}
}
}
@@ -1,157 +0,0 @@
using System;
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddCommissionSettlementProofs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "commission_settlement_export_events",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
settlement_id = table.Column<Guid>(type: "uuid", nullable: false),
exported_by = table.Column<Guid>(type: "uuid", nullable: true),
export_format = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
filename = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
row_count = table.Column<int>(type: "integer", nullable: false),
content_sha256 = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_commission_settlement_export_events", x => x.id);
table.CheckConstraint("ck_commission_export_events_rows", "row_count >= 0");
table.ForeignKey(
name: "fk_commission_settlement_export_events_commission_settlements_~",
columns: x => new { x.tenant_id, x.settlement_id },
principalTable: "commission_settlements",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_commission_settlement_export_events_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_commission_settlement_export_events_users_exported_by",
column: x => x.exported_by,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "commission_settlement_proofs",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
settlement_id = table.Column<Guid>(type: "uuid", nullable: false),
asset_id = table.Column<Guid>(type: "uuid", nullable: true),
submitted_by = table.Column<Guid>(type: "uuid", nullable: true),
reviewed_by = table.Column<Guid>(type: "uuid", nullable: true),
proof_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
description = table.Column<string>(type: "text", nullable: true),
external_url = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
amount_cents = table.Column<int>(type: "integer", nullable: true),
payment_method = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
payment_account = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
paid_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
reviewed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
review_note = table.Column<string>(type: "text", nullable: true),
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_commission_settlement_proofs", x => x.id);
table.UniqueConstraint("ak_commission_settlement_proofs_tenant_id_id", x => new { x.tenant_id, x.id });
table.CheckConstraint("ck_commission_settlement_proofs_amount", "amount_cents is null or amount_cents >= 0");
table.ForeignKey(
name: "fk_commission_settlement_proofs_commission_settlements_tenant_~",
columns: x => new { x.tenant_id, x.settlement_id },
principalTable: "commission_settlements",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_commission_settlement_proofs_content_assets_tenant_id_asset~",
columns: x => new { x.tenant_id, x.asset_id },
principalTable: "content_assets",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_commission_settlement_proofs_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_commission_settlement_proofs_users_reviewed_by",
column: x => x.reviewed_by,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_commission_settlement_proofs_users_submitted_by",
column: x => x.submitted_by,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "ix_commission_settlement_export_events_exported_by",
table: "commission_settlement_export_events",
column: "exported_by");
migrationBuilder.CreateIndex(
name: "ix_commission_settlement_export_events_tenant_id_settlement_id~",
table: "commission_settlement_export_events",
columns: new[] { "tenant_id", "settlement_id", "created_at" });
migrationBuilder.CreateIndex(
name: "ix_commission_settlement_proofs_reviewed_by",
table: "commission_settlement_proofs",
column: "reviewed_by");
migrationBuilder.CreateIndex(
name: "ix_commission_settlement_proofs_submitted_by",
table: "commission_settlement_proofs",
column: "submitted_by");
migrationBuilder.CreateIndex(
name: "ix_commission_settlement_proofs_tenant_id_asset_id",
table: "commission_settlement_proofs",
columns: new[] { "tenant_id", "asset_id" });
migrationBuilder.CreateIndex(
name: "ix_commission_settlement_proofs_tenant_id_settlement_id_status~",
table: "commission_settlement_proofs",
columns: new[] { "tenant_id", "settlement_id", "status", "created_at" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "commission_settlement_export_events");
migrationBuilder.DropTable(
name: "commission_settlement_proofs");
}
}
}
@@ -1,84 +0,0 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class EncryptTenantSecretPayloads : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "secret_payload",
table: "tenant_secrets");
migrationBuilder.AddColumn<byte[]>(
name: "encrypted_payload",
table: "tenant_secrets",
type: "bytea",
nullable: false,
defaultValue: new byte[0]);
migrationBuilder.AddColumn<string>(
name: "encryption_key_id",
table: "tenant_secrets",
type: "character varying(100)",
maxLength: 100,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<byte[]>(
name: "encryption_nonce",
table: "tenant_secrets",
type: "bytea",
nullable: false,
defaultValue: new byte[0]);
migrationBuilder.AddColumn<byte[]>(
name: "encryption_tag",
table: "tenant_secrets",
type: "bytea",
nullable: false,
defaultValue: new byte[0]);
migrationBuilder.AddCheckConstraint(
name: "ck_tenant_secrets_encryption_envelope",
table: "tenant_secrets",
sql: "octet_length(encrypted_payload) > 0 and octet_length(encryption_nonce) = 12 and octet_length(encryption_tag) = 16 and encryption_key_id <> ''");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropCheckConstraint(
name: "ck_tenant_secrets_encryption_envelope",
table: "tenant_secrets");
migrationBuilder.DropColumn(
name: "encrypted_payload",
table: "tenant_secrets");
migrationBuilder.DropColumn(
name: "encryption_key_id",
table: "tenant_secrets");
migrationBuilder.DropColumn(
name: "encryption_nonce",
table: "tenant_secrets");
migrationBuilder.DropColumn(
name: "encryption_tag",
table: "tenant_secrets");
migrationBuilder.AddColumn<JsonElement>(
name: "secret_payload",
table: "tenant_secrets",
type: "jsonb",
nullable: false,
defaultValueSql: "'{}'::jsonb");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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}");

Some files were not shown because too many files have changed in this diff Show More