From df88fa19cb05382c7d99d89543a239086ab83a4c Mon Sep 17 00:00:00 2001 From: xiong Date: Wed, 29 Jul 2026 10:40:10 +0800 Subject: [PATCH] feat(security): add distributed authorization foundation --- Directory.Packages.props | 5 + TIKU-BACKEND.slnx | 1 + .../ApiPresentationExtensions.cs | 4 +- .../ApplicationBuilderExtensions.cs | 1 + .../Configuration/AuthenticationExtensions.cs | 39 + Tiku.Api/Configuration/DependencyInjection.cs | 41 +- .../NetworkConfigurationExtensions.cs | 9 +- Tiku.Api/Controllers/AuthController.cs | 7 + .../Controllers/BackgroundJobsController.cs | 2 + Tiku.Api/Controllers/BackofficeController.cs | 12 + Tiku.Api/Controllers/BrowserAuthController.cs | 251 + Tiku.Api/Controllers/CommissionController.cs | 12 + Tiku.Api/Controllers/HealthController.cs | 38 +- Tiku.Api/Controllers/MeController.cs | 2 + .../SecurityDiagnosticsController.cs | 3 + Tiku.Api/Controllers/TaxonomyController.cs | 2 + .../TenantFrontendConfigController.cs | 3 + Tiku.Api/Controllers/TenantsController.cs | 2 + .../AuthRateLimitPartitionMiddleware.cs | 74 +- Tiku.Api/Middleware/BrowserCsrfMiddleware.cs | 68 + .../Middleware/ExceptionHandlingMiddleware.cs | 9 +- Tiku.Api/Options/BrowserAuthOptions.cs | 12 + Tiku.Api/Options/OptionsValidation.cs | 27 + .../AccessAuthorizationRequirements.cs | 41 +- .../Security/EndpointAuthorizationMetadata.cs | 79 + Tiku.Api/appsettings.json | 12 + Tiku.Application/Auth/AuthExceptions.cs | 3 + .../Security/ICapabilityAccessEvaluator.cs | 21 + .../Security/IRedisSecurityStore.cs | 23 + .../Security/ITenantExecutionScope.cs | 23 +- Tiku.Contracts/SecurityEvents.cs | 36 + Tiku.Contracts/Tiku.Contracts.csproj | 7 + Tiku.Domain/Commerce/CommerceEntities.cs | 2 +- .../Platform/PlatformOperationsEntities.cs | 26 + .../Tenancy/TenantConfigurationEntities.cs | 8 + Tiku.Infrastructure/Auth/AuthService.cs | 35 +- .../Auth/SmsVerificationService.cs | 67 + .../Backoffice/BackofficeService.cs | 57 +- .../Catalog/TaxonomyService.cs | 15 +- .../Content/DirectContentService.cs | 9 +- Tiku.Infrastructure/DependencyInjection.cs | 80 + .../Jobs/BackgroundJobService.cs | 41 +- .../Learning/LearningActivityService.cs | 10 +- .../Messaging/MessagingOptions.cs | 13 + .../Messaging/SecurityEventPublisher.cs | 70 + .../Messaging/SecurityStateChangedConsumer.cs | 26 + .../PlatformOperationsConfigurations.cs | 50 + .../Configurations/TenancyConfigurations.cs | 14 + ...dDistributedSecurityFoundation.Designer.cs | 18569 ++++++++++++++++ ...012205_AddDistributedSecurityFoundation.cs | 297 + .../Migrations/TikuDbContextModelSnapshot.cs | 470 + .../Persistence/TikuDbContext.cs | 12 + .../PlatformAdmin/PlatformAdminService.cs | 56 +- .../QuestionBanks/QuestionBankQueryService.cs | 15 +- .../QuestionBanks/QuestionReferenceService.cs | 5 +- .../Security/CapabilityAccessEvaluator.cs | 87 + .../RedisSecurityConnectionOptions.cs | 6 + .../Security/RedisSecurityStore.cs | 135 + .../Tenancy/TenantExecutionScope.cs | 119 +- .../TenantAdmin/TenantAdminDirectService.cs | 12 +- .../Tiku.Infrastructure.csproj | 6 + Tiku.IntegrationTests/Api/ApiTestFactory.cs | 11 +- .../Api/AuthEndpointTests.cs | 117 + .../Api/AuthorizationManifestTests.cs | 78 + .../Api/BrowserAuthenticationTests.cs | 183 + .../Api/CapabilityAuthorizationTests.cs | 49 + .../Api/ProductionConfigurationTests.cs | 20 + .../MassTransitOutboxTests.cs | 217 + .../RedisSecurityStoreTests.cs | 81 + .../SystemScopeAuditTests.cs | 75 + Tiku.Worker/Program.cs | 37 + Tiku.Worker/appsettings.json | 6 + ...entication-authorization-hardening-plan.md | 14 + .../authentication-authorization-security.md | 17 + .../endpoint-authorization-manifest.md | 9 + docs/quickstart.md | 13 + 76 files changed, 22020 insertions(+), 88 deletions(-) create mode 100644 Tiku.Api/Controllers/BrowserAuthController.cs create mode 100644 Tiku.Api/Middleware/BrowserCsrfMiddleware.cs create mode 100644 Tiku.Api/Options/BrowserAuthOptions.cs create mode 100644 Tiku.Api/Security/EndpointAuthorizationMetadata.cs create mode 100644 Tiku.Application/Security/ICapabilityAccessEvaluator.cs create mode 100644 Tiku.Application/Security/IRedisSecurityStore.cs create mode 100644 Tiku.Contracts/SecurityEvents.cs create mode 100644 Tiku.Contracts/Tiku.Contracts.csproj create mode 100644 Tiku.Infrastructure/Messaging/MessagingOptions.cs create mode 100644 Tiku.Infrastructure/Messaging/SecurityEventPublisher.cs create mode 100644 Tiku.Infrastructure/Messaging/SecurityStateChangedConsumer.cs create mode 100644 Tiku.Infrastructure/Persistence/Migrations/20260729012205_AddDistributedSecurityFoundation.Designer.cs create mode 100644 Tiku.Infrastructure/Persistence/Migrations/20260729012205_AddDistributedSecurityFoundation.cs create mode 100644 Tiku.Infrastructure/Security/CapabilityAccessEvaluator.cs create mode 100644 Tiku.Infrastructure/Security/RedisSecurityConnectionOptions.cs create mode 100644 Tiku.Infrastructure/Security/RedisSecurityStore.cs create mode 100644 Tiku.IntegrationTests/Api/AuthorizationManifestTests.cs create mode 100644 Tiku.IntegrationTests/Api/BrowserAuthenticationTests.cs create mode 100644 Tiku.IntegrationTests/Api/CapabilityAuthorizationTests.cs create mode 100644 Tiku.IntegrationTests/MassTransitOutboxTests.cs create mode 100644 Tiku.IntegrationTests/RedisSecurityStoreTests.cs create mode 100644 Tiku.IntegrationTests/SystemScopeAuditTests.cs create mode 100644 docs/architecture/endpoint-authorization-manifest.md diff --git a/Directory.Packages.props b/Directory.Packages.props index b9780b0..7cef88e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -26,6 +26,11 @@ + + + + + diff --git a/TIKU-BACKEND.slnx b/TIKU-BACKEND.slnx index 1071a5c..6ee7353 100644 --- a/TIKU-BACKEND.slnx +++ b/TIKU-BACKEND.slnx @@ -2,6 +2,7 @@ + diff --git a/Tiku.Api/Configuration/ApiPresentationExtensions.cs b/Tiku.Api/Configuration/ApiPresentationExtensions.cs index 52e31ea..7391fd5 100644 --- a/Tiku.Api/Configuration/ApiPresentationExtensions.cs +++ b/Tiku.Api/Configuration/ApiPresentationExtensions.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using Tiku.Api.OpenApi; +using Tiku.Api.Security; namespace Tiku.Api.Configuration; @@ -7,7 +8,8 @@ internal static class ApiPresentationExtensions { internal static IServiceCollection AddApiPresentation(this IServiceCollection services) { - services.AddControllers() + services.AddControllers(options => + options.Conventions.Add(new EndpointAuthorizationMetadataConvention())) .AddJsonOptions(options => { options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); diff --git a/Tiku.Api/Configuration/ApplicationBuilderExtensions.cs b/Tiku.Api/Configuration/ApplicationBuilderExtensions.cs index 63a43ce..5c5d229 100644 --- a/Tiku.Api/Configuration/ApplicationBuilderExtensions.cs +++ b/Tiku.Api/Configuration/ApplicationBuilderExtensions.cs @@ -29,6 +29,7 @@ public static class ApplicationBuilderExtensions app.UseRouting(); app.UseCors(CorsOptions.PolicyName); app.UseMiddleware(); + app.UseMiddleware(); app.UseAuthentication(); app.UseMiddleware(); app.UseRateLimiter(); diff --git a/Tiku.Api/Configuration/AuthenticationExtensions.cs b/Tiku.Api/Configuration/AuthenticationExtensions.cs index ab4c4bc..3dbed39 100644 --- a/Tiku.Api/Configuration/AuthenticationExtensions.cs +++ b/Tiku.Api/Configuration/AuthenticationExtensions.cs @@ -29,6 +29,15 @@ internal static class AuthenticationExtensions var jwtOptions = configuration .GetSection("Security:Jwt") .Get() ?? new JwtOptions(); + services.AddOptions() + .Bind(configuration.GetSection(BrowserAuthOptions.SectionName)) + .Validate( + options => options.AllowedOrigins.All(origin => + Uri.TryCreate(origin, UriKind.Absolute, out var uri) && + (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) && + string.IsNullOrEmpty(uri.PathAndQuery.Trim('/'))), + "BrowserAuth AllowedOrigins must contain only HTTP(S) origins without paths.") + .ValidateOnStart(); services .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) @@ -84,11 +93,41 @@ internal static class AuthenticationExtensions }; options.Events = new JwtBearerEvents { + OnMessageReceived = context => + { + if (string.IsNullOrWhiteSpace(context.Request.Headers.Authorization) && + IsSameOriginBrowserRequest(context.Request) && + context.Request.Cookies.TryGetValue(BrowserAuthOptions.AccessCookie, out var accessToken)) + { + context.Token = accessToken; + } + return Task.CompletedTask; + }, OnTokenValidated = ValidateTokenAsync, OnChallenge = WriteTenantConflictChallengeAsync }; } + private static bool IsSameOriginBrowserRequest(HttpRequest request) + { + var source = request.Headers.Origin.ToString(); + if (string.IsNullOrWhiteSpace(source)) + { + source = request.Headers.Referer.ToString(); + } + + if (Uri.TryCreate(source, UriKind.Absolute, out var uri)) + { + return string.Equals(uri.Scheme, request.Scheme, StringComparison.OrdinalIgnoreCase) && + string.Equals(uri.Authority, request.Host.Value, StringComparison.OrdinalIgnoreCase); + } + + return string.Equals( + request.Headers["Sec-Fetch-Site"].ToString(), + "same-origin", + StringComparison.OrdinalIgnoreCase); + } + private static async Task ValidateTokenAsync(TokenValidatedContext context) { var principal = context.Principal; diff --git a/Tiku.Api/Configuration/DependencyInjection.cs b/Tiku.Api/Configuration/DependencyInjection.cs index e7e2a57..f25eb54 100644 --- a/Tiku.Api/Configuration/DependencyInjection.cs +++ b/Tiku.Api/Configuration/DependencyInjection.cs @@ -1,6 +1,8 @@ using Serilog; using Tiku.Application; using Tiku.Infrastructure; +using Tiku.Infrastructure.Messaging; +using Tiku.Infrastructure.Security; namespace Tiku.Api.Configuration; @@ -15,14 +17,51 @@ public static class DependencyInjection preserveStaticLogger: true); builder.Services.AddApiPresentation(); + builder.Services.AddHealthChecks(); builder.Services.AddApplication(); - builder.Services.AddNetworkConfiguration(builder.Configuration); + builder.Services.AddNetworkConfiguration(builder.Configuration, builder.Environment); builder.Services.AddApiRateLimiting(builder.Configuration); var connectionString = Options.OptionsValidation.ResolveDatabaseConnectionString( builder.Configuration, builder.Environment.IsDevelopment()); builder.Services.AddInfrastructure(connectionString); + var redisConnectionString = builder.Configuration.GetConnectionString("Redis") ?? builder.Configuration["REDIS_URL"]; + builder.Services.AddOptions() + .Configure(options => options.ConnectionString = redisConnectionString ?? string.Empty) + .Validate( + options => !builder.Environment.IsProduction() || !string.IsNullOrWhiteSpace(options.ConnectionString), + "Redis is required in Production.") + .ValidateOnStart(); + if (!string.IsNullOrWhiteSpace(redisConnectionString)) + { + builder.Services.AddRedisSecurity(redisConnectionString, builder.Environment.EnvironmentName); + } + else if (builder.Environment.IsProduction()) + { + throw new InvalidOperationException( + "Redis is required in Production. Configure ConnectionStrings:Redis or REDIS_URL."); + } + var messaging = builder.Configuration.GetSection("RabbitMq").Get() ?? new MessagingOptions(); + builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection("RabbitMq")) + .Validate( + options => !builder.Environment.IsProduction() || + (options.IsConfigured && + !string.IsNullOrWhiteSpace(options.Username) && + !string.IsNullOrWhiteSpace(options.Password)), + "Production RabbitMQ requires a valid Host, Username and Password.") + .ValidateOnStart(); + builder.Services.AddSingleton(messaging); + if (messaging.IsConfigured) + { + messaging.ConfigureConsumers = false; + builder.Services.AddReliableMessaging(messaging); + } + else if (builder.Environment.IsProduction()) + { + throw new InvalidOperationException("RabbitMQ is required in Production. Configure RabbitMq:Host."); + } builder.Services.AddApiDataProtection(builder.Configuration, builder.Environment); builder.Services.AddExternalServiceOptions(builder.Configuration, builder.Environment); diff --git a/Tiku.Api/Configuration/NetworkConfigurationExtensions.cs b/Tiku.Api/Configuration/NetworkConfigurationExtensions.cs index 4057e42..2eb9fce 100644 --- a/Tiku.Api/Configuration/NetworkConfigurationExtensions.cs +++ b/Tiku.Api/Configuration/NetworkConfigurationExtensions.cs @@ -9,10 +9,15 @@ internal static class NetworkConfigurationExtensions { internal static IServiceCollection AddNetworkConfiguration( this IServiceCollection services, - IConfiguration configuration) + IConfiguration configuration, + IHostEnvironment environment) { services.AddOptions() - .Bind(configuration.GetSection(TenantResolutionOptions.SectionName)); + .Bind(configuration.GetSection(TenantResolutionOptions.SectionName)) + .Validate( + options => OptionsValidation.BeValidTenantResolutionOptions(options, configuration, environment.IsProduction()), + "Production requires formal platform hosts, non-wildcard AllowedHosts, and trusted proxy addresses.") + .ValidateOnStart(); services.Configure(options => { options.ForwardedHeaders = diff --git a/Tiku.Api/Controllers/AuthController.cs b/Tiku.Api/Controllers/AuthController.cs index fdb10d3..9a77211 100644 --- a/Tiku.Api/Controllers/AuthController.cs +++ b/Tiku.Api/Controllers/AuthController.cs @@ -28,6 +28,8 @@ public sealed class AuthController( [AllowAnonymous] [EnableRateLimiting(AuthRateLimitPolicies.Sms)] [HttpPost("sms/send")] + [EndpointSummary("发送短信验证码")] + [EndpointDescription("发送登录用途短信验证码,并应用租户级短信限流。")] [ProducesResponseType(StatusCodes.Status202Accepted)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status429TooManyRequests)] @@ -86,6 +88,7 @@ public sealed class AuthController( } [AllowAnonymous] + [EnableRateLimiting(AuthRateLimitPolicies.Sms)] [HttpPost("login/sms")] [EndpointSummary("短信验证码登录")] [EndpointDescription("校验已发送的登录用途短信验证码,成功后签发 JWT access token 与数据库 refresh/session。")] @@ -195,6 +198,8 @@ public sealed class AuthController( [HttpPost("logout-all")] [Authorize] + [EndpointSummary("退出全部登录会话")] + [EndpointDescription("撤销当前用户全部 refresh/session,会话校验开启时旧 access token 也会被拒绝。")] [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task LogoutAll(CancellationToken cancellationToken) { @@ -210,6 +215,8 @@ public sealed class AuthController( [AllowAnonymous] [HttpPost("password/change-required")] [EnableRateLimiting(AuthRateLimitPolicies.Password)] + [EndpointSummary("修改首次登录必改密码")] + [EndpointDescription("校验密码变更挑战令牌并设置新密码,成功后签发新的登录会话。")] public async Task> ChangeRequiredPassword( [FromBody] RequiredPasswordChangeDto request, CancellationToken cancellationToken) diff --git a/Tiku.Api/Controllers/BackgroundJobsController.cs b/Tiku.Api/Controllers/BackgroundJobsController.cs index e377ff3..aaaaf74 100644 --- a/Tiku.Api/Controllers/BackgroundJobsController.cs +++ b/Tiku.Api/Controllers/BackgroundJobsController.cs @@ -14,6 +14,7 @@ public sealed class BackgroundJobsController( ITenantContext tenantContext) : ControllerBase { [HttpGet] + [EndpointSummary("查询租户后台任务")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task>> List( [FromQuery] string? jobType, @@ -25,6 +26,7 @@ public sealed class BackgroundJobsController( } [HttpPost] + [EndpointSummary("创建租户后台任务")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> Create( CreateBackgroundJobDto request, diff --git a/Tiku.Api/Controllers/BackofficeController.cs b/Tiku.Api/Controllers/BackofficeController.cs index 3999832..77d519b 100644 --- a/Tiku.Api/Controllers/BackofficeController.cs +++ b/Tiku.Api/Controllers/BackofficeController.cs @@ -14,6 +14,8 @@ public sealed class BackofficeController( { [HttpGet("tenant/ui-bootstrap")] [Authorize(Policy = TikuPolicies.TenantBackofficeBootstrap)] + [EndpointSummary("查询租户后台菜单与权限")] + [EndpointDescription("返回当前租户管理员可见的后台菜单、权限和模块启用状态。")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> GetTenantUiBootstrap(CancellationToken cancellationToken) { @@ -24,6 +26,7 @@ public sealed class BackofficeController( [HttpGet("tenant/bootstrap")] [Authorize(Policy = BackendPermissions.TenantRoleManage)] + [EndpointSummary("查询租户角色管理初始化数据")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> GetTenantBootstrap(CancellationToken cancellationToken) { @@ -32,6 +35,8 @@ public sealed class BackofficeController( [HttpGet("platform/ui-bootstrap")] [Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)] + [EndpointSummary("查询平台后台菜单与权限")] + [EndpointDescription("返回当前平台管理员可见的后台菜单、权限和模块启用状态。")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> GetPlatformUiBootstrap(CancellationToken cancellationToken) { @@ -42,6 +47,7 @@ public sealed class BackofficeController( [HttpPost("tenant/roles")] [Authorize(Policy = BackendPermissions.TenantRoleManage)] + [EndpointSummary("创建或更新租户后台角色")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> UpsertTenantRole( UpsertBackofficeRoleDto request, @@ -52,6 +58,7 @@ public sealed class BackofficeController( [HttpPut("tenant/roles/{roleId:guid}/bindings")] [Authorize(Policy = BackendPermissions.TenantRoleManage)] + [EndpointSummary("替换租户后台角色权限绑定")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> ReplaceTenantRoleBindings( Guid roleId, @@ -63,6 +70,7 @@ public sealed class BackofficeController( [HttpPut("tenant/users/{userId:guid}/roles")] [Authorize(Policy = BackendPermissions.TenantRoleManage)] + [EndpointSummary("替换租户用户后台角色")] [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task ReplaceTenantUserRoles( Guid userId, @@ -75,6 +83,7 @@ public sealed class BackofficeController( [HttpGet("platform/bootstrap")] [Authorize(Policy = BackendPermissions.PlatformRoleManage)] + [EndpointSummary("查询平台角色管理初始化数据")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> GetPlatformBootstrap(CancellationToken cancellationToken) { @@ -83,6 +92,7 @@ public sealed class BackofficeController( [HttpPost("platform/roles")] [Authorize(Policy = BackendPermissions.PlatformRoleManage)] + [EndpointSummary("创建或更新平台后台角色")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> UpsertPlatformRole( UpsertBackofficeRoleDto request, @@ -93,6 +103,7 @@ public sealed class BackofficeController( [HttpPut("platform/roles/{roleId:guid}/bindings")] [Authorize(Policy = BackendPermissions.PlatformRoleManage)] + [EndpointSummary("替换平台后台角色权限绑定")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> ReplacePlatformRoleBindings( Guid roleId, @@ -104,6 +115,7 @@ public sealed class BackofficeController( [HttpPut("platform/users/{userId:guid}/roles")] [Authorize(Policy = BackendPermissions.PlatformRoleManage)] + [EndpointSummary("替换平台用户后台角色")] [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task ReplacePlatformUserRoles( Guid userId, diff --git a/Tiku.Api/Controllers/BrowserAuthController.cs b/Tiku.Api/Controllers/BrowserAuthController.cs new file mode 100644 index 0000000..c2b77fd --- /dev/null +++ b/Tiku.Api/Controllers/BrowserAuthController.cs @@ -0,0 +1,251 @@ +using System.Security.Cryptography; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Options; +using Tiku.Api.Contracts; +using Tiku.Api.Options; +using Tiku.Application.Auth; +using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Content; + +namespace Tiku.Api.Controllers; + +[ApiController] +[Route("api/browser-auth")] +[Produces("application/json")] +public sealed class BrowserAuthController( + IAuthService authService, + ISmsVerificationService smsVerificationService, + ITenantContext tenantContext, + ITenantContextInitializer tenantContextInitializer, + ITenantDirectory tenantDirectory, + ICurrentUser currentUser, + IOptions tenantResolutionOptions) : ControllerBase +{ + [AllowAnonymous] + [EnableRateLimiting(AuthRateLimitPolicies.Sms)] + [HttpPost("sms/send")] + public async Task> SendSmsCode( + [FromBody] SendSmsCodeDto request, + CancellationToken cancellationToken) + { + EnsureTrustedOrigin(); + var realm = request.Realm!.Value; + if (realm != AuthRealm.Tenant) + { + throw new RequiredFieldException("SMS authentication is only available in the tenant realm."); + } + var tenantId = await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken) + ?? throw new RequiredFieldException("tenantCode is required for SMS authentication."); + var result = await smsVerificationService.CreateCodeAsync(new SendSmsCodeRequest( + tenantId, + request.Phone, + SmsPurpose.Login, + HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString(), + request.DeviceId), cancellationToken); + return Accepted(result); + } + + [AllowAnonymous] + [HttpPost("login/password")] + public async Task> LoginWithPassword( + [FromBody] PasswordLoginDto request, + CancellationToken cancellationToken) + { + EnsureTrustedOrigin(); + var realm = request.Realm!.Value; + var identifier = request.Identifier ?? request.Phone; + if (string.IsNullOrWhiteSpace(identifier)) throw new RequiredFieldException("identifier is required."); + var result = await authService.LoginWithPasswordAsync(new PasswordLoginRequest( + realm, + await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken), + identifier, + request.Password, + HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString()), cancellationToken); + return Ok(WriteResult(result)); + } + + [AllowAnonymous] + [EnableRateLimiting(AuthRateLimitPolicies.Sms)] + [HttpPost("login/sms")] + public async Task> LoginWithSms( + [FromBody] SmsLoginDto request, + CancellationToken cancellationToken) + { + EnsureTrustedOrigin(); + var realm = request.Realm!.Value; + var result = await authService.LoginWithSmsAsync(new SmsLoginRequest( + realm, + await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken), + request.Phone, + request.Code, + HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString()), cancellationToken); + return Ok(WriteResult(result)); + } + + [AllowAnonymous] + [HttpPost("oauth/wechat")] + public async Task> LoginWithWechatWeb( + [FromBody] OAuthCodeDto request, + CancellationToken cancellationToken) + { + EnsureTrustedOrigin(); + var realm = request.Realm!.Value; + var result = await authService.LoginWithWechatWebAsync(new WechatLoginRequest( + realm, + await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken), + request.Code, + HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString()), cancellationToken); + return Ok(WriteResult(result)); + } + + [AllowAnonymous] + [HttpPost("oauth/wechat-miniapp")] + public async Task> LoginWithWechatMiniApp( + [FromBody] OAuthCodeDto request, + CancellationToken cancellationToken) + { + EnsureTrustedOrigin(); + var realm = request.Realm!.Value; + var result = await authService.LoginWithWechatMiniAppAsync(new WechatLoginRequest( + realm, + await ResolveTenantIdAsync(realm, request.TenantCode, cancellationToken), + request.Code, + HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString()), cancellationToken); + return Ok(WriteResult(result)); + } + + [AllowAnonymous] + [HttpPost("refresh")] + public async Task> Refresh(CancellationToken cancellationToken) + { + var refreshToken = Request.Cookies[BrowserAuthOptions.RefreshCookie]; + if (string.IsNullOrWhiteSpace(refreshToken)) return Unauthorized(); + var tokens = await authService.RefreshAsync(new RefreshSessionRequest( + refreshToken, + HttpContext.Connection.RemoteIpAddress?.ToString(), + Request.Headers.UserAgent.ToString()), cancellationToken); + WriteCookies(tokens); + return Ok(new { status = "authenticated" }); + } + + [AllowAnonymous] + [HttpPost("logout")] + public async Task Logout(CancellationToken cancellationToken) + { + var refreshToken = Request.Cookies[BrowserAuthOptions.RefreshCookie]; + if (!string.IsNullOrWhiteSpace(refreshToken)) + { + await authService.LogoutAsync(new LogoutSessionRequest(refreshToken), cancellationToken); + } + ClearCookies(); + return NoContent(); + } + + [Authorize] + [HttpPost("logout-all")] + public async Task LogoutAll(CancellationToken cancellationToken) + { + if (currentUser.UserId is not { } userId) return Unauthorized(); + await authService.LogoutAllAsync(userId, cancellationToken); + ClearCookies(); + return NoContent(); + } + + private object WriteResult(AuthenticationResult result) + { + if (result.User?.Tokens is { } tokens) + { + WriteCookies(tokens); + } + return new + { + status = result.Status.ToString(), + user = result.User is null ? null : new + { + result.User.UserId, + result.User.Phone, + result.User.Email, + result.User.Name, + result.User.Realm, + result.User.Tenant + }, + result.ChallengeToken, + result.ChallengeExpiresAt + }; + } + + private void WriteCookies(AuthTokenPair tokens) + { + Response.Cookies.Append(BrowserAuthOptions.AccessCookie, tokens.AccessToken, new CookieOptions + { + Secure = true, + HttpOnly = true, + SameSite = SameSiteMode.Lax, + Path = "/", + MaxAge = TimeSpan.FromMinutes(15) + }); + Response.Cookies.Append(BrowserAuthOptions.RefreshCookie, tokens.RefreshToken, new CookieOptions + { + Secure = true, + HttpOnly = true, + SameSite = SameSiteMode.Strict, + Path = "/api/browser-auth", + MaxAge = TimeSpan.FromDays(30) + }); + Response.Cookies.Append(BrowserAuthOptions.CsrfCookie, + Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(), new CookieOptions + { + Secure = true, + HttpOnly = false, + SameSite = SameSiteMode.Strict, + Path = "/" + }); + } + + private void ClearCookies() + { + Response.Cookies.Delete(BrowserAuthOptions.AccessCookie, new CookieOptions { Secure = true, Path = "/" }); + Response.Cookies.Delete(BrowserAuthOptions.RefreshCookie, new CookieOptions { Secure = true, Path = "/api/browser-auth" }); + Response.Cookies.Delete(BrowserAuthOptions.CsrfCookie, new CookieOptions { Secure = true, Path = "/" }); + } + + private void EnsureTrustedOrigin() + { + var origin = Request.Headers.Origin.ToString(); + if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri) || + !string.Equals(uri.Scheme, Request.Scheme, StringComparison.OrdinalIgnoreCase) || + !string.Equals(uri.Authority, Request.Host.Value, StringComparison.OrdinalIgnoreCase)) + { + throw new BrowserOriginException(); + } + } + + private async Task ResolveTenantIdAsync(AuthRealm realm, string? tenantCode, CancellationToken cancellationToken) + { + if (realm == AuthRealm.Platform) + { + var requestHost = Request.Host.Host.Trim().TrimEnd('.'); + if (!tenantResolutionOptions.Value.PlatformHosts.Any(host => + string.Equals(host.Trim().TrimEnd('.'), requestHost, StringComparison.OrdinalIgnoreCase))) + throw new RequiredFieldException("platform realm is only available on a configured platform host."); + return null; + } + if (tenantContext.TenantId is { } resolved) return resolved; + if (string.IsNullOrWhiteSpace(tenantCode)) throw new RequiredFieldException("tenantCode is required."); + var tenant = await tenantDirectory.FindByCodeAsync(tenantCode.Trim(), cancellationToken) + ?? throw new TenantNotFoundException(); + tenantContextInitializer.Initialize(tenant.TenantId, tenant.TenantCode, TenantResolutionSource.TenantCode); + return tenant.TenantId; + } +} + +public sealed class BrowserOriginException() : Exception("Browser authentication requires a same-origin request."); diff --git a/Tiku.Api/Controllers/CommissionController.cs b/Tiku.Api/Controllers/CommissionController.cs index a78902b..f152be6 100644 --- a/Tiku.Api/Controllers/CommissionController.cs +++ b/Tiku.Api/Controllers/CommissionController.cs @@ -16,50 +16,62 @@ public sealed class CommissionController( ITenantContext currentTenant) : ControllerBase { [HttpGet("settings")] + [EndpointSummary("查询佣金配置")] public async Task> Settings(CancellationToken cancellationToken) => Ok(await commissionService.GetSettingsAsync(ResolveActor(), cancellationToken)); [HttpPut("settings")] + [EndpointSummary("保存佣金配置")] public async Task> UpdateSettings(UpdateCommissionSettingsDto request, CancellationToken cancellationToken) => Ok(await commissionService.UpdateSettingsAsync(ResolveActor(), request.ToCommand(), cancellationToken)); [HttpPut("member-rate")] + [EndpointSummary("调整成员佣金比例")] public async Task> MemberRate(UpdateMemberCommissionRateDto request, CancellationToken cancellationToken) => Ok(await commissionService.UpdateMemberRateAsync(ResolveActor(), request.ToCommand(), cancellationToken)); [HttpGet("summary")] + [EndpointSummary("查询佣金统计摘要")] public async Task> Summary([FromQuery] CommissionPeriodQueryDto query, CancellationToken cancellationToken) => Ok(await commissionService.GetSummaryAsync(ResolveActor(), query.ToQuery(), cancellationToken)); [HttpGet("orders")] + [EndpointSummary("查询佣金来源订单")] public async Task>> Orders([FromQuery] CommissionPeriodQueryDto query, CancellationToken cancellationToken) => Ok(await commissionService.GetOrdersAsync(ResolveActor(), query.ToQuery(), cancellationToken)); [HttpGet("settlements")] + [EndpointSummary("查询佣金结算单")] public async Task>> Settlements([FromQuery] CommissionSettlementsQueryDto query, CancellationToken cancellationToken) => Ok(await commissionService.GetSettlementsAsync(ResolveActor(), query.ToQuery(), cancellationToken)); [HttpGet("settlements/export")] + [EndpointSummary("导出佣金结算单")] public async Task> Export([FromQuery] CommissionSettlementExportQueryDto query, CancellationToken cancellationToken) => Ok(await commissionService.ExportSettlementAsync(ResolveActor(), query.SettlementId, query.Format, cancellationToken)); [HttpPost("settlements/generate")] + [EndpointSummary("生成佣金结算单")] public async Task> Generate(GenerateCommissionSettlementDto request, CancellationToken cancellationToken) => Ok(await commissionService.GenerateSettlementAsync(ResolveActor(), request.ToCommand(), cancellationToken)); [HttpPost("settlements/status")] + [EndpointSummary("更新佣金结算单状态")] public async Task> UpdateStatus(UpdateCommissionSettlementStatusDto request, CancellationToken cancellationToken) => Ok(await commissionService.UpdateSettlementStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken)); [HttpGet("settlements/proofs")] + [EndpointSummary("查询佣金结算凭证")] public async Task>> Proofs([FromQuery] CommissionSettlementProofQueryDto query, CancellationToken cancellationToken) => Ok(await commissionService.GetProofsAsync(ResolveActor(), query.SettlementId, cancellationToken)); [HttpPost("settlements/proofs")] + [EndpointSummary("创建佣金结算凭证")] public async Task> CreateProof(CreateCommissionProofDto request, CancellationToken cancellationToken) => Ok(await commissionService.CreateProofAsync(ResolveActor(), request.ToCommand(), cancellationToken)); [HttpPost("settlements/proofs/status")] + [EndpointSummary("更新佣金结算凭证状态")] public async Task> UpdateProofStatus(UpdateCommissionProofStatusDto request, CancellationToken cancellationToken) => Ok(await commissionService.UpdateProofStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken)); diff --git a/Tiku.Api/Controllers/HealthController.cs b/Tiku.Api/Controllers/HealthController.cs index d40d967..cd08693 100644 --- a/Tiku.Api/Controllers/HealthController.cs +++ b/Tiku.Api/Controllers/HealthController.cs @@ -1,6 +1,12 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tiku.Api.Contracts; +using Tiku.Application.Security; +using Tiku.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using MassTransit.EntityFrameworkCoreIntegration; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Tiku.Infrastructure.Messaging; namespace Tiku.Api.Controllers; @@ -8,7 +14,11 @@ namespace Tiku.Api.Controllers; [AllowAnonymous] [Produces("application/json")] [Route("api/health")] -public sealed class HealthController : ControllerBase +public sealed class HealthController( + TikuDbContext dbContext, + IRedisSecurityStore redisSecurityStore, + MessagingOptions messagingOptions, + HealthCheckService healthCheckService) : ControllerBase { [HttpGet] [EndpointSummary("健康检查")] @@ -21,4 +31,30 @@ public sealed class HealthController : ControllerBase "tiku-api", DateTimeOffset.UtcNow)); } + + [HttpGet("ready")] + [EndpointSummary("依赖就绪检查")] + public async Task> Ready(CancellationToken cancellationToken) + { + var database = await dbContext.Database.CanConnectAsync(cancellationToken); + var redis = !redisSecurityStore.IsConfigured || await redisSecurityStore.PingAsync(cancellationToken); + var rabbitHealth = await healthCheckService.CheckHealthAsync( + registration => registration.Tags.Contains("ready"), + cancellationToken); + var rabbitMq = !messagingOptions.IsConfigured || rabbitHealth.Status == HealthStatus.Healthy; + var outboxPending = database + ? await dbContext.Set().CountAsync(cancellationToken) + : -1; + var ready = database && redis && rabbitMq; + var response = new + { + status = ready ? "ready" : "not_ready", + database, + redis = new { configured = redisSecurityStore.IsConfigured, ready = redis }, + rabbitMq = new { configured = messagingOptions.IsConfigured, ready = rabbitMq }, + outbox = new { pending = outboxPending }, + checkedAt = DateTimeOffset.UtcNow + }; + return ready ? Ok(response) : StatusCode(StatusCodes.Status503ServiceUnavailable, response); + } } diff --git a/Tiku.Api/Controllers/MeController.cs b/Tiku.Api/Controllers/MeController.cs index 1e8f4d9..bbd81e3 100644 --- a/Tiku.Api/Controllers/MeController.cs +++ b/Tiku.Api/Controllers/MeController.cs @@ -15,6 +15,8 @@ public sealed class MeController( TikuDbContext dbContext) : ControllerBase { [HttpGet] + [EndpointSummary("获取当前登录用户")] + [EndpointDescription("根据 Bearer Token 返回当前用户基础信息和活跃租户成员摘要。")] public async Task> Get(CancellationToken cancellationToken) { if (currentUser.UserId is null) diff --git a/Tiku.Api/Controllers/SecurityDiagnosticsController.cs b/Tiku.Api/Controllers/SecurityDiagnosticsController.cs index 32fe4a5..191920d 100644 --- a/Tiku.Api/Controllers/SecurityDiagnosticsController.cs +++ b/Tiku.Api/Controllers/SecurityDiagnosticsController.cs @@ -13,6 +13,7 @@ public sealed class SecurityDiagnosticsController( { [Authorize(Policy = TikuPolicies.AuthenticatedUser)] [HttpGet("authenticated")] + [EndpointSummary("诊断已登录用户上下文")] public ActionResult Authenticated() { return Ok(new @@ -24,6 +25,7 @@ public sealed class SecurityDiagnosticsController( [Authorize(Policy = TikuPolicies.CurrentTenantMember)] [HttpGet("tenant-member")] + [EndpointSummary("诊断当前租户成员上下文")] public ActionResult TenantMember() { return Ok(new @@ -34,6 +36,7 @@ public sealed class SecurityDiagnosticsController( [Authorize(Policy = TikuPolicies.TenantAdmin)] [HttpGet("tenant-admin")] + [EndpointSummary("诊断当前租户管理员上下文")] public ActionResult TenantAdmin() { return Ok(new diff --git a/Tiku.Api/Controllers/TaxonomyController.cs b/Tiku.Api/Controllers/TaxonomyController.cs index 7e3d77e..c28e828 100644 --- a/Tiku.Api/Controllers/TaxonomyController.cs +++ b/Tiku.Api/Controllers/TaxonomyController.cs @@ -15,6 +15,7 @@ public sealed class TaxonomyController( ITaxonomyService taxonomyService) : ControllerBase { [HttpGet] + [EndpointSummary("查询租户分类节点")] public Task> List(CancellationToken cancellationToken) { return taxonomyService.ListAsync(RequireTenantId(), cancellationToken); @@ -22,6 +23,7 @@ public sealed class TaxonomyController( [HttpPost] [Authorize(Policy = BackendPermissions.TenantContentManage)] + [EndpointSummary("创建租户分类节点")] public Task Create( CreateTaxonomyNodeDto request, CancellationToken cancellationToken) diff --git a/Tiku.Api/Controllers/TenantFrontendConfigController.cs b/Tiku.Api/Controllers/TenantFrontendConfigController.cs index b4f2158..0ab213c 100644 --- a/Tiku.Api/Controllers/TenantFrontendConfigController.cs +++ b/Tiku.Api/Controllers/TenantFrontendConfigController.cs @@ -15,12 +15,14 @@ public sealed class TenantFrontendConfigController( ITenantFrontendConfigService frontendConfigService) : ControllerBase { [HttpGet] + [EndpointSummary("查询租户前端配置")] public Task Get(CancellationToken cancellationToken) { return frontendConfigService.GetAsync(RequireTenantId(), cancellationToken); } [HttpPut("draft")] + [EndpointSummary("保存租户前端配置草稿")] public Task SaveDraft( SaveTenantFrontendConfigDraftDto request, CancellationToken cancellationToken) @@ -32,6 +34,7 @@ public sealed class TenantFrontendConfigController( } [HttpPost("publish")] + [EndpointSummary("发布租户前端配置")] public Task Publish( PublishTenantFrontendConfigDto request, CancellationToken cancellationToken) diff --git a/Tiku.Api/Controllers/TenantsController.cs b/Tiku.Api/Controllers/TenantsController.cs index fc446a8..39c3250 100644 --- a/Tiku.Api/Controllers/TenantsController.cs +++ b/Tiku.Api/Controllers/TenantsController.cs @@ -17,6 +17,8 @@ public sealed class TenantsController( TikuDbContext dbContext) : ControllerBase { [HttpGet("current")] + [EndpointSummary("查询当前租户")] + [EndpointDescription("返回当前请求租户及当前用户在该租户内的成员角色。")] public async Task> GetCurrent(CancellationToken cancellationToken) { if (currentUser.UserId is null || currentTenant.TenantId is null) diff --git a/Tiku.Api/Middleware/AuthRateLimitPartitionMiddleware.cs b/Tiku.Api/Middleware/AuthRateLimitPartitionMiddleware.cs index 929f65e..320b566 100644 --- a/Tiku.Api/Middleware/AuthRateLimitPartitionMiddleware.cs +++ b/Tiku.Api/Middleware/AuthRateLimitPartitionMiddleware.cs @@ -3,11 +3,24 @@ using System.Text; using System.Text.Json; using Microsoft.AspNetCore.RateLimiting; using Tiku.Api.Options; +using Tiku.Application.Security; +using Tiku.Infrastructure.Security; namespace Tiku.Api.Middleware; -public sealed class AuthRateLimitPartitionMiddleware(RequestDelegate next) +public sealed class AuthRateLimitPartitionMiddleware( + RequestDelegate next, + IRedisSecurityStore redisSecurityStore, + Microsoft.Extensions.Options.IOptions options) { + public AuthRateLimitPartitionMiddleware(RequestDelegate next) + : this( + next, + new NullRedisSecurityStore(), + Microsoft.Extensions.Options.Options.Create(new AuthRateLimitOptions())) + { + } + public async Task InvokeAsync(HttpContext context) { var policy = context.GetEndpoint()? @@ -24,11 +37,70 @@ public sealed class AuthRateLimitPartitionMiddleware(RequestDelegate next) if (HttpMethods.IsPost(context.Request.Method) && propertyName is not null) { await CaptureAccountHashAsync(context, propertyName); + if (redisSecurityStore.IsConfigured) + { + await ConsumeDistributedLimitAsync(context, policy!); + if (context.Response.HasStarted) + { + return; + } + } } await next(context); } + private async Task ConsumeDistributedLimitAsync(HttpContext context, string policyName) + { + var ip = Hash(context.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"); + var account = context.Items.TryGetValue(AuthRateLimitPartitionKey.AccountHashItemKey, out var value) + ? value as string ?? "unknown-account" + : "unknown-account"; + var isPassword = policyName == AuthRateLimitPolicies.Password; + var limit = isPassword ? options.Value.PasswordPermitLimit : options.Value.SmsPermitLimit; + var window = TimeSpan.FromSeconds(isPassword + ? options.Value.PasswordWindowSeconds + : options.Value.SmsWindowSeconds); + + DistributedRateLimitResult result; + try + { + result = await redisSecurityStore.ConsumeAsync( + [ + new DistributedRateLimitBucket($"{policyName}:ip:{ip}", limit * 4, window), + new DistributedRateLimitBucket($"{policyName}:ip-account:{ip}:{account}", limit, window) + ], context.RequestAborted); + } + catch (RedisSecurityUnavailableException) + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + await context.Response.WriteAsJsonAsync(new + { + title = "Authentication security service is unavailable.", + status = StatusCodes.Status503ServiceUnavailable, + code = "auth_security_unavailable", + traceId = context.TraceIdentifier + }, context.RequestAborted); + return; + } + + if (!result.Allowed) + { + if (result.RetryAfter is { } retryAfter) + { + context.Response.Headers.RetryAfter = Math.Max(1, (int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(); + } + context.Response.StatusCode = StatusCodes.Status429TooManyRequests; + await context.Response.WriteAsJsonAsync(new + { + title = "Too many requests.", + status = StatusCodes.Status429TooManyRequests, + code = "rate_limited", + traceId = context.TraceIdentifier + }, context.RequestAborted); + } + } + private static async Task CaptureAccountHashAsync(HttpContext context, string propertyName) { context.Request.EnableBuffering(bufferThreshold: 4096, bufferLimit: 16_384); diff --git a/Tiku.Api/Middleware/BrowserCsrfMiddleware.cs b/Tiku.Api/Middleware/BrowserCsrfMiddleware.cs new file mode 100644 index 0000000..80ab541 --- /dev/null +++ b/Tiku.Api/Middleware/BrowserCsrfMiddleware.cs @@ -0,0 +1,68 @@ +using System.Security.Cryptography; +using Microsoft.Extensions.Options; +using Tiku.Api.Options; + +namespace Tiku.Api.Middleware; + +public sealed class BrowserCsrfMiddleware( + RequestDelegate next, + IOptions options) +{ + public async Task InvokeAsync(HttpContext context) + { + if (!IsUnsafe(context.Request.Method) || !IsBrowserCookieRequest(context.Request)) + { + await next(context); + return; + } + + if (!IsTrustedOrigin(context.Request) || !HasValidCsrfToken(context.Request)) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + await context.Response.WriteAsJsonAsync(new + { + title = "Browser request origin or CSRF token is invalid.", + status = StatusCodes.Status403Forbidden, + code = "browser_csrf_rejected", + traceId = context.TraceIdentifier + }, context.RequestAborted); + return; + } + + await next(context); + } + + private static bool IsUnsafe(string method) => + !HttpMethods.IsGet(method) && !HttpMethods.IsHead(method) && !HttpMethods.IsOptions(method); + + private static bool IsBrowserCookieRequest(HttpRequest request) => + request.Cookies.ContainsKey(BrowserAuthOptions.AccessCookie) || + request.Cookies.ContainsKey(BrowserAuthOptions.RefreshCookie); + + private bool IsTrustedOrigin(HttpRequest request) + { + var origin = request.Headers.Origin.ToString().Trim().TrimEnd('/'); + if (string.IsNullOrWhiteSpace(origin) || !Uri.TryCreate(origin, UriKind.Absolute, out var uri)) + { + return false; + } + + var sameOrigin = string.Equals(uri.Scheme, request.Scheme, StringComparison.OrdinalIgnoreCase) && + string.Equals(uri.Authority, request.Host.Value, StringComparison.OrdinalIgnoreCase); + return sameOrigin || options.Value.AllowedOrigins.Any(allowed => + string.Equals(allowed.Trim().TrimEnd('/'), origin, StringComparison.OrdinalIgnoreCase)); + } + + private static bool HasValidCsrfToken(HttpRequest request) + { + var cookie = request.Cookies[BrowserAuthOptions.CsrfCookie]; + var header = request.Headers[BrowserAuthOptions.CsrfHeader].ToString(); + if (string.IsNullOrWhiteSpace(cookie) || string.IsNullOrWhiteSpace(header)) + { + return false; + } + var left = System.Text.Encoding.UTF8.GetBytes(cookie); + var right = System.Text.Encoding.UTF8.GetBytes(header); + return left.Length == right.Length && CryptographicOperations.FixedTimeEquals(left, right); + } +} diff --git a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs index d039fdd..8d2c7fe 100644 --- a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs +++ b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs @@ -50,6 +50,12 @@ public sealed class ExceptionHandlingMiddleware( return; } + if (exception is BrowserOriginException) + { + await WriteProblemAsync(context, exception.Message, StatusCodes.Status403Forbidden, "browser_origin_rejected"); + return; + } + if (exception is TenantContextConflictException) { await WriteProblemAsync( @@ -380,6 +386,7 @@ public sealed class ExceptionHandlingMiddleware( "tenant_access_denied" => StatusCodes.Status403Forbidden, "sms_rate_limited" => StatusCodes.Status429TooManyRequests, "auth_provider_not_configured" => StatusCodes.Status503ServiceUnavailable, + "auth_security_unavailable" => StatusCodes.Status503ServiceUnavailable, "session_revoked" => StatusCodes.Status401Unauthorized, _ => StatusCodes.Status401Unauthorized }; @@ -494,7 +501,7 @@ public sealed class ExceptionHandlingMiddleware( { return code switch { - "platform_access_denied" or "tenant_access_denied" => StatusCodes.Status403Forbidden, + "platform_access_denied" or "tenant_access_denied" or "capability_not_available" => StatusCodes.Status403Forbidden, _ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound, _ => StatusCodes.Status400BadRequest }; diff --git a/Tiku.Api/Options/BrowserAuthOptions.cs b/Tiku.Api/Options/BrowserAuthOptions.cs new file mode 100644 index 0000000..87f976d --- /dev/null +++ b/Tiku.Api/Options/BrowserAuthOptions.cs @@ -0,0 +1,12 @@ +namespace Tiku.Api.Options; + +public sealed class BrowserAuthOptions +{ + public const string SectionName = "BrowserAuth"; + public const string AccessCookie = "__Host-tiku-at"; + public const string RefreshCookie = "__Secure-tiku-rt"; + public const string CsrfCookie = "__Host-tiku-csrf"; + public const string CsrfHeader = "X-CSRF-Token"; + + public string[] AllowedOrigins { get; set; } = []; +} diff --git a/Tiku.Api/Options/OptionsValidation.cs b/Tiku.Api/Options/OptionsValidation.cs index 6de776d..0a69930 100644 --- a/Tiku.Api/Options/OptionsValidation.cs +++ b/Tiku.Api/Options/OptionsValidation.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Configuration; +using System.Net; using Tiku.Application.Security; namespace Tiku.Api.Options; @@ -41,6 +42,32 @@ public static class OptionsValidation return options.AllowedOrigins.All(IsHttpOrigin); } + public static bool BeValidTenantResolutionOptions( + TenantResolutionOptions options, + IConfiguration configuration, + bool isProduction) + { + if (!isProduction) + { + return true; + } + + var platformHosts = options.PlatformHosts + .Where(host => !string.IsNullOrWhiteSpace(host)) + .Select(host => host.Trim()) + .ToArray(); + var hasFormalHost = platformHosts.Any(host => + !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) && + !string.Equals(host, "127.0.0.1", StringComparison.OrdinalIgnoreCase) && + !string.Equals(host, "::1", StringComparison.OrdinalIgnoreCase)); + var allowedHosts = configuration["AllowedHosts"]; + return hasFormalHost && + options.TrustedProxyAddresses.Any(address => IPAddress.TryParse(address, out _)) && + !string.IsNullOrWhiteSpace(allowedHosts) && + !allowedHosts.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Contains("*", StringComparer.Ordinal); + } + private static bool IsHttpOrigin(string origin) { return Uri.TryCreate(origin, UriKind.Absolute, out var uri) && diff --git a/Tiku.Api/Security/AccessAuthorizationRequirements.cs b/Tiku.Api/Security/AccessAuthorizationRequirements.cs index d10326b..4576bce 100644 --- a/Tiku.Api/Security/AccessAuthorizationRequirements.cs +++ b/Tiku.Api/Security/AccessAuthorizationRequirements.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Tiku.Application.Security; namespace Tiku.Api.Security; @@ -73,7 +74,10 @@ internal sealed class CurrentAccessAuthorizationHandler(ICurrentAccessContext ac Guid.TryParse(principal.FindFirst(TikuClaimTypes.TenantId)?.Value, out var tenantId) ? tenantId : null; } -internal sealed class TenantPermissionAuthorizationHandler(ICurrentAccessContext accessContext) : +internal sealed class TenantPermissionAuthorizationHandler( + ICurrentAccessContext accessContext, + ICapabilityAccessEvaluator capabilityAccessEvaluator, + IHttpContextAccessor httpContextAccessor) : AuthorizationHandler { protected override async Task HandleRequirementAsync( @@ -86,11 +90,27 @@ internal sealed class TenantPermissionAuthorizationHandler(ICurrentAccessContext } var access = await accessContext.GetAsync(); - if (access.HasTenantPermission(requirement.PermissionCode)) + var moduleCode = ResolveModuleCode(requirement.PermissionCode); + var operation = IsSafeMethod(httpContextAccessor.HttpContext?.Request.Method) + ? CapabilityOperation.Read + : CapabilityOperation.Write; + if (access.TenantId is { } tenantId && + access.HasTenantPermission(requirement.PermissionCode) && + await capabilityAccessEvaluator.IsAllowedAsync(tenantId, moduleCode, operation)) { context.Succeed(requirement); } } + + private static string ResolveModuleCode(string permissionCode) + { + var parts = permissionCode.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return parts.Length >= 2 ? parts[1].ToLowerInvariant() : permissionCode.ToLowerInvariant(); + } + + private static bool IsSafeMethod(string? method) => + method is not null && + (HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsOptions(method)); } internal sealed class CurrentPlatformAccessAuthorizationHandler(ICurrentAccessContext accessContext) : @@ -176,6 +196,8 @@ public static class AccessAuthorizationServiceCollectionExtensions { public static IServiceCollection AddTikuRbacAuthorization(this IServiceCollection services) { + services.AddHttpContextAccessor(); + services.TryAddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -252,3 +274,18 @@ public static class AccessAuthorizationServiceCollectionExtensions return services; } } + +internal sealed class CompatibilityCapabilityAccessEvaluator : ICapabilityAccessEvaluator +{ + public Task IsAllowedAsync( + Guid tenantId, + string moduleCode, + CapabilityOperation operation, + CancellationToken cancellationToken = default) => Task.FromResult(true); + + public Task> GetEnabledModulesAsync( + Guid tenantId, + CapabilityOperation operation = CapabilityOperation.Read, + CancellationToken cancellationToken = default) => + Task.FromResult>(new HashSet(StringComparer.Ordinal)); +} diff --git a/Tiku.Api/Security/EndpointAuthorizationMetadata.cs b/Tiku.Api/Security/EndpointAuthorizationMetadata.cs new file mode 100644 index 0000000..9342b31 --- /dev/null +++ b/Tiku.Api/Security/EndpointAuthorizationMetadata.cs @@ -0,0 +1,79 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.Mvc.Routing; +using Tiku.Application.Security; + +namespace Tiku.Api.Security; + +public sealed record EndpointAuthorizationMetadata( + string Realm, + string? Module, + string? Permission, + CapabilityOperation Operation, + bool RequiresAllDataScope, + string AuditAction); + +internal sealed class EndpointAuthorizationMetadataConvention : IApplicationModelConvention +{ + public void Apply(ApplicationModel application) + { + foreach (var controller in application.Controllers) + { + foreach (var action in controller.Actions) + { + var anonymous = controller.Attributes.OfType().Any() || + action.Attributes.OfType().Any(); + if (anonymous) + { + continue; + } + + var policies = controller.Attributes.OfType() + .Concat(action.Attributes.OfType()) + .Select(attribute => attribute.Policy) + .Where(policy => !string.IsNullOrWhiteSpace(policy)) + .Cast() + .ToArray(); + var permission = policies.FirstOrDefault(policy => + BackendPermissions.Tenant.Contains(policy) || BackendPermissions.Platform.Contains(policy)); + var realm = permission is not null && BackendPermissions.Platform.Contains(permission) || + policies.Any(policy => policy.StartsWith("platform", StringComparison.Ordinal)) + ? "platform" + : permission is not null && BackendPermissions.Tenant.Contains(permission) || + policies.Any(policy => policy.StartsWith("tenant", StringComparison.Ordinal)) + ? "tenant" + : "authenticated"; + var module = permission is null ? null : ResolveModule(permission); + var httpMethods = action.Attributes.OfType() + .SelectMany(attribute => attribute.HttpMethods) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + var operation = httpMethods.All(IsSafeMethod) + ? CapabilityOperation.Read + : CapabilityOperation.Write; + var route = $"{controller.ControllerName}.{action.ActionName}"; + var metadata = new EndpointAuthorizationMetadata( + realm, + module, + permission, + operation, + policies.Contains(TikuPolicies.TenantContentManageAllScope, StringComparer.Ordinal) || + policies.Contains(TikuPolicies.TenantCommerceOperateAllScope, StringComparer.Ordinal), + $"{string.Join(',', httpMethods.Order(StringComparer.Ordinal))}:{route}"); + foreach (var selector in action.Selectors) + { + selector.EndpointMetadata.Add(metadata); + } + } + } + } + + private static string ResolveModule(string permission) + { + var parts = permission.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return parts.Length >= 2 ? parts[1].ToLowerInvariant() : permission.ToLowerInvariant(); + } + + private static bool IsSafeMethod(string method) => + HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsOptions(method); +} diff --git a/Tiku.Api/appsettings.json b/Tiku.Api/appsettings.json index 16f1d27..6847f7d 100644 --- a/Tiku.Api/appsettings.json +++ b/Tiku.Api/appsettings.json @@ -65,6 +65,18 @@ "DeviceRequestsPerHour": 10 } }, + "Redis": { + "KeyPrefix": "tiku" + }, + "RabbitMq": { + "Host": "", + "VirtualHost": "/", + "Username": "", + "Password": "" + }, + "BrowserAuth": { + "AllowedOrigins": [] + }, "Storage": { "DefaultProvider": "aliyun_oss", "DefaultBucket": "tenant-assets", diff --git a/Tiku.Application/Auth/AuthExceptions.cs b/Tiku.Application/Auth/AuthExceptions.cs index 652a274..77ed146 100644 --- a/Tiku.Application/Auth/AuthExceptions.cs +++ b/Tiku.Application/Auth/AuthExceptions.cs @@ -20,5 +20,8 @@ public sealed class SmsRateLimitedException() public sealed class AuthProviderNotConfiguredException(string provider) : AuthException("auth_provider_not_configured", $"The {provider} auth provider is not configured."); +public sealed class AuthSecurityUnavailableException() + : AuthException("auth_security_unavailable", "Authentication security services are unavailable."); + public sealed class InvalidAuthChallengeException(string code = "invalid_auth_challenge") : AuthException(code, "The authentication challenge is invalid, consumed, or expired."); diff --git a/Tiku.Application/Security/ICapabilityAccessEvaluator.cs b/Tiku.Application/Security/ICapabilityAccessEvaluator.cs new file mode 100644 index 0000000..0dad35a --- /dev/null +++ b/Tiku.Application/Security/ICapabilityAccessEvaluator.cs @@ -0,0 +1,21 @@ +namespace Tiku.Application.Security; + +public enum CapabilityOperation +{ + Read, + Write +} + +public interface ICapabilityAccessEvaluator +{ + Task IsAllowedAsync( + Guid tenantId, + string moduleCode, + CapabilityOperation operation, + CancellationToken cancellationToken = default); + + Task> GetEnabledModulesAsync( + Guid tenantId, + CapabilityOperation operation = CapabilityOperation.Read, + CancellationToken cancellationToken = default); +} diff --git a/Tiku.Application/Security/IRedisSecurityStore.cs b/Tiku.Application/Security/IRedisSecurityStore.cs new file mode 100644 index 0000000..07981c9 --- /dev/null +++ b/Tiku.Application/Security/IRedisSecurityStore.cs @@ -0,0 +1,23 @@ +namespace Tiku.Application.Security; + +public sealed record DistributedRateLimitBucket(string Key, int PermitLimit, TimeSpan Window); + +public sealed record DistributedRateLimitResult(bool Allowed, TimeSpan? RetryAfter = null); + +public interface IRedisSecurityStore +{ + bool IsConfigured { get; } + + Task ConsumeAsync( + IReadOnlyCollection buckets, + CancellationToken cancellationToken = default); + + Task PingAsync(CancellationToken cancellationToken = default); + + Task SetInvalidationVersionAsync( + string realm, + Guid? tenantId, + Guid? userId, + long version, + CancellationToken cancellationToken = default); +} diff --git a/Tiku.Application/Security/ITenantExecutionScope.cs b/Tiku.Application/Security/ITenantExecutionScope.cs index 41d77cb..f54aef9 100644 --- a/Tiku.Application/Security/ITenantExecutionScope.cs +++ b/Tiku.Application/Security/ITenantExecutionScope.cs @@ -1,16 +1,31 @@ namespace Tiku.Application.Security; +public enum SystemScopeCallerType +{ + Platform, + Worker, + Migrator, + PublicQuestionBank, + Test +} + +public sealed record SystemScopeRequest( + Guid? TargetTenantId, + SystemScopeCallerType CallerType, + string Caller, + string Reason, + string CorrelationId); + public interface ITenantExecutionScope { Task ExecuteAsync( - Guid? targetTenantId, - string reason, + SystemScopeRequest request, Func operation, CancellationToken cancellationToken = default); Task ExecuteAsync( - Guid? targetTenantId, - string reason, + SystemScopeRequest request, Func> operation, CancellationToken cancellationToken = default); + } diff --git a/Tiku.Contracts/SecurityEvents.cs b/Tiku.Contracts/SecurityEvents.cs new file mode 100644 index 0000000..79f95f4 --- /dev/null +++ b/Tiku.Contracts/SecurityEvents.cs @@ -0,0 +1,36 @@ +namespace Tiku.Contracts; + +public sealed record AuthorizationStateChangedV1( + Guid EventId, + Guid? TenantId, + Guid? UserId, + string ChangeKind, + long Version, + DateTimeOffset OccurredAt, + string CorrelationId); + +public sealed record TenantCapabilityChangedV1( + Guid EventId, + Guid TenantId, + string ModuleCode, + string ChangeKind, + long Version, + DateTimeOffset OccurredAt, + string CorrelationId); + +public sealed record MembershipLifecycleChangedV1( + Guid EventId, + Guid TenantId, + Guid UserId, + string PreviousStatus, + string CurrentStatus, + DateTimeOffset OccurredAt, + string CorrelationId); + +public sealed record BackgroundJobRequestedV1( + Guid EventId, + Guid JobId, + Guid TenantId, + string JobType, + DateTimeOffset OccurredAt, + string CorrelationId); diff --git a/Tiku.Contracts/Tiku.Contracts.csproj b/Tiku.Contracts/Tiku.Contracts.csproj new file mode 100644 index 0000000..6c3a887 --- /dev/null +++ b/Tiku.Contracts/Tiku.Contracts.csproj @@ -0,0 +1,7 @@ + + + net10.0 + enable + enable + + diff --git a/Tiku.Domain/Commerce/CommerceEntities.cs b/Tiku.Domain/Commerce/CommerceEntities.cs index bb5fb07..eaff60b 100644 --- a/Tiku.Domain/Commerce/CommerceEntities.cs +++ b/Tiku.Domain/Commerce/CommerceEntities.cs @@ -389,7 +389,7 @@ public enum EntitlementScopeType { Tenant, Region, Module, Subject, QuestionBank public enum EntitlementStatus { Active, Revoked, Expired } public enum DiscountType { Percent, Fixed } public enum CouponRedemptionStatus { Claimed, Used, Expired, Cancelled } -public enum TenantSubscriptionStatus { Trial, Active, PastDue, Cancelled } +public enum TenantSubscriptionStatus { Trial, Active, PastDue, Cancelled, Expired } public enum CommerceRefundStatus { Requested, Approved, Processing, Succeeded, Failed, Rejected, Cancelled } public enum RefundEntitlementAction { None, RevokeOnSuccess } public enum ReconciliationBillType { Payment, Refund, Combined } diff --git a/Tiku.Domain/Platform/PlatformOperationsEntities.cs b/Tiku.Domain/Platform/PlatformOperationsEntities.cs index 15f1188..ef52aa1 100644 --- a/Tiku.Domain/Platform/PlatformOperationsEntities.cs +++ b/Tiku.Domain/Platform/PlatformOperationsEntities.cs @@ -18,6 +18,30 @@ public sealed class PlatformSaasPlan : AuditableEntity public int SortOrder { get; set; } } +public sealed class ProductModule : AuditableEntity +{ + public string Code { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public ProductModuleStatus Status { get; set; } = ProductModuleStatus.Active; + public int SortOrder { get; set; } +} + +public sealed class PlanModuleEntitlement : Entity +{ + public string PlanCode { get; set; } = string.Empty; + public string ModuleCode { get; set; } = string.Empty; + public bool Enabled { get; set; } = true; +} + +public sealed class TenantModuleOverride : AuditableTenantEntity +{ + public string ModuleCode { get; set; } = string.Empty; + public TenantModuleOverrideMode Mode { get; set; } = TenantModuleOverrideMode.Disabled; + public DateTimeOffset? ExpiresAt { get; set; } + public string? Reason { get; set; } +} + public sealed class TenantBillingProfile : IHasTimestamps, ITenantOwned { public Guid TenantId { get; set; } @@ -177,6 +201,8 @@ public sealed class PlatformDunningNotificationEvent : AuditableTenantEntity public enum PlatformBillingCycle { Monthly, Quarterly, Yearly, OneTime } public enum PlatformSaasPlanStatus { Active, Archived } +public enum ProductModuleStatus { Active, Archived } +public enum TenantModuleOverrideMode { Enabled, Disabled } public enum TenantInvoiceTitleType { None, NormalVat, SpecialVat } public enum TenantInvoiceType { Subscription, ServiceFee, UsageOverage, ManualAdjustment } public enum TenantInvoiceStatus { Draft, Issued, Paid, Void, Overdue } diff --git a/Tiku.Domain/Tenancy/TenantConfigurationEntities.cs b/Tiku.Domain/Tenancy/TenantConfigurationEntities.cs index abf9dfd..b529ed0 100644 --- a/Tiku.Domain/Tenancy/TenantConfigurationEntities.cs +++ b/Tiku.Domain/Tenancy/TenantConfigurationEntities.cs @@ -59,6 +59,14 @@ public sealed class TenantSettings : IHasTimestamps, ITenantOwned public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; } +public sealed class TenantAuthPolicy : IHasTimestamps, ITenantOwned +{ + public Guid TenantId { get; set; } + public bool AllowExternalStudentSelfRegistration { get; set; } + 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; diff --git a/Tiku.Infrastructure/Auth/AuthService.cs b/Tiku.Infrastructure/Auth/AuthService.cs index f785608..30b6893 100644 --- a/Tiku.Infrastructure/Auth/AuthService.cs +++ b/Tiku.Infrastructure/Auth/AuthService.cs @@ -404,6 +404,10 @@ public sealed class AuthService( request.TenantId.Value, user.Id, cancellationToken); + // Persist the external identity and membership together only after the + // tenant policy and existing membership state have accepted the login. + // A denied first login must not leave a user or provider identity behind. + await dbContext.SaveChangesAsync(cancellationToken); return await CompleteSuccessfulLoginAsync( request.Realm, @@ -502,7 +506,6 @@ public sealed class AuthService( existingIdentity.UserId = user.Id; existingIdentity.OpenId = wechatIdentity.OpenId; existingIdentity.UnionId = wechatIdentity.UnionId; - await dbContext.SaveChangesAsync(cancellationToken); return user; } @@ -552,22 +555,26 @@ public sealed class AuthService( membership.UserId == userId && membership.Role == TenantRole.Student, cancellationToken); - if (studentMembership is null) + if (studentMembership is not null) { - dbContext.TenantMemberships.Add(new TenantMembership - { - TenantId = tenantId, - UserId = userId, - Role = TenantRole.Student, - Status = MembershipStatus.Active - }); - } - else - { - studentMembership.Status = MembershipStatus.Active; + // Invited and Disabled memberships require an explicit administrator action. + throw new TenantAccessDeniedException(); } - await dbContext.SaveChangesAsync(cancellationToken); + var policy = await dbContext.TenantAuthPolicies.AsNoTracking() + .SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); + if (policy is not null && !policy.AllowExternalStudentSelfRegistration) + { + throw new TenantAccessDeniedException(); + } + + dbContext.TenantMemberships.Add(new TenantMembership + { + TenantId = tenantId, + UserId = userId, + Role = TenantRole.Student, + Status = MembershipStatus.Active + }); } private async Task FindActiveMembershipAsync( diff --git a/Tiku.Infrastructure/Auth/SmsVerificationService.cs b/Tiku.Infrastructure/Auth/SmsVerificationService.cs index 96b69bf..8a197ef 100644 --- a/Tiku.Infrastructure/Auth/SmsVerificationService.cs +++ b/Tiku.Infrastructure/Auth/SmsVerificationService.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Security.Cryptography; +using System.Text; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -7,14 +8,25 @@ using Tiku.Application.Auth; using Tiku.Domain.Common; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; +using Tiku.Application.Security; +using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Auth; public sealed class SmsVerificationService( TikuDbContext dbContext, ISmsProvider smsProvider, + IRedisSecurityStore redisSecurityStore, IOptions securityOptions) : ISmsVerificationService { + public SmsVerificationService( + TikuDbContext dbContext, + ISmsProvider smsProvider, + IOptions securityOptions) + : this(dbContext, smsProvider, new NullRedisSecurityStore(), securityOptions) + { + } + private static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(10); private static readonly SemaphoreSlim InMemoryRateLimitLock = new(1, 1); private readonly SmsSecurityOptions options = securityOptions.Value; @@ -141,6 +153,7 @@ public sealed class SmsVerificationService( var normalizedPhone = SmsCodeHashing.NormalizePhone(phone); var now = DateTimeOffset.UtcNow; + await ConsumeVerificationLimitAsync(tenantId, normalizedPhone, purpose, cancellationToken); var codeHash = SmsCodeHashing.Hash( tenantId, normalizedPhone, @@ -184,6 +197,39 @@ public sealed class SmsVerificationService( throw new InvalidCredentialsException("invalid_sms_code"); } + private async Task ConsumeVerificationLimitAsync( + Guid tenantId, + string phone, + SmsPurpose purpose, + CancellationToken cancellationToken) + { + if (!redisSecurityStore.IsConfigured) + { + return; + } + + var phoneHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(phone))) + .ToLowerInvariant(); + try + { + var result = await redisSecurityStore.ConsumeAsync( + [ + new DistributedRateLimitBucket( + $"sms-verify:{tenantId:N}:{purpose.ToString().ToLowerInvariant()}:{phoneHash}", + options.MaxVerificationAttempts, + CodeLifetime) + ], cancellationToken); + if (!result.Allowed) + { + throw new SmsRateLimitedException(); + } + } + catch (RedisSecurityUnavailableException) + { + throw new AuthSecurityUnavailableException(); + } + } + private async Task ConsumeRateLimitsAsync( SendSmsCodeRequest request, string phone, @@ -193,6 +239,27 @@ public sealed class SmsVerificationService( var limits = BuildRateLimits(request, phone); var bucketStart = TruncateToHour(now); + if (redisSecurityStore.IsConfigured) + { + try + { + var distributed = await redisSecurityStore.ConsumeAsync( + limits.Select(limit => new DistributedRateLimitBucket( + $"sms-send:{request.TenantId:N}:{ToSnakeCase(limit.Dimension)}:{limit.ScopeHash}", + limit.Maximum, + TimeSpan.FromHours(1))).ToArray(), + cancellationToken); + if (!distributed.Allowed) + { + throw new SmsRateLimitedException(); + } + } + catch (RedisSecurityUnavailableException) + { + throw new AuthSecurityUnavailableException(); + } + } + if (!dbContext.Database.IsRelational()) { await ConsumeInMemoryRateLimitsAsync(limits, request.TenantId, bucketStart, now, cancellationToken); diff --git a/Tiku.Infrastructure/Backoffice/BackofficeService.cs b/Tiku.Infrastructure/Backoffice/BackofficeService.cs index c2a501f..f699496 100644 --- a/Tiku.Infrastructure/Backoffice/BackofficeService.cs +++ b/Tiku.Infrastructure/Backoffice/BackofficeService.cs @@ -11,7 +11,8 @@ namespace Tiku.Infrastructure.Backoffice; internal sealed class BackofficeService( TikuDbContext dbContext, - IOperationAuditService auditService) : IBackofficeService + IOperationAuditService auditService, + ICapabilityAccessEvaluator capabilityAccessEvaluator) : IBackofficeService { private static readonly BuiltinPermission[] BuiltinPermissions = [ @@ -63,7 +64,11 @@ internal sealed class BackofficeService( } await EnsureCatalogAsync(cancellationToken); - var permissionCodes = access.TenantPermissions.Order(StringComparer.Ordinal).ToArray(); + var permissionCodes = await FilterTenantPermissionCodesAsync( + access.TenantId.Value, + access.TenantPermissions, + CapabilityOperation.Read, + cancellationToken); var menus = await LoadEffectiveMenusAsync( BackendPermissionArea.Tenant, permissionCodes, @@ -100,10 +105,18 @@ internal sealed class BackofficeService( .Where(item => item.Area == BackendPermissionArea.Tenant || item.Area == BackendPermissionArea.Both) .OrderBy(item => item.Module).ThenBy(item => item.SortOrder).ThenBy(item => item.Code) .ToArrayAsync(cancellationToken); + var enabledPermissionCodes = await FilterTenantPermissionCodesAsync( + tenantId, + permissions.Select(item => item.Code), + CapabilityOperation.Read, + cancellationToken); + permissions = permissions.Where(item => enabledPermissionCodes.Contains(item.Code, StringComparer.Ordinal)).ToArray(); var menus = await dbContext.BackendMenus.AsNoTracking() .Where(item => item.IsActive && item.Area == BackendPermissionArea.Tenant) .OrderBy(item => item.SortOrder).ThenBy(item => item.Code) .ToArrayAsync(cancellationToken); + menus = menus.Where(item => item.PermissionCode is null || + enabledPermissionCodes.Contains(item.PermissionCode, StringComparer.Ordinal)).ToArray(); return new BackofficeBootstrap( permissions.Select(ToPermissionItem).ToArray(), menus.Select(ToMenuItem).ToArray(), @@ -350,6 +363,19 @@ internal sealed class BackofficeService( var normalizedPermissions = NormalizeCodes(permissionCodes); var normalizedMenus = NormalizeCodes(menuCodes); await ValidatePermissionCodesAsync(normalizedPermissions, BackendPermissionArea.Tenant, cancellationToken); + foreach (var permissionCode in normalizedPermissions) + { + if (!await capabilityAccessEvaluator.IsAllowedAsync( + tenantId, + ResolveModuleCode(permissionCode), + CapabilityOperation.Write, + cancellationToken)) + { + throw new BackofficeException( + "One or more permissions belong to a module unavailable to this tenant.", + "capability_not_available"); + } + } await ValidateMenuCodesAsync(normalizedMenus, BackendPermissionArea.Tenant, cancellationToken); await dbContext.TenantBackendRolePermissions.Where(item => item.TenantId == tenantId && item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken); await dbContext.TenantBackendRoleMenus.Where(item => item.TenantId == tenantId && item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken); @@ -401,6 +427,33 @@ internal sealed class BackofficeService( return menus.Select(ToMenuItem).ToArray(); } + private async Task FilterTenantPermissionCodesAsync( + Guid tenantId, + IEnumerable permissionCodes, + CapabilityOperation operation, + CancellationToken cancellationToken) + { + var enabled = new List(); + foreach (var permissionCode in permissionCodes.Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal)) + { + if (await capabilityAccessEvaluator.IsAllowedAsync( + tenantId, + ResolveModuleCode(permissionCode), + operation, + cancellationToken)) + { + enabled.Add(permissionCode); + } + } + return enabled.ToArray(); + } + + private static string ResolveModuleCode(string permissionCode) + { + var parts = permissionCode.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return parts.Length >= 2 ? parts[1].ToLowerInvariant() : permissionCode.ToLowerInvariant(); + } + private async Task ValidateMenuCodesAsync(string[] codes, BackendPermissionArea area, CancellationToken cancellationToken) { var count = await dbContext.BackendMenus.CountAsync( diff --git a/Tiku.Infrastructure/Catalog/TaxonomyService.cs b/Tiku.Infrastructure/Catalog/TaxonomyService.cs index 59bf50e..ffe275d 100644 --- a/Tiku.Infrastructure/Catalog/TaxonomyService.cs +++ b/Tiku.Infrastructure/Catalog/TaxonomyService.cs @@ -30,8 +30,9 @@ public sealed class TaxonomyService( } return await tenantExecutionScope.ExecuteAsync( - tenantId, - "List platform taxonomy with tenant extensions", + new SystemScopeRequest( + tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(TaxonomyService), + "List platform taxonomy with tenant extensions", Guid.NewGuid().ToString("N")), async (provider, token) => { var systemDbContext = provider.GetRequiredService(); @@ -85,8 +86,9 @@ public sealed class TaxonomyService( _ => throw new InvalidOperationException("A parent source is required when parentId is provided.") }; parent = await tenantExecutionScope.ExecuteAsync( - tenantId, - "Validate taxonomy extension parent ownership", + new SystemScopeRequest( + tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(TaxonomyService), + "Validate taxonomy extension parent ownership", Guid.NewGuid().ToString("N")), async (provider, token) => { var systemDbContext = provider.GetRequiredService(); @@ -137,8 +139,9 @@ public sealed class TaxonomyService( { await accessPolicy.EnsureCanStartAsync(tenantId, cancellationToken); return await tenantExecutionScope.ExecuteAsync( - tenantId, - "Resolve platform taxonomy owner", + new SystemScopeRequest( + tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(TaxonomyService), + "Resolve platform taxonomy owner", Guid.NewGuid().ToString("N")), async (provider, token) => await provider.GetRequiredService() .Tenants.AsNoTracking() .Where(tenant => tenant.Mode == TenantMode.PlatformOwned) diff --git a/Tiku.Infrastructure/Content/DirectContentService.cs b/Tiku.Infrastructure/Content/DirectContentService.cs index 9409871..5609a49 100644 --- a/Tiku.Infrastructure/Content/DirectContentService.cs +++ b/Tiku.Infrastructure/Content/DirectContentService.cs @@ -40,7 +40,9 @@ public sealed class DirectContentService( CancellationToken cancellationToken = default) { await AssertQuestionReferencesAsync(actor.TenantId, command, cancellationToken); - await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + await using var transaction = dbContext.Database.CurrentTransaction is null + ? await dbContext.Database.BeginTransactionAsync(cancellationToken) + : null; var question = new Question { @@ -57,7 +59,10 @@ public sealed class DirectContentService( await SyncPrimaryCollectionItemAsync(actor, question, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + } return new ContentManagementResult(ToQuestionItem(question, version)); } diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs index 942f062..058283a 100644 --- a/Tiku.Infrastructure/DependencyInjection.cs +++ b/Tiku.Infrastructure/DependencyInjection.cs @@ -44,6 +44,9 @@ using Tiku.Infrastructure.StudyContent; using Tiku.Infrastructure.TenantAdmin; using Tiku.Infrastructure.Tenancy; using Tiku.Domain.Identity; +using StackExchange.Redis; +using MassTransit; +using Tiku.Infrastructure.Messaging; namespace Tiku.Infrastructure; @@ -83,6 +86,8 @@ public static class DependencyInjection .AddPasswordValidator>(); services.Configure(options => options.IterationCount = 210_000); services.AddScoped(); + services.AddSingleton(); + services.AddScoped(); services.AddMemoryCache(); services.AddScoped(); services.AddScoped(); @@ -121,6 +126,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -150,4 +156,78 @@ public static class DependencyInjection return services; } + + public static IServiceCollection AddRedisSecurity( + this IServiceCollection services, + string connectionString, + string environmentName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(connectionString); + var options = ConfigurationOptions.Parse(connectionString); + options.AbortOnConnectFail = false; + options.ClientName = $"tiku-{environmentName.ToLowerInvariant()}"; + services.AddSingleton(_ => ConnectionMultiplexer.Connect(options)); + services.AddSingleton(provider => new RedisSecurityStore( + provider.GetRequiredService(), + environmentName, + provider.GetRequiredService>())); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddStackExchangeRedisCache(cache => cache.ConfigurationOptions = options); + return services; + } + + public static IServiceCollection AddReliableMessaging( + this IServiceCollection services, + MessagingOptions options) + { + ArgumentNullException.ThrowIfNull(options); + if (!options.IsConfigured) + { + throw new ArgumentException("A valid RabbitMQ host URI is required.", nameof(options)); + } + + services.AddMassTransit(registration => + { + registration.SetKebabCaseEndpointNameFormatter(); + registration.ConfigureHealthCheckOptions(health => + { + health.Name = "rabbitmq"; + health.Tags.Add("ready"); + }); + registration.AddEntityFrameworkOutbox(outbox => + { + outbox.UsePostgres(); + outbox.UseBusOutbox(); + outbox.QueryDelay = TimeSpan.FromSeconds(1); + outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30); + }); + if (options.ConfigureConsumers) + { + registration.AddConsumer(consumer => + { + consumer.ConcurrentMessageLimit = 1; + consumer.UseMessageRetry(retry => retry.Intervals( + TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15))); + }); + registration.AddConfigureEndpointsCallback((context, _, endpoint) => + { + endpoint.PrefetchCount = 1; + endpoint.ConcurrentMessageLimit = 1; + endpoint.UseEntityFrameworkOutbox(context); + }); + } + + registration.UsingRabbitMq((context, configurator) => + { + configurator.Host(new Uri(options.Host), options.VirtualHost, host => + { + if (!string.IsNullOrWhiteSpace(options.Username)) host.Username(options.Username); + if (!string.IsNullOrWhiteSpace(options.Password)) host.Password(options.Password); + }); + configurator.ConfigureEndpoints(context); + }); + }); + services.AddScoped(); + return services; + } } diff --git a/Tiku.Infrastructure/Jobs/BackgroundJobService.cs b/Tiku.Infrastructure/Jobs/BackgroundJobService.cs index 930e303..6e80148 100644 --- a/Tiku.Infrastructure/Jobs/BackgroundJobService.cs +++ b/Tiku.Infrastructure/Jobs/BackgroundJobService.cs @@ -16,7 +16,8 @@ namespace Tiku.Infrastructure.Jobs; internal sealed class BackgroundJobService( TikuDbContext dbContext, IServiceProvider serviceProvider, - ITenantExecutionScope tenantExecutionScope) : IBackgroundJobService + ITenantExecutionScope tenantExecutionScope, + ICapabilityAccessEvaluator capabilityAccessEvaluator) : IBackgroundJobService { private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(5); @@ -24,10 +25,19 @@ internal sealed class BackgroundJobService( CreateBackgroundJobCommand command, CancellationToken cancellationToken = default) { + var normalizedJobType = NormalizeJobType(command.JobType); + if (!await capabilityAccessEvaluator.IsAllowedAsync( + command.TenantId, + ResolveCapabilityModule(normalizedJobType), + CapabilityOperation.Write, + cancellationToken)) + { + throw new InvalidOperationException("Tenant capability does not allow this background job."); + } var job = new BackgroundJob { TenantId = command.TenantId, - JobType = NormalizeJobType(command.JobType), + JobType = normalizedJobType, Payload = command.Payload, RunAfter = command.RunAfter, MaxRetries = Math.Clamp(command.MaxRetries, 0, 20) @@ -55,6 +65,19 @@ internal sealed class BackgroundJobService( foreach (var job in jobs) { cancellationToken.ThrowIfCancellationRequested(); + if (!await capabilityAccessEvaluator.IsAllowedAsync( + job.TenantId, + ResolveCapabilityModule(job.JobType), + CapabilityOperation.Write, + cancellationToken)) + { + job.Status = BackgroundJobStatus.Failed; + job.CompletedAt = DateTimeOffset.UtcNow; + job.LastError = "Tenant capability was revoked before job execution."; + await dbContext.SaveChangesAsync(cancellationToken); + processed++; + continue; + } job.Status = BackgroundJobStatus.Processing; job.LockedBy = workerId; job.LockExpiresAt = now.Add(LeaseDuration); @@ -64,8 +87,9 @@ internal sealed class BackgroundJobService( try { var result = await tenantExecutionScope.ExecuteAsync( - job.TenantId, - $"Background job {job.JobType}", + new SystemScopeRequest( + job.TenantId, SystemScopeCallerType.Worker, workerId, + $"Background job {job.JobType}", job.Id.ToString("N")), (provider, token) => ProcessCoreAsync(provider, job, token), cancellationToken); job.Status = BackgroundJobStatus.Succeeded; @@ -328,6 +352,15 @@ internal sealed class BackgroundJobService( return jobType.Trim().ToLowerInvariant(); } + private static string ResolveCapabilityModule(string jobType) => jobType switch + { + "content_export" or "content_import" or "asset_security_scan" => "content", + "statistics_aggregation" => "dashboard", + "commerce_reconciliation" => "commerce", + "tenant_domain_recheck" => "settings", + _ => "job" + }; + private static string NormalizeProvider(string? provider) { var normalized = (provider ?? string.Empty).Trim().ToLowerInvariant(); diff --git a/Tiku.Infrastructure/Learning/LearningActivityService.cs b/Tiku.Infrastructure/Learning/LearningActivityService.cs index cb0ad2d..f1aacbf 100644 --- a/Tiku.Infrastructure/Learning/LearningActivityService.cs +++ b/Tiku.Infrastructure/Learning/LearningActivityService.cs @@ -1024,8 +1024,9 @@ public sealed class LearningActivityService( CancellationToken cancellationToken) { var rows = await tenantExecutionScope.ExecuteAsync( - tenantId, - "Lock published question versions for a new practice session", + new SystemScopeRequest( + tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(LearningActivityService), + "Lock published question versions for a new practice session", Guid.NewGuid().ToString("N")), async (provider, token) => { var systemDbContext = provider.GetRequiredService(); @@ -1076,8 +1077,9 @@ public sealed class LearningActivityService( CancellationToken cancellationToken) { return tenantExecutionScope.ExecuteAsync( - tenantId, - "Read locked question versions for a tenant practice session", + new SystemScopeRequest( + tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(LearningActivityService), + "Read locked question versions for a tenant practice session", Guid.NewGuid().ToString("N")), async (provider, token) => { var systemDbContext = provider.GetRequiredService(); diff --git a/Tiku.Infrastructure/Messaging/MessagingOptions.cs b/Tiku.Infrastructure/Messaging/MessagingOptions.cs new file mode 100644 index 0000000..9f33acf --- /dev/null +++ b/Tiku.Infrastructure/Messaging/MessagingOptions.cs @@ -0,0 +1,13 @@ +namespace Tiku.Infrastructure.Messaging; + +public sealed class MessagingOptions +{ + public string Host { get; set; } = string.Empty; + public string VirtualHost { get; set; } = "/"; + public string Username { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public bool ConfigureConsumers { get; set; } + + public bool IsConfigured => Uri.TryCreate(Host, UriKind.Absolute, out var uri) && + uri.Scheme is "rabbitmq" or "amqp" or "amqps"; +} diff --git a/Tiku.Infrastructure/Messaging/SecurityEventPublisher.cs b/Tiku.Infrastructure/Messaging/SecurityEventPublisher.cs new file mode 100644 index 0000000..9bc0ad8 --- /dev/null +++ b/Tiku.Infrastructure/Messaging/SecurityEventPublisher.cs @@ -0,0 +1,70 @@ +using MassTransit; +using Tiku.Contracts; + +namespace Tiku.Infrastructure.Messaging; + +public interface ISecurityEventPublisher +{ + Task AuthorizationChangedAsync( + Guid? tenantId, + Guid? userId, + string changeKind, + long version, + string correlationId, + CancellationToken cancellationToken = default); + + Task CapabilityChangedAsync( + Guid tenantId, + string moduleCode, + string changeKind, + long version, + string correlationId, + CancellationToken cancellationToken = default); + + Task MembershipChangedAsync( + Guid tenantId, + Guid userId, + string previousStatus, + string currentStatus, + string correlationId, + CancellationToken cancellationToken = default); +} + +internal sealed class NullSecurityEventPublisher : ISecurityEventPublisher +{ + public Task AuthorizationChangedAsync( + Guid? tenantId, Guid? userId, string changeKind, long version, + string correlationId, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task CapabilityChangedAsync( + Guid tenantId, string moduleCode, string changeKind, long version, + string correlationId, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task MembershipChangedAsync( + Guid tenantId, Guid userId, string previousStatus, string currentStatus, + string correlationId, CancellationToken cancellationToken = default) => Task.CompletedTask; +} + +internal sealed class MassTransitSecurityEventPublisher(IPublishEndpoint publishEndpoint) : ISecurityEventPublisher +{ + public Task AuthorizationChangedAsync( + Guid? tenantId, Guid? userId, string changeKind, long version, + string correlationId, CancellationToken cancellationToken = default) => + publishEndpoint.Publish(new AuthorizationStateChangedV1( + Guid.NewGuid(), tenantId, userId, changeKind, version, + DateTimeOffset.UtcNow, correlationId), cancellationToken); + + public Task CapabilityChangedAsync( + Guid tenantId, string moduleCode, string changeKind, long version, + string correlationId, CancellationToken cancellationToken = default) => + publishEndpoint.Publish(new TenantCapabilityChangedV1( + Guid.NewGuid(), tenantId, moduleCode, changeKind, version, + DateTimeOffset.UtcNow, correlationId), cancellationToken); + + public Task MembershipChangedAsync( + Guid tenantId, Guid userId, string previousStatus, string currentStatus, + string correlationId, CancellationToken cancellationToken = default) => + publishEndpoint.Publish(new MembershipLifecycleChangedV1( + Guid.NewGuid(), tenantId, userId, previousStatus, currentStatus, + DateTimeOffset.UtcNow, correlationId), cancellationToken); +} diff --git a/Tiku.Infrastructure/Messaging/SecurityStateChangedConsumer.cs b/Tiku.Infrastructure/Messaging/SecurityStateChangedConsumer.cs new file mode 100644 index 0000000..2c3e514 --- /dev/null +++ b/Tiku.Infrastructure/Messaging/SecurityStateChangedConsumer.cs @@ -0,0 +1,26 @@ +using MassTransit; +using Tiku.Application.Security; +using Tiku.Contracts; + +namespace Tiku.Infrastructure.Messaging; + +internal sealed class SecurityStateChangedConsumer(IRedisSecurityStore redisSecurityStore) : + IConsumer, + IConsumer, + IConsumer +{ + public Task Consume(ConsumeContext context) => + redisSecurityStore.SetInvalidationVersionAsync( + "authorization", context.Message.TenantId, context.Message.UserId, + context.Message.Version, context.CancellationToken); + + public Task Consume(ConsumeContext context) => + redisSecurityStore.SetInvalidationVersionAsync( + $"capability-{context.Message.ModuleCode}", context.Message.TenantId, null, + context.Message.Version, context.CancellationToken); + + public Task Consume(ConsumeContext context) => + redisSecurityStore.SetInvalidationVersionAsync( + "membership", context.Message.TenantId, context.Message.UserId, + context.Message.OccurredAt.ToUnixTimeMilliseconds(), context.CancellationToken); +} diff --git a/Tiku.Infrastructure/Persistence/Configurations/PlatformOperationsConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/PlatformOperationsConfigurations.cs index 7b7df36..59da03c 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/PlatformOperationsConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/PlatformOperationsConfigurations.cs @@ -27,6 +27,56 @@ internal sealed class PlatformSaasPlanConfiguration : IEntityTypeConfiguration

+{ + public void Configure(EntityTypeBuilder builder) + { + builder.ConfigureEntity("product_modules"); + builder.ConfigureTimestamps(); + builder.Property(entity => entity.Code).HasMaxLength(100); + builder.Property(entity => entity.Name).HasMaxLength(200); + builder.Property(entity => entity.Description).HasMaxLength(1000); + builder.Property(entity => entity.Status).HasSnakeCaseEnum(); + builder.HasIndex(entity => entity.Code).IsUnique(); + } +} + +internal sealed class PlanModuleEntitlementConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ConfigureEntity("plan_module_entitlements"); + builder.Property(entity => entity.PlanCode).HasMaxLength(100); + builder.Property(entity => entity.ModuleCode).HasMaxLength(100); + builder.HasIndex(entity => new { entity.PlanCode, entity.ModuleCode }).IsUnique(); + builder.HasOne().WithMany() + .HasPrincipalKey(entity => entity.Code) + .HasForeignKey(entity => entity.PlanCode) + .OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany() + .HasPrincipalKey(entity => entity.Code) + .HasForeignKey(entity => entity.ModuleCode) + .OnDelete(DeleteBehavior.Cascade); + } +} + +internal sealed class TenantModuleOverrideConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ConfigureTenantEntity("tenant_module_overrides"); + builder.ConfigureTimestamps(); + builder.Property(entity => entity.ModuleCode).HasMaxLength(100); + builder.Property(entity => entity.Mode).HasSnakeCaseEnum(); + builder.Property(entity => entity.Reason).HasMaxLength(1000); + builder.HasIndex(entity => new { entity.TenantId, entity.ModuleCode }).IsUnique(); + builder.HasOne().WithMany() + .HasPrincipalKey(entity => entity.Code) + .HasForeignKey(entity => entity.ModuleCode) + .OnDelete(DeleteBehavior.Restrict); + } +} + internal sealed class TenantBillingProfileConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) diff --git a/Tiku.Infrastructure/Persistence/Configurations/TenancyConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/TenancyConfigurations.cs index 2fadca1..2bc3c9b 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/TenancyConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/TenancyConfigurations.cs @@ -115,6 +115,20 @@ internal sealed class TenantSettingsConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("tenant_auth_policies"); + builder.HasKey(entity => entity.TenantId); + builder.ConfigureTimestamps(); + builder.Property(entity => entity.AllowExternalStudentSelfRegistration).HasDefaultValue(false); + builder.HasOne().WithOne() + .HasForeignKey(entity => entity.TenantId) + .OnDelete(DeleteBehavior.Cascade); + } +} + internal sealed class TenantFrontendConfigConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) diff --git a/Tiku.Infrastructure/Persistence/Migrations/20260729012205_AddDistributedSecurityFoundation.Designer.cs b/Tiku.Infrastructure/Persistence/Migrations/20260729012205_AddDistributedSecurityFoundation.Designer.cs new file mode 100644 index 0000000..952db6b --- /dev/null +++ b/Tiku.Infrastructure/Persistence/Migrations/20260729012205_AddDistributedSecurityFoundation.Designer.cs @@ -0,0 +1,18569 @@ +// +using System; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Tiku.Infrastructure.Persistence; + +#nullable disable + +namespace Tiku.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(TikuDbContext))] + [Migration("20260729012205_AddDistributedSecurityFoundation")] + partial class AddDistributedSecurityFoundation + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext"); + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "ltree"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone") + .HasColumnName("consumed"); + + b.Property("ConsumerId") + .HasColumnType("uuid") + .HasColumnName("consumer_id"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone") + .HasColumnName("delivered"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_time"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint") + .HasColumnName("last_sequence_number"); + + b.Property("LockId") + .HasColumnType("uuid") + .HasColumnName("lock_id"); + + b.Property("MessageId") + .HasColumnType("uuid") + .HasColumnName("message_id"); + + b.Property("ReceiveCount") + .HasColumnType("integer") + .HasColumnName("receive_count"); + + b.Property("Received") + .HasColumnType("timestamp with time zone") + .HasColumnName("received"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_inbox_state"); + + b.HasAlternateKey("MessageId", "ConsumerId") + .HasName("ak_inbox_state_message_id_consumer_id"); + + b.HasIndex("Delivered") + .HasDatabaseName("ix_inbox_state_delivered"); + + b.ToTable("inbox_state", (string)null); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("sequence_number"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text") + .HasColumnName("body"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("content_type"); + + b.Property("ConversationId") + .HasColumnType("uuid") + .HasColumnName("conversation_id"); + + b.Property("CorrelationId") + .HasColumnType("uuid") + .HasColumnName("correlation_id"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("destination_address"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("enqueue_time"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_time"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("fault_address"); + + b.Property("Headers") + .HasColumnType("text") + .HasColumnName("headers"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid") + .HasColumnName("inbox_consumer_id"); + + b.Property("InboxMessageId") + .HasColumnType("uuid") + .HasColumnName("inbox_message_id"); + + b.Property("InitiatorId") + .HasColumnType("uuid") + .HasColumnName("initiator_id"); + + b.Property("MessageId") + .HasColumnType("uuid") + .HasColumnName("message_id"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("message_type"); + + b.Property("OutboxId") + .HasColumnType("uuid") + .HasColumnName("outbox_id"); + + b.Property("Properties") + .HasColumnType("text") + .HasColumnName("properties"); + + b.Property("RequestId") + .HasColumnType("uuid") + .HasColumnName("request_id"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("response_address"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("sent_time"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("source_address"); + + b.HasKey("SequenceNumber") + .HasName("pk_outbox_message"); + + b.HasIndex("EnqueueTime") + .HasDatabaseName("ix_outbox_message_enqueue_time"); + + b.HasIndex("ExpirationTime") + .HasDatabaseName("ix_outbox_message_expiration_time"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique() + .HasDatabaseName("ix_outbox_message_outbox_id_sequence_number"); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique() + .HasDatabaseName("ix_outbox_message_inbox_message_id_inbox_consumer_id_sequence_~"); + + b.ToTable("outbox_message", (string)null); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("outbox_id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone") + .HasColumnName("delivered"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint") + .HasColumnName("last_sequence_number"); + + b.Property("LockId") + .HasColumnType("uuid") + .HasColumnName("lock_id"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("row_version"); + + b.HasKey("OutboxId") + .HasName("pk_outbox_state"); + + b.HasIndex("Created") + .HasDatabaseName("ix_outbox_state_created"); + + b.ToTable("outbox_state", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text") + .HasColumnName("friendly_name"); + + b.Property("Xml") + .HasColumnType("text") + .HasColumnName("xml"); + + b.HasKey("Id") + .HasName("pk_data_protection_keys"); + + b.ToTable("data_protection_keys", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_claims_user_id"); + + b.ToTable("user_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key"); + + b.Property("ProviderDisplayName") + .HasColumnType("text") + .HasColumnName("provider_display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_user_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_logins_user_id"); + + b.ToTable("user_logins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_user_tokens"); + + b.ToTable("user_tokens", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CategoryType") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("category_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("NodeId") + .HasColumnType("uuid") + .HasColumnName("node_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("SubjectId") + .HasColumnType("uuid") + .HasColumnName("subject_id"); + + b.Property("SvipQuestionLimit") + .HasColumnType("integer") + .HasColumnName("svip_question_limit"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_categories"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_categories_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_categories_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "NodeId") + .HasDatabaseName("ix_categories_tenant_id_node_id"); + + b.HasIndex("TenantId", "SubjectId") + .HasDatabaseName("ix_categories_tenant_id_subject_id"); + + b.ToTable("categories", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.Major", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SchoolId") + .HasColumnType("uuid") + .HasColumnName("school_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("StudyTips") + .HasColumnType("text") + .HasColumnName("study_tips"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_majors"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_majors_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_majors_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_majors_tenant_id_region_id"); + + b.HasIndex("TenantId", "SchoolId") + .HasDatabaseName("ix_majors_tenant_id_school_id"); + + b.ToTable("majors", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.ModuleNode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("LegacyModuleId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_module_id"); + + b.Property("LegacyParentId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_parent_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("ModuleId") + .HasColumnType("uuid") + .HasColumnName("module_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("ParentId") + .HasColumnType("uuid") + .HasColumnName("parent_id"); + + b.Property("Path") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("path"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_module_nodes"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_module_nodes_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_module_nodes_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "ModuleId") + .HasDatabaseName("ix_module_nodes_tenant_id_module_id"); + + b.HasIndex("TenantId", "ParentId") + .HasDatabaseName("ix_module_nodes_tenant_id_parent_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_module_nodes_tenant_id_region_id"); + + b.ToTable("module_nodes", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.QuestionTaxonomyAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("IsPrimary") + .HasColumnType("boolean") + .HasColumnName("is_primary"); + + b.Property("QuestionId") + .HasColumnType("uuid") + .HasColumnName("question_id"); + + b.Property("TaxonomyNodeId") + .HasColumnType("uuid") + .HasColumnName("taxonomy_node_id"); + + b.Property("TaxonomyOwnerTenantId") + .HasColumnType("uuid") + .HasColumnName("taxonomy_owner_tenant_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id") + .HasName("pk_question_taxonomy_assignments"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_question_taxonomy_assignments_tenant_id_id"); + + b.HasIndex("TaxonomyOwnerTenantId", "TaxonomyNodeId") + .HasDatabaseName("ix_question_taxonomy_assignments_taxonomy_owner_tenant_id_taxo~"); + + b.HasIndex("TenantId", "QuestionId", "TaxonomyOwnerTenantId", "TaxonomyNodeId") + .IsUnique() + .HasDatabaseName("ix_question_taxonomy_assignments_tenant_id_question_id_taxonom~"); + + b.ToTable("question_taxonomy_assignments", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Code") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("code"); + + b.Property("Config") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("config") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("FullName") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("full_name"); + + b.Property("Icon") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("icon"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsHot") + .HasColumnType("boolean") + .HasColumnName("is_hot"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Pinyin") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("pinyin"); + + b.Property("ShortName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("short_name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_regions"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_regions_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_regions_tenant_id_legacy_id"); + + b.ToTable("regions", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.RegionModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Color") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("color"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Icon") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("icon"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsPrimarySchoolModule") + .HasColumnType("boolean") + .HasColumnName("is_primary_school_module"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("Route") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("route"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TextColor") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("text_color"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_region_modules"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_region_modules_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_region_modules_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_region_modules_tenant_id_region_id"); + + b.ToTable("region_modules", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.School", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("ModuleId") + .HasColumnType("uuid") + .HasColumnName("module_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("ProfessionalExamDate") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("professional_exam_date"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_schools"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_schools_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_schools_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "ModuleId") + .HasDatabaseName("ix_schools_tenant_id_module_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_schools_tenant_id_region_id"); + + b.ToTable("schools", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.ScorelineField", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("FieldKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("field_key"); + + b.Property("FieldName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("field_name"); + + b.Property("FieldType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("field_type"); + + b.Property("IsFilter") + .HasColumnType("boolean") + .HasColumnName("is_filter"); + + b.Property("IsRequired") + .HasColumnType("boolean") + .HasColumnName("is_required"); + + b.Property("IsTrend") + .HasColumnType("boolean") + .HasColumnName("is_trend"); + + b.Property("IsVisible") + .HasColumnType("boolean") + .HasColumnName("is_visible"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Options") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("options") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("Placeholder") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("placeholder"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Unit") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("unit"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_scoreline_fields"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_scoreline_fields_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_scoreline_fields_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "RegionId", "FieldKey") + .IsUnique() + .HasDatabaseName("ix_scoreline_fields_tenant_id_region_id_field_key"); + + b.HasIndex("TenantId", "RegionId", "IsFilter", "SortOrder") + .HasDatabaseName("ix_scoreline_fields_tenant_id_region_id_is_filter_sort_order"); + + b.ToTable("scoreline_fields", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.ScorelineRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("FieldValues") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("field_values") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("MajorId") + .HasColumnType("uuid") + .HasColumnName("major_id"); + + b.Property("MajorName") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("major_name"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SchoolId") + .HasColumnType("uuid") + .HasColumnName("school_id"); + + b.Property("SchoolName") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("school_name"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("Year") + .HasColumnType("integer") + .HasColumnName("year"); + + b.HasKey("Id") + .HasName("pk_scoreline_records"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_scoreline_records_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_scoreline_records_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "MajorId") + .HasDatabaseName("ix_scoreline_records_tenant_id_major_id"); + + b.HasIndex("TenantId", "SchoolId") + .HasDatabaseName("ix_scoreline_records_tenant_id_school_id"); + + b.HasIndex("TenantId", "Year") + .HasDatabaseName("ix_scoreline_records_tenant_id_year"); + + b.HasIndex("TenantId", "RegionId", "SchoolId", "MajorId", "Year") + .HasDatabaseName("ix_scoreline_records_tenant_id_region_id_school_id_major_id_ye~"); + + b.ToTable("scoreline_records", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.Subject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Icon") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("icon"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("MajorId") + .HasColumnType("uuid") + .HasColumnName("major_id"); + + b.Property("MajorLegacyIds") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("major_legacy_ids") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("ModuleId") + .HasColumnType("uuid") + .HasColumnName("module_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("NodeId") + .HasColumnType("uuid") + .HasColumnName("node_id"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SchoolId") + .HasColumnType("uuid") + .HasColumnName("school_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Stats") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("stats") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Type") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_subjects"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_subjects_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_subjects_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "MajorId") + .HasDatabaseName("ix_subjects_tenant_id_major_id"); + + b.HasIndex("TenantId", "ModuleId") + .HasDatabaseName("ix_subjects_tenant_id_module_id"); + + b.HasIndex("TenantId", "NodeId") + .HasDatabaseName("ix_subjects_tenant_id_node_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_subjects_tenant_id_region_id"); + + b.HasIndex("TenantId", "SchoolId") + .HasDatabaseName("ix_subjects_tenant_id_school_id"); + + b.ToTable("subjects", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.TaxonomyNode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Depth") + .HasColumnType("integer") + .HasColumnName("depth"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("NodeType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("node_type"); + + b.Property("ParentId") + .HasColumnType("uuid") + .HasColumnName("parent_id"); + + b.Property("ParentOwnerTenantId") + .HasColumnType("uuid") + .HasColumnName("parent_owner_tenant_id"); + + b.Property("Path") + .HasColumnType("ltree") + .HasColumnName("path"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_taxonomy_nodes"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_taxonomy_nodes_tenant_id_id"); + + b.HasIndex("ParentOwnerTenantId", "ParentId") + .HasDatabaseName("ix_taxonomy_nodes_parent_owner_tenant_id_parent_id"); + + b.HasIndex("TenantId", "Code") + .IsUnique() + .HasDatabaseName("ix_taxonomy_nodes_tenant_id_code"); + + b.ToTable("taxonomy_nodes", null, t => + { + t.HasCheckConstraint("ck_taxonomy_nodes_parent_pair", "(parent_owner_tenant_id is null) = (parent_id is null)"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.ActivationCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AgentUserId") + .HasColumnType("uuid") + .HasColumnName("agent_user_id"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext") + .HasColumnName("code"); + + b.Property("CouponCode") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("coupon_code"); + + b.Property("CouponRedemptionId") + .HasColumnType("uuid") + .HasColumnName("coupon_redemption_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Days") + .HasColumnType("integer") + .HasColumnName("days"); + + b.Property("IsUsed") + .HasColumnType("boolean") + .HasColumnName("is_used"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Remark") + .HasColumnType("text") + .HasColumnName("remark"); + + b.Property("SaleType") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("sale_type"); + + b.Property("SoldTo") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("sold_to"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UnitPriceCents") + .HasColumnType("integer") + .HasColumnName("unit_price_cents"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UsedBy") + .HasColumnType("uuid") + .HasColumnName("used_by"); + + b.Property("UsedRegionId") + .HasColumnType("uuid") + .HasColumnName("used_region_id"); + + b.HasKey("Id") + .HasName("pk_activation_codes"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_activation_codes_tenant_id_id"); + + b.HasIndex("AgentUserId") + .HasDatabaseName("ix_activation_codes_agent_user_id"); + + b.HasIndex("UsedBy") + .HasDatabaseName("ix_activation_codes_used_by"); + + b.HasIndex("TenantId", "BatchId") + .HasDatabaseName("ix_activation_codes_tenant_id_batch_id"); + + b.HasIndex("TenantId", "Code") + .IsUnique() + .HasDatabaseName("ix_activation_codes_tenant_id_code"); + + b.HasIndex("TenantId", "CouponRedemptionId") + .HasDatabaseName("ix_activation_codes_tenant_id_coupon_redemption_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_activation_codes_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "UsedRegionId") + .HasDatabaseName("ix_activation_codes_tenant_id_used_region_id"); + + b.ToTable("activation_codes", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.CodeBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CampaignName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("campaign_name"); + + b.Property("Channel") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("channel"); + + b.Property("CommissionRate") + .HasPrecision(6, 4) + .HasColumnType("numeric(6,4)") + .HasColumnName("commission_rate"); + + b.Property("CostPriceCents") + .HasColumnType("integer") + .HasColumnName("cost_price_cents"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("Days") + .HasColumnType("integer") + .HasColumnName("days"); + + b.Property("DefaultUnitPriceCents") + .HasColumnType("integer") + .HasColumnName("default_unit_price_cents"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("issued_at"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("LegacyRegionId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_region_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("Remark") + .HasColumnType("text") + .HasColumnName("remark"); + + b.Property("SaleType") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("sale_type"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TotalCount") + .HasColumnType("integer") + .HasColumnName("total_count"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_code_batches"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_code_batches_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_code_batches_tenant_id_legacy_id"); + + b.ToTable("code_batches", null, t => + { + t.HasCheckConstraint("ck_code_batches_amounts", "default_unit_price_cents >= 0 and cost_price_cents >= 0"); + + t.HasCheckConstraint("ck_code_batches_counts", "total_count >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.CommerceAdjustmentVoucher", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AmountCents") + .HasColumnType("integer") + .HasColumnName("amount_cents"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("ClosedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("closed_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("currency"); + + b.Property("Direction") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("direction"); + + b.Property("IssueId") + .HasColumnType("uuid") + .HasColumnName("issue_id"); + + b.Property("ItemId") + .HasColumnType("uuid") + .HasColumnName("item_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("OrderId") + .HasColumnType("uuid") + .HasColumnName("order_id"); + + b.Property("PaymentId") + .HasColumnType("uuid") + .HasColumnName("payment_id"); + + b.Property("ProofAssetKey") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("proof_asset_key"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("reason"); + + b.Property("RefundRequestId") + .HasColumnType("uuid") + .HasColumnName("refund_request_id"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("reviewed_at"); + + b.Property("ReviewedBy") + .HasColumnType("uuid") + .HasColumnName("reviewed_by"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("VoucherNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("voucher_no"); + + b.HasKey("Id") + .HasName("pk_commerce_adjustment_vouchers"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_commerce_adjustment_vouchers_tenant_id_id"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_commerce_adjustment_vouchers_created_by"); + + b.HasIndex("ReviewedBy") + .HasDatabaseName("ix_commerce_adjustment_vouchers_reviewed_by"); + + b.HasIndex("TenantId", "BatchId") + .HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_batch_id"); + + b.HasIndex("TenantId", "ItemId") + .HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_item_id"); + + b.HasIndex("TenantId", "OrderId") + .HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_order_id"); + + b.HasIndex("TenantId", "PaymentId") + .HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_payment_id"); + + b.HasIndex("TenantId", "RefundRequestId") + .HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_refund_request_id"); + + b.HasIndex("TenantId", "VoucherNo") + .IsUnique() + .HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_voucher_no"); + + b.HasIndex("TenantId", "IssueId", "CreatedAt") + .HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_issue_id_created_at"); + + b.HasIndex("TenantId", "Status", "CreatedAt") + .HasDatabaseName("ix_commerce_adjustment_vouchers_tenant_id_status_created_at"); + + b.ToTable("commerce_adjustment_vouchers", null, t => + { + t.HasCheckConstraint("ck_commerce_adjustment_vouchers_amount", "amount_cents > 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.CommerceAdjustmentVoucherEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Details") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("details") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("FromStatus") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("from_status"); + + b.Property("Note") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("note"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("ToStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("to_status"); + + b.Property("VoucherId") + .HasColumnType("uuid") + .HasColumnName("voucher_id"); + + b.HasKey("Id") + .HasName("pk_commerce_adjustment_voucher_events"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_commerce_adjustment_voucher_events_tenant_id_id"); + + b.HasIndex("ActorUserId") + .HasDatabaseName("ix_commerce_adjustment_voucher_events_actor_user_id"); + + b.HasIndex("TenantId", "VoucherId", "CreatedAt") + .HasDatabaseName("ix_commerce_adjustment_voucher_events_tenant_id_voucher_id_cre~"); + + b.ToTable("commerce_adjustment_voucher_events", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.CommerceReconciliationBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AmountCents") + .HasColumnType("integer") + .HasColumnName("amount_cents"); + + b.Property("BillDate") + .HasColumnType("date") + .HasColumnName("bill_date"); + + b.Property("BillType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("bill_type"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DuplicateCount") + .HasColumnType("integer") + .HasColumnName("duplicate_count"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("FeeCents") + .HasColumnType("integer") + .HasColumnName("fee_cents"); + + b.Property("IgnoredCount") + .HasColumnType("integer") + .HasColumnName("ignored_count"); + + b.Property("MatchedCount") + .HasColumnType("integer") + .HasColumnName("matched_count"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("MismatchCount") + .HasColumnType("integer") + .HasColumnName("mismatch_count"); + + b.Property("MissingLocalCount") + .HasColumnType("integer") + .HasColumnName("missing_local_count"); + + b.Property("MissingProviderCount") + .HasColumnType("integer") + .HasColumnName("missing_provider_count"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("provider"); + + b.Property("RefundAmountCents") + .HasColumnType("integer") + .HasColumnName("refund_amount_cents"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("source"); + + b.Property("SourceHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("source_hash"); + + b.Property("SourceName") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("source_name"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TotalCount") + .HasColumnType("integer") + .HasColumnName("total_count"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_commerce_reconciliation_batches"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_commerce_reconciliation_batches_tenant_id_id"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_commerce_reconciliation_batches_created_by"); + + b.HasIndex("TenantId", "Status", "CreatedAt") + .HasDatabaseName("ix_commerce_reconciliation_batches_tenant_id_status_created_at"); + + b.HasIndex("TenantId", "Provider", "BillDate", "CreatedAt") + .HasDatabaseName("ix_commerce_reconciliation_batches_tenant_id_provider_bill_dat~"); + + b.ToTable("commerce_reconciliation_batches", null, t => + { + t.HasCheckConstraint("ck_commerce_reconciliation_batches_amounts", "amount_cents >= 0 and refund_amount_cents >= 0"); + + t.HasCheckConstraint("ck_commerce_reconciliation_batches_counts", "total_count >= 0 and matched_count >= 0 and mismatch_count >= 0 and missing_local_count >= 0 and missing_provider_count >= 0 and duplicate_count >= 0 and ignored_count >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.CommerceReconciliationIssue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AmountCents") + .HasColumnType("integer") + .HasColumnName("amount_cents"); + + b.Property("AssignedTo") + .HasColumnType("uuid") + .HasColumnName("assigned_to"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DueAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("due_at"); + + b.Property("IssueCode") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("issue_code"); + + b.Property("IssueNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("issue_no"); + + b.Property("ItemId") + .HasColumnType("uuid") + .HasColumnName("item_id"); + + b.Property("MatchStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("match_status"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("OrderId") + .HasColumnType("uuid") + .HasColumnName("order_id"); + + b.Property("OrderNo") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("order_no"); + + b.Property("PaymentId") + .HasColumnType("uuid") + .HasColumnName("payment_id"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("provider"); + + b.Property("ProviderRefundNo") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("provider_refund_no"); + + b.Property("ProviderTradeNo") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("provider_trade_no"); + + b.Property("RefundAmountCents") + .HasColumnType("integer") + .HasColumnName("refund_amount_cents"); + + b.Property("RefundNo") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("refund_no"); + + b.Property("RefundRequestId") + .HasColumnType("uuid") + .HasColumnName("refund_request_id"); + + b.Property("ResolutionNote") + .HasColumnType("text") + .HasColumnName("resolution_note"); + + b.Property("ResolutionType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("resolution_type"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.Property("ResolvedBy") + .HasColumnType("uuid") + .HasColumnName("resolved_by"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("severity"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("Summary") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("summary"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TransactionType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("transaction_type"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_commerce_reconciliation_issues"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_commerce_reconciliation_issues_tenant_id_id"); + + b.HasIndex("AssignedTo") + .HasDatabaseName("ix_commerce_reconciliation_issues_assigned_to"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_commerce_reconciliation_issues_created_by"); + + b.HasIndex("ResolvedBy") + .HasDatabaseName("ix_commerce_reconciliation_issues_resolved_by"); + + b.HasIndex("TenantId", "IssueNo") + .IsUnique() + .HasDatabaseName("ix_commerce_reconciliation_issues_tenant_id_issue_no"); + + b.HasIndex("TenantId", "ItemId") + .IsUnique() + .HasDatabaseName("ix_commerce_reconciliation_issues_tenant_id_item_id") + .HasFilter("item_id is not null and status in ('open', 'investigating', 'escalated')"); + + b.HasIndex("TenantId", "OrderId") + .HasDatabaseName("ix_commerce_reconciliation_issues_tenant_id_order_id"); + + b.HasIndex("TenantId", "RefundRequestId") + .HasDatabaseName("ix_commerce_reconciliation_issues_tenant_id_refund_request_id"); + + b.HasIndex("TenantId", "OrderNo", "CreatedAt") + .HasDatabaseName("ix_commerce_reconciliation_issues_tenant_id_order_no_created_at"); + + b.HasIndex("TenantId", "PaymentId", "CreatedAt") + .HasDatabaseName("ix_commerce_reconciliation_issues_tenant_id_payment_id_created~"); + + b.HasIndex("TenantId", "AssignedTo", "Status", "DueAt") + .HasDatabaseName("ix_commerce_reconciliation_issues_tenant_id_assigned_to_status~"); + + b.HasIndex("TenantId", "BatchId", "Status", "CreatedAt") + .HasDatabaseName("ix_commerce_reconciliation_issues_tenant_id_batch_id_status_cr~"); + + b.HasIndex("TenantId", "Status", "Severity", "CreatedAt") + .HasDatabaseName("ix_commerce_reconciliation_issues_tenant_id_status_severity_cr~"); + + b.ToTable("commerce_reconciliation_issues", null, t => + { + t.HasCheckConstraint("ck_commerce_reconciliation_issues_amounts", "amount_cents >= 0 and refund_amount_cents >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.CommerceReconciliationIssueEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Details") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("details") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("event_type"); + + b.Property("FromStatus") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("from_status"); + + b.Property("IssueId") + .HasColumnType("uuid") + .HasColumnName("issue_id"); + + b.Property("Note") + .HasColumnType("text") + .HasColumnName("note"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("ToStatus") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("to_status"); + + b.HasKey("Id") + .HasName("pk_commerce_reconciliation_issue_events"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_commerce_reconciliation_issue_events_tenant_id_id"); + + b.HasIndex("ActorUserId") + .HasDatabaseName("ix_commerce_reconciliation_issue_events_actor_user_id"); + + b.HasIndex("TenantId", "IssueId", "CreatedAt") + .HasDatabaseName("ix_commerce_reconciliation_issue_events_tenant_id_issue_id_cre~"); + + b.ToTable("commerce_reconciliation_issue_events", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.CommerceReconciliationItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AmountCents") + .HasColumnType("integer") + .HasColumnName("amount_cents"); + + b.Property("BatchId") + .HasColumnType("uuid") + .HasColumnName("batch_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Details") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("details") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("FeeCents") + .HasColumnType("integer") + .HasColumnName("fee_cents"); + + b.Property("IssueCode") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("issue_code"); + + b.Property("LocalStatus") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("local_status"); + + b.Property("MatchStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("match_status"); + + b.Property("OrderId") + .HasColumnType("uuid") + .HasColumnName("order_id"); + + b.Property("OrderNo") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("order_no"); + + b.Property("PaidAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("paid_at"); + + b.Property("PaymentId") + .HasColumnType("uuid") + .HasColumnName("payment_id"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("provider"); + + b.Property("ProviderRefundNo") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("provider_refund_no"); + + b.Property("ProviderStatus") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("provider_status"); + + b.Property("ProviderTradeNo") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("provider_trade_no"); + + b.Property("RefundAmountCents") + .HasColumnType("integer") + .HasColumnName("refund_amount_cents"); + + b.Property("RefundNo") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("refund_no"); + + b.Property("RefundRequestId") + .HasColumnType("uuid") + .HasColumnName("refund_request_id"); + + b.Property("RefundedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("refunded_at"); + + b.Property("RowNo") + .HasColumnType("integer") + .HasColumnName("row_no"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("severity"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TransactionType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("transaction_type"); + + b.HasKey("Id") + .HasName("pk_commerce_reconciliation_items"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_commerce_reconciliation_items_tenant_id_id"); + + b.HasIndex("TenantId", "OrderId") + .HasDatabaseName("ix_commerce_reconciliation_items_tenant_id_order_id"); + + b.HasIndex("TenantId", "PaymentId") + .HasDatabaseName("ix_commerce_reconciliation_items_tenant_id_payment_id"); + + b.HasIndex("TenantId", "RefundRequestId") + .HasDatabaseName("ix_commerce_reconciliation_items_tenant_id_refund_request_id"); + + b.HasIndex("TenantId", "BatchId", "RowNo") + .IsUnique() + .HasDatabaseName("ix_commerce_reconciliation_items_tenant_id_batch_id_row_no"); + + b.HasIndex("TenantId", "OrderNo", "CreatedAt") + .HasDatabaseName("ix_commerce_reconciliation_items_tenant_id_order_no_created_at"); + + b.HasIndex("TenantId", "BatchId", "MatchStatus", "RowNo") + .HasDatabaseName("ix_commerce_reconciliation_items_tenant_id_batch_id_match_stat~"); + + b.HasIndex("TenantId", "Provider", "ProviderTradeNo", "CreatedAt") + .HasDatabaseName("ix_commerce_reconciliation_items_tenant_id_provider_provider_t~"); + + b.HasIndex("TenantId", "RefundNo", "ProviderRefundNo", "CreatedAt") + .HasDatabaseName("ix_commerce_reconciliation_items_tenant_id_refund_no_provider_~"); + + b.ToTable("commerce_reconciliation_items", null, t => + { + t.HasCheckConstraint("ck_commerce_reconciliation_items_amounts", "amount_cents >= 0 and refund_amount_cents >= 0"); + + t.HasCheckConstraint("ck_commerce_reconciliation_items_row", "row_no > 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.CommerceRefundEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Details") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("details") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("event_type"); + + b.Property("FromStatus") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("from_status"); + + b.Property("RefundRequestId") + .HasColumnType("uuid") + .HasColumnName("refund_request_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("ToStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("to_status"); + + b.HasKey("Id") + .HasName("pk_commerce_refund_events"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_commerce_refund_events_tenant_id_id"); + + b.HasIndex("ActorUserId") + .HasDatabaseName("ix_commerce_refund_events_actor_user_id"); + + b.HasIndex("TenantId", "RefundRequestId", "CreatedAt") + .HasDatabaseName("ix_commerce_refund_events_tenant_id_refund_request_id_created_~"); + + b.ToTable("commerce_refund_events", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.CommerceRefundRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AmountCents") + .HasColumnType("integer") + .HasColumnName("amount_cents"); + + b.Property("CancelledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("cancelled_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("EntitlementAction") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("entitlement_action"); + + b.Property("FailedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("failed_at"); + + b.Property("FailureReason") + .HasColumnType("text") + .HasColumnName("failure_reason"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("OrderId") + .HasColumnType("uuid") + .HasColumnName("order_id"); + + b.Property("PaymentId") + .HasColumnType("uuid") + .HasColumnName("payment_id"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at"); + + b.Property("ProcessedBy") + .HasColumnType("uuid") + .HasColumnName("processed_by"); + + b.Property("Provider") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("provider"); + + b.Property("ProviderRefundNo") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("provider_refund_no"); + + b.Property("Reason") + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("RefundNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("refund_no"); + + b.Property("RequestedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("requested_at") + .HasDefaultValueSql("now()"); + + b.Property("RequestedBy") + .HasColumnType("uuid") + .HasColumnName("requested_by"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("reviewed_at"); + + b.Property("ReviewedBy") + .HasColumnType("uuid") + .HasColumnName("reviewed_by"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("SucceededAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("succeeded_at"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_commerce_refund_requests"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_commerce_refund_requests_tenant_id_id"); + + b.HasIndex("ProcessedBy") + .HasDatabaseName("ix_commerce_refund_requests_processed_by"); + + b.HasIndex("RequestedBy") + .HasDatabaseName("ix_commerce_refund_requests_requested_by"); + + b.HasIndex("ReviewedBy") + .HasDatabaseName("ix_commerce_refund_requests_reviewed_by"); + + b.HasIndex("TenantId", "PaymentId") + .HasDatabaseName("ix_commerce_refund_requests_tenant_id_payment_id"); + + b.HasIndex("TenantId", "RefundNo") + .IsUnique() + .HasDatabaseName("ix_commerce_refund_requests_tenant_id_refund_no"); + + b.HasIndex("TenantId", "OrderId", "CreatedAt") + .HasDatabaseName("ix_commerce_refund_requests_tenant_id_order_id_created_at"); + + b.HasIndex("TenantId", "Status", "CreatedAt") + .HasDatabaseName("ix_commerce_refund_requests_tenant_id_status_created_at"); + + b.ToTable("commerce_refund_requests", null, t => + { + t.HasCheckConstraint("ck_commerce_refund_requests_amount", "amount_cents > 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.Coupon", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DiscountType") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("discount_type"); + + b.Property("DiscountValue") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)") + .HasColumnName("discount_value"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("MaxUses") + .HasColumnType("integer") + .HasColumnName("max_uses"); + + b.Property("PlanId") + .HasColumnType("uuid") + .HasColumnName("plan_id"); + + b.Property("Remark") + .HasColumnType("text") + .HasColumnName("remark"); + + b.Property("Source") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("source"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UsedCount") + .HasColumnType("integer") + .HasColumnName("used_count"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone") + .HasColumnName("valid_from"); + + b.Property("ValidTo") + .HasColumnType("timestamp with time zone") + .HasColumnName("valid_to"); + + b.HasKey("Id") + .HasName("pk_coupons"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_coupons_tenant_id_id"); + + b.HasIndex("TenantId", "Code") + .IsUnique() + .HasDatabaseName("ix_coupons_tenant_id_code"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_coupons_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "PlanId") + .HasDatabaseName("ix_coupons_tenant_id_plan_id"); + + b.ToTable("coupons", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.CouponRedemption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ClaimedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("claimed_at"); + + b.Property("CouponCode") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("coupon_code"); + + b.Property("CouponId") + .HasColumnType("uuid") + .HasColumnName("coupon_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DiscountAppliedCents") + .HasColumnType("integer") + .HasColumnName("discount_applied_cents"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("OrderId") + .HasColumnType("uuid") + .HasColumnName("order_id"); + + b.Property("PlanId") + .HasColumnType("uuid") + .HasColumnName("plan_id"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("Remark") + .HasColumnType("text") + .HasColumnName("remark"); + + b.Property("Source") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("source"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_coupon_redemptions"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_coupon_redemptions_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_coupon_redemptions_user_id"); + + b.HasIndex("TenantId", "CouponId") + .HasDatabaseName("ix_coupon_redemptions_tenant_id_coupon_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_coupon_redemptions_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "PlanId") + .HasDatabaseName("ix_coupon_redemptions_tenant_id_plan_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_coupon_redemptions_tenant_id_region_id"); + + b.HasIndex("TenantId", "OrderId", "UserId") + .HasDatabaseName("ix_coupon_redemptions_tenant_id_order_id_user_id") + .HasFilter("order_id is not null"); + + b.HasIndex("TenantId", "UserId", "CouponId") + .IsUnique() + .HasDatabaseName("ix_coupon_redemptions_tenant_id_user_id_coupon_id") + .HasFilter("coupon_id is not null"); + + b.ToTable("coupon_redemptions", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.Entitlement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("EntitlementType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("entitlement_type"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("LegacySourceId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_source_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("RevokedBy") + .HasColumnType("uuid") + .HasColumnName("revoked_by"); + + b.Property("RevokedReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("revoked_reason"); + + b.Property("ScopeId") + .HasColumnType("uuid") + .HasColumnName("scope_id"); + + b.Property("ScopeType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("scope_type"); + + b.Property("SourceId") + .HasColumnType("uuid") + .HasColumnName("source_id"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("source_type"); + + b.Property("StartsAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("starts_at") + .HasDefaultValueSql("now()"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_entitlements"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_entitlements_tenant_id_id"); + + b.HasIndex("RevokedBy") + .HasDatabaseName("ix_entitlements_revoked_by"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_entitlements_user_id"); + + b.HasIndex("TenantId", "UserId", "Status", "ExpiresAt") + .HasDatabaseName("ix_entitlements_tenant_id_user_id_status_expires_at"); + + b.ToTable("entitlements", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AmountCents") + .HasColumnType("integer") + .HasColumnName("amount_cents"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Days") + .HasColumnType("integer") + .HasColumnName("days"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("LegacyPlanId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_plan_id"); + + b.Property("LegacyRegionId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_region_id"); + + b.Property("LegacyUserId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_user_id"); + + b.Property("OrderNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("order_no"); + + b.Property("PaidAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("paid_at"); + + b.Property("PayMethod") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("pay_method"); + + b.Property("PayProvider") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("pay_provider"); + + b.Property("PlanId") + .HasColumnType("uuid") + .HasColumnName("plan_id"); + + b.Property("ProductName") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("product_name"); + + b.Property("ProductType") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("product_type"); + + b.Property("RawPayload") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("raw_payload") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("RefundedAmountCents") + .HasColumnType("integer") + .HasColumnName("refunded_amount_cents"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TradeNo") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("trade_no"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_orders"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_orders_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_orders_user_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_orders_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "OrderNo") + .IsUnique() + .HasDatabaseName("ix_orders_tenant_id_order_no"); + + b.HasIndex("TenantId", "PlanId") + .HasDatabaseName("ix_orders_tenant_id_plan_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_orders_tenant_id_region_id"); + + b.HasIndex("TenantId", "UserId", "CreatedAt") + .HasDatabaseName("ix_orders_tenant_id_user_id_created_at"); + + b.ToTable("orders", null, t => + { + t.HasCheckConstraint("ck_orders_amount", "amount_cents >= 0"); + + t.HasCheckConstraint("ck_orders_refunded_amount", "refunded_amount_cents >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.OrderItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("ItemId") + .HasColumnType("uuid") + .HasColumnName("item_id"); + + b.Property("ItemType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("item_type"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("OrderId") + .HasColumnType("uuid") + .HasColumnName("order_id"); + + b.Property("Quantity") + .HasColumnType("integer") + .HasColumnName("quantity"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TotalAmountCents") + .HasColumnType("integer") + .HasColumnName("total_amount_cents"); + + b.Property("UnitAmountCents") + .HasColumnType("integer") + .HasColumnName("unit_amount_cents"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_order_items"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_order_items_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_order_items_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "OrderId") + .HasDatabaseName("ix_order_items_tenant_id_order_id"); + + b.ToTable("order_items", null, t => + { + t.HasCheckConstraint("ck_order_items_amounts", "unit_amount_cents >= 0 and total_amount_cents >= 0"); + + t.HasCheckConstraint("ck_order_items_quantity", "quantity > 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.Payment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AmountCents") + .HasColumnType("integer") + .HasColumnName("amount_cents"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("LegacyOrderId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_order_id"); + + b.Property("Method") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("method"); + + b.Property("OrderId") + .HasColumnType("uuid") + .HasColumnName("order_id"); + + b.Property("PaidAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("paid_at"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("provider"); + + b.Property("ProviderTradeNo") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("provider_trade_no"); + + b.Property("RawPayload") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("raw_payload") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("RefundedAmountCents") + .HasColumnType("integer") + .HasColumnName("refunded_amount_cents"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_payments"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_payments_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_payments_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "OrderId", "UpdatedAt") + .HasDatabaseName("ix_payments_tenant_id_order_id_updated_at"); + + b.HasIndex("TenantId", "Provider", "ProviderTradeNo") + .IsUnique() + .HasDatabaseName("ix_payments_tenant_id_provider_provider_trade_no") + .HasFilter("provider_trade_no is not null"); + + b.ToTable("payments", null, t => + { + t.HasCheckConstraint("ck_payments_amount", "amount_cents >= 0"); + + t.HasCheckConstraint("ck_payments_refunded_amount", "refunded_amount_cents >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.PaymentEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Error") + .HasColumnType("text") + .HasColumnName("error"); + + b.Property("EventId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("event_id"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("event_type"); + + b.Property("Payload") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("payload") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("PaymentId") + .HasColumnType("uuid") + .HasColumnName("payment_id"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("processed_at"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("provider"); + + b.Property("SignatureValid") + .HasColumnType("boolean") + .HasColumnName("signature_valid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id") + .HasName("pk_payment_events"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_payment_events_tenant_id_id"); + + b.HasIndex("TenantId", "PaymentId") + .HasDatabaseName("ix_payment_events_tenant_id_payment_id"); + + b.HasIndex("TenantId", "Provider", "EventId") + .IsUnique() + .HasDatabaseName("ix_payment_events_tenant_id_provider_event_id"); + + b.ToTable("payment_events", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.PointActivityClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ClaimedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("claimed_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Points") + .HasColumnType("integer") + .HasColumnName("points"); + + b.Property("SourceId") + .HasColumnType("uuid") + .HasColumnName("source_id"); + + b.Property("SourceType") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("source_type"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TaskId") + .HasColumnType("uuid") + .HasColumnName("task_id"); + + b.Property("TaskKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext") + .HasColumnName("task_key"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_point_activity_claims"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_point_activity_claims_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_point_activity_claims_user_id"); + + b.HasIndex("TenantId", "TaskId") + .HasDatabaseName("ix_point_activity_claims_tenant_id_task_id"); + + b.HasIndex("TenantId", "UserId", "CreatedAt") + .HasDatabaseName("ix_point_activity_claims_tenant_id_user_id_created_at"); + + b.HasIndex("TenantId", "UserId", "TaskId", "SourceType", "SourceId") + .IsUnique() + .HasDatabaseName("ix_point_activity_claims_tenant_id_user_id_task_id_source_type~") + .HasFilter("source_type is not null and source_id is not null"); + + b.ToTable("point_activity_claims", null, t => + { + t.HasCheckConstraint("ck_point_activity_claims_points", "points > 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.PointActivityTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("EndsAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ends_at"); + + b.Property("MaxClaimsPerUser") + .HasColumnType("integer") + .HasColumnName("max_claims_per_user"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Points") + .HasColumnType("integer") + .HasColumnName("points"); + + b.Property("Rules") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("rules") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("StartsAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("starts_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TaskKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext") + .HasColumnName("task_key"); + + b.Property("TaskType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("task_type"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_point_activity_tasks"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_point_activity_tasks_tenant_id_id"); + + b.HasIndex("TenantId", "TaskKey") + .IsUnique() + .HasDatabaseName("ix_point_activity_tasks_tenant_id_task_key"); + + b.HasIndex("TenantId", "Status", "SortOrder") + .HasDatabaseName("ix_point_activity_tasks_tenant_id_status_sort_order"); + + b.ToTable("point_activity_tasks", null, t => + { + t.HasCheckConstraint("ck_point_activity_tasks_max_claims", "max_claims_per_user > 0"); + + t.HasCheckConstraint("ck_point_activity_tasks_points", "points > 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.PointExchangeItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Days") + .HasColumnType("integer") + .HasColumnName("days"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("EndsAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ends_at"); + + b.Property("FulfillmentPayload") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("fulfillment_payload") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("ItemKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext") + .HasColumnName("item_key"); + + b.Property("ItemType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("item_type"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("PointsCost") + .HasColumnType("integer") + .HasColumnName("points_cost"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("StartsAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("starts_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("Stock") + .HasColumnType("integer") + .HasColumnName("stock"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_point_exchange_items"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_point_exchange_items_tenant_id_id"); + + b.HasIndex("TenantId", "ItemKey") + .IsUnique() + .HasDatabaseName("ix_point_exchange_items_tenant_id_item_key"); + + b.HasIndex("TenantId", "RegionId", "Status", "SortOrder") + .HasDatabaseName("ix_point_exchange_items_tenant_id_region_id_status_sort_order"); + + b.ToTable("point_exchange_items", null, t => + { + t.HasCheckConstraint("ck_point_exchange_items_days", "days is null or days >= 0"); + + t.HasCheckConstraint("ck_point_exchange_items_points_cost", "points_cost > 0"); + + t.HasCheckConstraint("ck_point_exchange_items_stock", "stock is null or stock >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.PointExchangeOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CancelledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("cancelled_at"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("FulfillmentSnapshot") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("fulfillment_snapshot") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("ItemId") + .HasColumnType("uuid") + .HasColumnName("item_id"); + + b.Property("ItemName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("item_name"); + + b.Property("ItemType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("item_type"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("OrderNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("order_no"); + + b.Property("OrderedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("ordered_at") + .HasDefaultValueSql("now()"); + + b.Property("PointsCost") + .HasColumnType("integer") + .HasColumnName("points_cost"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_point_exchange_orders"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_point_exchange_orders_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_point_exchange_orders_user_id"); + + b.HasIndex("TenantId", "ItemId") + .HasDatabaseName("ix_point_exchange_orders_tenant_id_item_id"); + + b.HasIndex("TenantId", "OrderNo") + .IsUnique() + .HasDatabaseName("ix_point_exchange_orders_tenant_id_order_no"); + + b.HasIndex("TenantId", "UserId", "CreatedAt") + .HasDatabaseName("ix_point_exchange_orders_tenant_id_user_id_created_at"); + + b.ToTable("point_exchange_orders", null, t => + { + t.HasCheckConstraint("ck_point_exchange_orders_points_cost", "points_cost > 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Cover") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("cover"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DetailImages") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("detail_images") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("LegacyPriceText") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("legacy_price_text"); + + b.Property("Link") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("link"); + + b.Property("PreviewIframe") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("preview_iframe"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Tags") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("tags") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("title"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_products"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_products_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_products_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "RegionId", "Type", "IsActive", "SortOrder") + .HasDatabaseName("ix_products_tenant_id_region_id_type_is_active_sort_order"); + + b.ToTable("products", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.SvipPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Badge") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("badge"); + + b.Property("CouponOnly") + .HasColumnType("boolean") + .HasColumnName("coupon_only"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Days") + .HasColumnType("integer") + .HasColumnName("days"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("OriginalPriceCents") + .HasColumnType("integer") + .HasColumnName("original_price_cents"); + + b.Property("PerDayLabel") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("per_day_label"); + + b.Property("PriceCents") + .HasColumnType("integer") + .HasColumnName("price_cents"); + + b.Property("Recommended") + .HasColumnType("boolean") + .HasColumnName("recommended"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("VpEnabled") + .HasColumnType("boolean") + .HasColumnName("vp_enabled"); + + b.Property("VpProductId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("vp_product_id"); + + b.HasKey("Id") + .HasName("pk_svip_plans"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_svip_plans_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_svip_plans_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "RegionId", "IsActive", "SortOrder") + .HasDatabaseName("ix_svip_plans_tenant_id_region_id_is_active_sort_order"); + + b.ToTable("svip_plans", null, t => + { + t.HasCheckConstraint("ck_svip_plans_days", "days >= 0"); + + t.HasCheckConstraint("ck_svip_plans_price", "price_cents >= 0 and (original_price_cents is null or original_price_cents >= 0)"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.TenantSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AmountCents") + .HasColumnType("integer") + .HasColumnName("amount_cents"); + + b.Property("BillingCycle") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("billing_cycle"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("PlanCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("plan_code"); + + b.Property("StartsAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("starts_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_tenant_subscriptions"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_subscriptions_tenant_id_id"); + + b.HasIndex("TenantId", "Status", "ExpiresAt") + .HasDatabaseName("ix_tenant_subscriptions_tenant_id_status_expires_at"); + + b.ToTable("tenant_subscriptions", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.TenantUsageRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("MetricKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("metric_key"); + + b.Property("MetricValue") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)") + .HasColumnName("metric_value"); + + b.Property("PeriodEnd") + .HasColumnType("date") + .HasColumnName("period_end"); + + b.Property("PeriodStart") + .HasColumnType("date") + .HasColumnName("period_start"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id") + .HasName("pk_tenant_usage_records"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_usage_records_tenant_id_id"); + + b.HasIndex("TenantId", "MetricKey", "PeriodStart", "PeriodEnd") + .HasDatabaseName("ix_tenant_usage_records_tenant_id_metric_key_period_start_peri~"); + + b.ToTable("tenant_usage_records", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.AiRecommendationReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ContextPayload") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("context_payload") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("ErrorMessage") + .HasColumnType("text") + .HasColumnName("error_message"); + + b.Property("GeneratedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("generated_at"); + + b.Property("InputPayload") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("input_payload") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Model") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("model"); + + b.Property("PromptVersion") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("prompt_version"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("provider"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("ResultPayload") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("result_payload") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_ai_recommendation_reports"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_ai_recommendation_reports_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_ai_recommendation_reports_user_id"); + + b.HasIndex("TenantId", "RegionId", "CreatedAt") + .HasDatabaseName("ix_ai_recommendation_reports_tenant_id_region_id_created_at"); + + b.HasIndex("TenantId", "UserId", "CreatedAt") + .HasDatabaseName("ix_ai_recommendation_reports_tenant_id_user_id_created_at"); + + b.ToTable("ai_recommendation_reports", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.AppAsset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AssetKey") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("asset_key"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("FileName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("file_name"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_app_assets"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_app_assets_tenant_id_id"); + + b.HasIndex("TenantId", "AssetKey") + .IsUnique() + .HasDatabaseName("ix_app_assets_tenant_id_asset_key"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_app_assets_tenant_id_legacy_id"); + + b.ToTable("app_assets", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentAsset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AccessRules") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("access_rules") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("AssetKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("asset_key"); + + b.Property("AssetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("asset_type"); + + b.Property("Bucket") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("bucket"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("category"); + + b.Property("CategoryId") + .HasColumnType("uuid") + .HasColumnName("category_id"); + + b.Property("CdnUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("cdn_url"); + + b.Property("ChecksumSha256") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("checksum_sha256"); + + b.Property("ContentNodeId") + .HasColumnType("uuid") + .HasColumnName("content_node_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("DownloadCount") + .HasColumnType("integer") + .HasColumnName("download_count"); + + b.Property("FileName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("file_name"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint") + .HasColumnName("file_size_bytes"); + + b.Property("IsPublic") + .HasColumnType("boolean") + .HasColumnName("is_public"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("MimeType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("mime_type"); + + b.Property("ObjectKey") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("object_key"); + + b.Property("PreviewObjectKey") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("preview_object_key"); + + b.Property("PreviewStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("preview_status"); + + b.Property("PreviewUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("preview_url"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SecurityFlags") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("security_flags") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("SecurityScanProvider") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("security_scan_provider"); + + b.Property("SecurityScanStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasDefaultValue("not_required") + .HasColumnName("security_scan_status"); + + b.Property("SecurityScanSummary") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("security_scan_summary") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("SecurityScannedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("security_scanned_at"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("source"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("StorageProvider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("storage_provider"); + + b.Property("SubjectId") + .HasColumnType("uuid") + .HasColumnName("subject_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("UploadStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("upload_status"); + + b.Property("VerificationDetails") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("verification_details") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("verified_at"); + + b.Property("VerifiedBy") + .HasColumnType("uuid") + .HasColumnName("verified_by"); + + b.Property("VerifiedChecksumSha256") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("verified_checksum_sha256"); + + b.Property("VerifiedSizeBytes") + .HasColumnType("bigint") + .HasColumnName("verified_size_bytes"); + + b.Property("Visibility") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("visibility"); + + b.HasKey("Id") + .HasName("pk_content_assets"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_content_assets_tenant_id_id"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_content_assets_created_by"); + + b.HasIndex("UpdatedBy") + .HasDatabaseName("ix_content_assets_updated_by"); + + b.HasIndex("VerifiedBy") + .HasDatabaseName("ix_content_assets_verified_by"); + + b.HasIndex("TenantId", "CategoryId") + .HasDatabaseName("ix_content_assets_tenant_id_category_id"); + + b.HasIndex("TenantId", "ChecksumSha256") + .HasDatabaseName("ix_content_assets_tenant_id_checksum_sha256") + .HasFilter("checksum_sha256 is not null"); + + b.HasIndex("TenantId", "ContentNodeId") + .HasDatabaseName("ix_content_assets_tenant_id_content_node_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_content_assets_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "PreviewStatus") + .HasDatabaseName("ix_content_assets_tenant_id_preview_status") + .HasFilter("preview_status <> 'none'"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_content_assets_tenant_id_region_id"); + + b.HasIndex("TenantId", "SubjectId") + .HasDatabaseName("ix_content_assets_tenant_id_subject_id"); + + b.HasIndex("TenantId", "StorageProvider", "Bucket", "ObjectKey") + .HasDatabaseName("ix_content_assets_tenant_id_storage_provider_bucket_object_key"); + + b.HasIndex("TenantId", "UploadStatus", "Status", "UpdatedAt") + .HasDatabaseName("ix_content_assets_tenant_id_upload_status_status_updated_at"); + + b.HasIndex("TenantId", "SecurityScanStatus", "UploadStatus", "Status", "UpdatedAt") + .HasDatabaseName("ix_content_assets_tenant_id_security_scan_status_upload_status~"); + + b.HasIndex("TenantId", "Status", "Visibility", "AssetType", "RegionId", "SubjectId", "CategoryId", "SortOrder") + .HasDatabaseName("ix_content_assets_tenant_id_status_visibility_asset_type_regio~"); + + b.ToTable("content_assets", null, t => + { + t.HasCheckConstraint("ck_content_assets_download_count", "download_count >= 0"); + + t.HasCheckConstraint("ck_content_assets_file_size", "file_size_bytes is null or file_size_bytes >= 0"); + + t.HasCheckConstraint("ck_content_assets_verified_checksum", "verified_checksum_sha256 is null or verified_checksum_sha256 ~ '^[a-f0-9]{64}$'"); + + t.HasCheckConstraint("ck_content_assets_verified_size", "verified_size_bytes is null or verified_size_bytes >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentAssetAccessEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AccessType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("access_type"); + + b.Property("ActorRole") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("actor_role"); + + b.Property("AssetId") + .HasColumnType("uuid") + .HasColumnName("asset_id"); + + b.Property("AssetType") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("asset_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DenyCode") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("deny_code"); + + b.Property("Disposition") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("disposition"); + + b.Property("ExpiresInSeconds") + .HasColumnType("integer") + .HasColumnName("expires_in_seconds"); + + b.Property("IpAddress") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("ip_address"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Result") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("result"); + + b.Property("SignatureMode") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("signature_mode"); + + b.Property("StorageProvider") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("storage_provider"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UserAgent") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("Visibility") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("visibility"); + + b.HasKey("Id") + .HasName("pk_content_asset_access_events"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_content_asset_access_events_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_content_asset_access_events_user_id"); + + b.HasIndex("TenantId", "AssetId", "CreatedAt") + .HasDatabaseName("ix_content_asset_access_events_tenant_id_asset_id_created_at"); + + b.HasIndex("TenantId", "UserId", "CreatedAt") + .HasDatabaseName("ix_content_asset_access_events_tenant_id_user_id_created_at") + .HasFilter("user_id is not null"); + + b.HasIndex("TenantId", "AccessType", "Result", "CreatedAt") + .HasDatabaseName("ix_content_asset_access_events_tenant_id_access_type_result_cr~"); + + b.ToTable("content_asset_access_events", null, t => + { + t.HasCheckConstraint("ck_content_asset_access_events_expiry", "expires_in_seconds is null or expires_in_seconds > 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentAssetSecurityScanEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AssetId") + .HasColumnType("uuid") + .HasColumnName("asset_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Details") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("details") + .HasDefaultValueSql("'{}'::jsonb"); + + b.PrimitiveCollection("IssueCodes") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text[]") + .HasColumnName("issue_codes") + .HasDefaultValueSql("'{}'::text[]"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("provider"); + + b.Property("RiskLevel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("risk_level"); + + b.Property("ScanStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("scan_status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id") + .HasName("pk_content_asset_security_scan_events"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_content_asset_security_scan_events_tenant_id_id"); + + b.HasIndex("TenantId", "AssetId", "CreatedAt") + .HasDatabaseName("ix_content_asset_security_scan_events_tenant_id_asset_id_creat~"); + + b.HasIndex("TenantId", "ScanStatus", "RiskLevel", "CreatedAt") + .HasDatabaseName("ix_content_asset_security_scan_events_tenant_id_scan_status_ri~"); + + b.ToTable("content_asset_security_scan_events", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AccessRules") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("access_rules") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("description"); + + b.Property("EntryKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("entry_key"); + + b.Property("EntryType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("entry_type"); + + b.Property("Icon") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("icon"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LayoutConfig") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("layout_config") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("Route") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("route"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Visibility") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("visibility"); + + b.HasKey("Id") + .HasName("pk_content_entries"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_content_entries_tenant_id_id"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_content_entries_created_by"); + + b.HasIndex("UpdatedBy") + .HasDatabaseName("ix_content_entries_updated_by"); + + b.HasIndex("TenantId", "EntryKey") + .IsUnique() + .HasDatabaseName("ix_content_entries_tenant_id_entry_key"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_content_entries_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "RegionId", "EntryType", "IsActive", "SortOrder") + .HasDatabaseName("ix_content_entries_tenant_id_region_id_entry_type_is_active_so~"); + + b.ToTable("content_entries", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentImportIssue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Details") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("details") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("FieldPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("field_path"); + + b.Property("ItemId") + .HasColumnType("uuid") + .HasColumnName("item_id"); + + b.Property("JobId") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("RowNo") + .HasColumnType("integer") + .HasColumnName("row_no"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("severity"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_content_import_issues"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_content_import_issues_tenant_id_id"); + + b.HasIndex("TenantId", "ItemId") + .HasDatabaseName("ix_content_import_issues_tenant_id_item_id"); + + b.HasIndex("TenantId", "JobId", "Severity", "RowNo") + .HasDatabaseName("ix_content_import_issues_tenant_id_job_id_severity_row_no"); + + b.ToTable("content_import_issues", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentImportItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ContentHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("content_hash"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("ExternalId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("external_id"); + + b.Property("IssuesCount") + .HasColumnType("integer") + .HasColumnName("issues_count"); + + b.Property("JobId") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.Property("NormalizedPayload") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("normalized_payload") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("RowNo") + .HasColumnType("integer") + .HasColumnName("row_no"); + + b.Property("SourcePayload") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("source_payload") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TargetId") + .HasColumnType("uuid") + .HasColumnName("target_id"); + + b.Property("TargetType") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("target_type"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_content_import_items"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_content_import_items_tenant_id_id"); + + b.HasIndex("TenantId", "JobId", "RowNo") + .IsUnique() + .HasDatabaseName("ix_content_import_items_tenant_id_job_id_row_no"); + + b.HasIndex("TenantId", "JobId", "Status", "RowNo") + .HasDatabaseName("ix_content_import_items_tenant_id_job_id_status_row_no"); + + b.ToTable("content_import_items", null, t => + { + t.HasCheckConstraint("ck_content_import_items_issues_count", "issues_count >= 0"); + + t.HasCheckConstraint("ck_content_import_items_row_no", "row_no >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentImportJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DryRun") + .HasColumnType("boolean") + .HasColumnName("dry_run"); + + b.Property("ErrorCount") + .HasColumnType("integer") + .HasColumnName("error_count"); + + b.Property("ErrorMessage") + .HasColumnType("text") + .HasColumnName("error_message"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ImportType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("import_type"); + + b.Property("InsertedCount") + .HasColumnType("integer") + .HasColumnName("inserted_count"); + + b.Property("NormalizedPayload") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("normalized_payload") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("RawPayload") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("raw_payload") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("SkippedCount") + .HasColumnType("integer") + .HasColumnName("skipped_count"); + + b.Property("SourceFormat") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("source_format"); + + b.Property("SourceHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_hash"); + + b.Property("SourceName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("source_name"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("Summary") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("summary") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("TargetCategoryId") + .HasColumnType("uuid") + .HasColumnName("target_category_id"); + + b.Property("TargetContentNodeId") + .HasColumnType("uuid") + .HasColumnName("target_content_node_id"); + + b.Property("TargetQuestionBankId") + .HasColumnType("uuid") + .HasColumnName("target_question_bank_id"); + + b.Property("TargetRegionId") + .HasColumnType("uuid") + .HasColumnName("target_region_id"); + + b.Property("TargetSubjectId") + .HasColumnType("uuid") + .HasColumnName("target_subject_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TotalCount") + .HasColumnType("integer") + .HasColumnName("total_count"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedCount") + .HasColumnType("integer") + .HasColumnName("updated_count"); + + b.Property("ValidCount") + .HasColumnType("integer") + .HasColumnName("valid_count"); + + b.Property("WarningCount") + .HasColumnType("integer") + .HasColumnName("warning_count"); + + b.HasKey("Id") + .HasName("pk_content_import_jobs"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_content_import_jobs_tenant_id_id"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_content_import_jobs_created_by"); + + b.HasIndex("TenantId", "TargetCategoryId") + .HasDatabaseName("ix_content_import_jobs_tenant_id_target_category_id"); + + b.HasIndex("TenantId", "TargetContentNodeId") + .HasDatabaseName("ix_content_import_jobs_tenant_id_target_content_node_id"); + + b.HasIndex("TenantId", "TargetQuestionBankId") + .HasDatabaseName("ix_content_import_jobs_tenant_id_target_question_bank_id"); + + b.HasIndex("TenantId", "TargetRegionId") + .HasDatabaseName("ix_content_import_jobs_tenant_id_target_region_id"); + + b.HasIndex("TenantId", "TargetSubjectId") + .HasDatabaseName("ix_content_import_jobs_tenant_id_target_subject_id"); + + b.HasIndex("TenantId", "ImportType", "Status", "CreatedAt") + .HasDatabaseName("ix_content_import_jobs_tenant_id_import_type_status_created_at"); + + b.ToTable("content_import_jobs", null, t => + { + t.HasCheckConstraint("ck_content_import_jobs_counts", "total_count >= 0 and valid_count >= 0 and error_count >= 0 and warning_count >= 0 and inserted_count >= 0 and updated_count >= 0 and skipped_count >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentNode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AccessRules") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("access_rules") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("Depth") + .HasColumnType("integer") + .HasColumnName("depth"); + + b.Property("EntryId") + .HasColumnType("uuid") + .HasColumnName("entry_id"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsLeaf") + .HasColumnType("boolean") + .HasColumnName("is_leaf"); + + b.Property("IsSelectable") + .HasColumnType("boolean") + .HasColumnName("is_selectable"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("MarkerConfig") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("marker_config") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("MarkerType") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("marker_type"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("NodeKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("node_key"); + + b.Property("NodeType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("node_type"); + + b.Property("ParentId") + .HasColumnType("uuid") + .HasColumnName("parent_id"); + + b.Property("Path") + .HasColumnType("ltree") + .HasColumnName("path"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_content_nodes"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_content_nodes_tenant_id_id"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_content_nodes_created_by"); + + b.HasIndex("Path") + .HasDatabaseName("ix_content_nodes_path"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Path"), "gist"); + + b.HasIndex("UpdatedBy") + .HasDatabaseName("ix_content_nodes_updated_by"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_content_nodes_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "MarkerType") + .HasDatabaseName("ix_content_nodes_tenant_id_marker_type") + .HasFilter("marker_type is not null"); + + b.HasIndex("TenantId", "ParentId") + .HasDatabaseName("ix_content_nodes_tenant_id_parent_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_content_nodes_tenant_id_region_id"); + + b.HasIndex("TenantId", "EntryId", "NodeKey") + .IsUnique() + .HasDatabaseName("ix_content_nodes_tenant_id_entry_id_node_key"); + + b.HasIndex("TenantId", "EntryId", "ParentId", "SortOrder") + .HasDatabaseName("ix_content_nodes_tenant_id_entry_id_parent_id_sort_order"); + + b.ToTable("content_nodes", null, t => + { + t.HasCheckConstraint("ck_content_nodes_depth", "depth >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Content.HandbookChapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ContentNodeId") + .HasColumnType("uuid") + .HasColumnName("content_node_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EntryId") + .HasColumnType("uuid") + .HasColumnName("entry_id"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("SourceHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_hash"); + + b.Property("SubjectId") + .HasColumnType("uuid") + .HasColumnName("subject_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_handbook_chapters"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_handbook_chapters_tenant_id_id"); + + b.HasIndex("TenantId", "ContentNodeId") + .HasDatabaseName("ix_handbook_chapters_tenant_id_content_node_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_handbook_chapters_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "SubjectId") + .HasDatabaseName("ix_handbook_chapters_tenant_id_subject_id"); + + b.HasIndex("TenantId", "EntryId", "ContentNodeId", "SubjectId", "IsActive", "SortOrder") + .HasDatabaseName("ix_handbook_chapters_tenant_id_entry_id_content_node_id_subjec~"); + + b.ToTable("handbook_chapters", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.HandbookEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ChapterId") + .HasColumnType("uuid") + .HasColumnName("chapter_id"); + + b.Property("Content") + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("ContentNodeId") + .HasColumnType("uuid") + .HasColumnName("content_node_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("EntryId") + .HasColumnType("uuid") + .HasColumnName("entry_id"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("SourceHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_hash"); + + b.Property("Summary") + .HasColumnType("text") + .HasColumnName("summary"); + + b.Property("Tags") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("tags") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_handbook_entries"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_handbook_entries_tenant_id_id"); + + b.HasIndex("TenantId", "ChapterId") + .HasDatabaseName("ix_handbook_entries_tenant_id_chapter_id"); + + b.HasIndex("TenantId", "ContentNodeId") + .HasDatabaseName("ix_handbook_entries_tenant_id_content_node_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_handbook_entries_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "EntryId", "ContentNodeId", "ChapterId", "IsActive", "SortOrder") + .HasDatabaseName("ix_handbook_entries_tenant_id_entry_id_content_node_id_chapter~"); + + b.ToTable("handbook_entries", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.HandbookSubject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Color") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("color"); + + b.Property("ContentNodeId") + .HasColumnType("uuid") + .HasColumnName("content_node_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EntryId") + .HasColumnType("uuid") + .HasColumnName("entry_id"); + + b.Property("Icon") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("icon"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("MajorId") + .HasColumnType("uuid") + .HasColumnName("major_id"); + + b.Property("MajorLegacyIds") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("major_legacy_ids") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SchoolId") + .HasColumnType("uuid") + .HasColumnName("school_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("SourceHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_hash"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Type") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_handbook_subjects"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_handbook_subjects_tenant_id_id"); + + b.HasIndex("TenantId", "ContentNodeId") + .HasDatabaseName("ix_handbook_subjects_tenant_id_content_node_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_handbook_subjects_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "MajorId") + .HasDatabaseName("ix_handbook_subjects_tenant_id_major_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_handbook_subjects_tenant_id_region_id"); + + b.HasIndex("TenantId", "SchoolId") + .HasDatabaseName("ix_handbook_subjects_tenant_id_school_id"); + + b.HasIndex("TenantId", "EntryId", "ContentNodeId", "RegionId", "IsActive", "SortOrder") + .HasDatabaseName("ix_handbook_subjects_tenant_id_entry_id_content_node_id_region~"); + + b.ToTable("handbook_subjects", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ImageAsset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("category"); + + b.Property("CdnUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("cdn_url"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("FileName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("file_name"); + + b.Property("IsPublic") + .HasColumnType("boolean") + .HasColumnName("is_public"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_images"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_images_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_images_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "Category", "IsPublic") + .HasDatabaseName("ix_images_tenant_id_category_is_public"); + + b.ToTable("images", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.PracticeBlueprint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AccessRules") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("access_rules") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("AssemblyType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("assembly_type"); + + b.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DurationMinutes") + .HasColumnType("integer") + .HasColumnName("duration_minutes"); + + b.Property("EntryId") + .HasColumnType("uuid") + .HasColumnName("entry_id"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("mode"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("NodeId") + .HasColumnType("uuid") + .HasColumnName("node_id"); + + b.Property("PassScore") + .HasPrecision(8, 2) + .HasColumnType("numeric(8,2)") + .HasColumnName("pass_score"); + + b.Property("QuestionLimit") + .HasColumnType("integer") + .HasColumnName("question_limit"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("Rules") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("rules") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Sections") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("sections") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TotalScore") + .HasPrecision(8, 2) + .HasColumnType("numeric(8,2)") + .HasColumnName("total_score"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_practice_blueprints"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_practice_blueprints_tenant_id_id"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_practice_blueprints_created_by"); + + b.HasIndex("UpdatedBy") + .HasDatabaseName("ix_practice_blueprints_updated_by"); + + b.HasIndex("TenantId", "CollectionId") + .HasDatabaseName("ix_practice_blueprints_tenant_id_collection_id"); + + b.HasIndex("TenantId", "EntryId") + .HasDatabaseName("ix_practice_blueprints_tenant_id_entry_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_practice_blueprints_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_practice_blueprints_tenant_id_region_id"); + + b.HasIndex("TenantId", "NodeId", "CollectionId", "Mode", "Status", "SortOrder") + .HasDatabaseName("ix_practice_blueprints_tenant_id_node_id_collection_id_mode_st~"); + + b.ToTable("practice_blueprints", null, t => + { + t.HasCheckConstraint("ck_practice_blueprints_duration", "duration_minutes is null or duration_minutes > 0"); + + t.HasCheckConstraint("ck_practice_blueprints_question_limit", "question_limit is null or question_limit > 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Content.QuestionCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AccessRules") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("access_rules") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("CategoryId") + .HasColumnType("uuid") + .HasColumnName("category_id"); + + b.Property("CollectionType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("collection_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DurationMinutes") + .HasColumnType("integer") + .HasColumnName("duration_minutes"); + + b.Property("EntryId") + .HasColumnType("uuid") + .HasColumnName("entry_id"); + + b.Property("Filters") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("filters") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("NodeId") + .HasColumnType("uuid") + .HasColumnName("node_id"); + + b.Property("QuestionBankId") + .HasColumnType("uuid") + .HasColumnName("question_bank_id"); + + b.Property("QuestionCount") + .HasColumnType("integer") + .HasColumnName("question_count"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("source_type"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("SubjectId") + .HasColumnType("uuid") + .HasColumnName("subject_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TotalScore") + .HasPrecision(8, 2) + .HasColumnType("numeric(8,2)") + .HasColumnName("total_score"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_question_collections"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_question_collections_tenant_id_id"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_question_collections_created_by"); + + b.HasIndex("UpdatedBy") + .HasDatabaseName("ix_question_collections_updated_by"); + + b.HasIndex("TenantId", "CategoryId") + .HasDatabaseName("ix_question_collections_tenant_id_category_id"); + + b.HasIndex("TenantId", "EntryId") + .HasDatabaseName("ix_question_collections_tenant_id_entry_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_question_collections_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "QuestionBankId") + .HasDatabaseName("ix_question_collections_tenant_id_question_bank_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_question_collections_tenant_id_region_id"); + + b.HasIndex("TenantId", "SubjectId") + .HasDatabaseName("ix_question_collections_tenant_id_subject_id"); + + b.HasIndex("TenantId", "NodeId", "Status", "SortOrder") + .HasDatabaseName("ix_question_collections_tenant_id_node_id_status_sort_order"); + + b.ToTable("question_collections", null, t => + { + t.HasCheckConstraint("ck_question_collections_duration", "duration_minutes is null or duration_minutes > 0"); + + t.HasCheckConstraint("ck_question_collections_question_count", "question_count >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Content.QuestionCollectionItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("QuestionId") + .HasColumnType("uuid") + .HasColumnName("question_id"); + + b.Property("QuestionOwnerTenantId") + .HasColumnType("uuid") + .HasColumnName("question_owner_tenant_id"); + + b.Property("QuestionReferenceId") + .HasColumnType("uuid") + .HasColumnName("question_reference_id"); + + b.Property("Required") + .HasColumnType("boolean") + .HasColumnName("required"); + + b.Property("Score") + .HasPrecision(8, 2) + .HasColumnType("numeric(8,2)") + .HasColumnName("score"); + + b.Property("SectionKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("section_key"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_question_collection_items"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_question_collection_items_tenant_id_id"); + + b.HasIndex("QuestionOwnerTenantId", "QuestionId") + .HasDatabaseName("ix_question_collection_items_question_owner_tenant_id_question~"); + + b.HasIndex("TenantId", "QuestionReferenceId") + .HasDatabaseName("ix_question_collection_items_tenant_id_question_reference_id"); + + b.HasIndex("TenantId", "CollectionId", "QuestionReferenceId") + .IsUnique() + .HasDatabaseName("ix_question_collection_items_tenant_id_collection_id_question_~"); + + b.HasIndex("TenantId", "CollectionId", "SectionKey", "SortOrder") + .HasDatabaseName("ix_question_collection_items_tenant_id_collection_id_section_k~"); + + b.ToTable("question_collection_items", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.QuestionTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("SubjectId") + .HasColumnType("uuid") + .HasColumnName("subject_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Types") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("types") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_question_type_groups"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_question_type_groups_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_question_type_groups_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "SubjectId", "IsActive", "SortOrder") + .HasDatabaseName("ix_question_type_groups_tenant_id_subject_id_is_active_sort_or~"); + + b.ToTable("question_type_groups", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.QuestionVideo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("LegacyQuestionId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_question_id"); + + b.Property("LegacyVideoId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_video_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("QuestionId") + .HasColumnType("uuid") + .HasColumnName("question_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("VideoId") + .HasColumnType("uuid") + .HasColumnName("video_id"); + + b.Property("VideoType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("video_type"); + + b.HasKey("Id") + .HasName("pk_question_videos"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_question_videos_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_question_videos_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "VideoId") + .HasDatabaseName("ix_question_videos_tenant_id_video_id"); + + b.HasIndex("TenantId", "QuestionId", "VideoId", "VideoType") + .IsUnique() + .HasDatabaseName("ix_question_videos_tenant_id_question_id_video_id_video_type"); + + b.ToTable("question_videos", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.SubjectShare", b => + { + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("SourceSubjectId") + .HasColumnType("uuid") + .HasColumnName("source_subject_id"); + + b.Property("TargetSubjectId") + .HasColumnType("uuid") + .HasColumnName("target_subject_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LegacyId") + .HasColumnType("text") + .HasColumnName("legacy_id"); + + b.Property("LegacySourceSubjectId") + .HasColumnType("text") + .HasColumnName("legacy_source_subject_id"); + + b.Property("LegacyTargetSubjectId") + .HasColumnType("text") + .HasColumnName("legacy_target_subject_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("TenantId", "SourceSubjectId", "TargetSubjectId") + .HasName("pk_subject_shares"); + + b.HasIndex("TenantId", "TargetSubjectId") + .HasDatabaseName("ix_subject_shares_tenant_id_target_subject_id"); + + b.ToTable("subject_shares", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.TenantQuestionBankPreference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Alias") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("alias"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("IsVisible") + .HasColumnType("boolean") + .HasColumnName("is_visible"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("NavigationLocation") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("navigation_location"); + + b.Property("QuestionBankId") + .HasColumnType("uuid") + .HasColumnName("question_bank_id"); + + b.Property("QuestionBankOwnerTenantId") + .HasColumnType("uuid") + .HasColumnName("question_bank_owner_tenant_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_tenant_question_bank_preferences"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_question_bank_preferences_tenant_id_id"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_tenant_question_bank_preferences_created_by"); + + b.HasIndex("UpdatedBy") + .HasDatabaseName("ix_tenant_question_bank_preferences_updated_by"); + + b.HasIndex("QuestionBankOwnerTenantId", "QuestionBankId") + .HasDatabaseName("ix_tenant_question_bank_preferences_question_bank_owner_tenant~"); + + b.HasIndex("TenantId", "QuestionBankOwnerTenantId", "QuestionBankId") + .IsUnique() + .HasDatabaseName("ix_tenant_question_bank_preferences_tenant_id_question_bank_ow~"); + + b.ToTable("tenant_question_bank_preferences", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.TenantQuestionReference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("QuestionId") + .HasColumnType("uuid") + .HasColumnName("question_id"); + + b.Property("QuestionOwnerTenantId") + .HasColumnType("uuid") + .HasColumnName("question_owner_tenant_id"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("source"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_tenant_question_references"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_question_references_tenant_id_id"); + + b.HasAlternateKey("TenantId", "QuestionOwnerTenantId", "QuestionId") + .HasName("ak_tenant_question_references_tenant_id_question_owner_tenant_~"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_tenant_question_references_created_by"); + + b.HasIndex("QuestionOwnerTenantId", "QuestionId") + .HasDatabaseName("ix_tenant_question_references_question_owner_tenant_id_questio~"); + + b.ToTable("tenant_question_references", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.UserWordFavorite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("FavoritedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("favorited_at"); + + b.Property("LegacyId") + .HasColumnType("text") + .HasColumnName("legacy_id"); + + b.Property("LegacyUserId") + .HasColumnType("text") + .HasColumnName("legacy_user_id"); + + b.Property("LegacyWordId") + .HasColumnType("text") + .HasColumnName("legacy_word_id"); + + b.Property("Note") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("note"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("WordId") + .HasColumnType("uuid") + .HasColumnName("word_id"); + + b.HasKey("Id") + .HasName("pk_user_word_favorites"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_user_word_favorites_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_word_favorites_user_id"); + + b.HasIndex("TenantId", "WordId") + .HasDatabaseName("ix_user_word_favorites_tenant_id_word_id"); + + b.HasIndex("TenantId", "UserId", "WordId") + .IsUnique() + .HasDatabaseName("ix_user_word_favorites_tenant_id_user_id_word_id"); + + b.ToTable("user_word_favorites", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.UserWordProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CorrectCount") + .HasColumnType("integer") + .HasColumnName("correct_count"); + + b.Property("CorrectStreak") + .HasColumnType("integer") + .HasColumnName("correct_streak"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DueLevel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("due_level"); + + b.Property("EaseFactor") + .ValueGeneratedOnAdd() + .HasPrecision(4, 2) + .HasColumnType("numeric(4,2)") + .HasDefaultValue(2.50m) + .HasColumnName("ease_factor"); + + b.Property("LastResult") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("last_result"); + + b.Property("LastReviewAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_review_at"); + + b.Property("LegacyId") + .HasColumnType("text") + .HasColumnName("legacy_id"); + + b.Property("LegacyUserId") + .HasColumnType("text") + .HasColumnName("legacy_user_id"); + + b.Property("LegacyWordId") + .HasColumnType("text") + .HasColumnName("legacy_word_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("NextReviewAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_review_at"); + + b.Property("ReviewCount") + .HasColumnType("integer") + .HasColumnName("review_count"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("WordId") + .HasColumnType("uuid") + .HasColumnName("word_id"); + + b.Property("WrongCount") + .HasColumnType("integer") + .HasColumnName("wrong_count"); + + b.HasKey("Id") + .HasName("pk_user_word_progress"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_user_word_progress_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_word_progress_user_id"); + + b.HasIndex("TenantId", "WordId") + .HasDatabaseName("ix_user_word_progress_tenant_id_word_id"); + + b.HasIndex("TenantId", "UserId", "WordId") + .IsUnique() + .HasDatabaseName("ix_user_word_progress_tenant_id_user_id_word_id"); + + b.HasIndex("TenantId", "UserId", "NextReviewAt", "Status", "DueLevel") + .HasDatabaseName("ix_user_word_progress_tenant_id_user_id_next_review_at_status_~"); + + b.ToTable("user_word_progress", null, t => + { + t.HasCheckConstraint("ck_user_word_progress_correct_streak", "correct_streak >= 0"); + + t.HasCheckConstraint("ck_user_word_progress_ease_factor", "ease_factor >= 1.30 and ease_factor <= 3.00"); + + t.HasCheckConstraint("ck_user_word_progress_review_count", "review_count >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Content.VideoExplanation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsGeneral") + .HasColumnType("boolean") + .HasColumnName("is_general"); + + b.Property("KnowledgeTags") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("knowledge_tags") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("LegacySubjectId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_subject_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("SourceHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_hash"); + + b.Property("SubjectId") + .HasColumnType("uuid") + .HasColumnName("subject_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("ThumbnailUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("thumbnail_url"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("VideoUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("video_url"); + + b.HasKey("Id") + .HasName("pk_video_explanations"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_video_explanations_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_video_explanations_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "SourceHash") + .HasDatabaseName("ix_video_explanations_tenant_id_source_hash") + .HasFilter("source_hash is not null"); + + b.HasIndex("TenantId", "SubjectId", "IsActive", "SortOrder") + .HasDatabaseName("ix_video_explanations_tenant_id_subject_id_is_active_sort_order"); + + b.ToTable("video_explanations", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.VideoPlaybackProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsCompleted") + .HasColumnType("boolean") + .HasColumnName("is_completed"); + + b.Property("LastPlayedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("last_played_at") + .HasDefaultValueSql("now()"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("PlayCount") + .HasColumnType("integer") + .HasColumnName("play_count"); + + b.Property("PositionSeconds") + .HasColumnType("integer") + .HasColumnName("position_seconds"); + + b.Property("QuestionId") + .HasColumnType("uuid") + .HasColumnName("question_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("VideoId") + .HasColumnType("uuid") + .HasColumnName("video_id"); + + b.Property("WatchedSeconds") + .HasColumnType("integer") + .HasColumnName("watched_seconds"); + + b.HasKey("Id") + .HasName("pk_video_playback_progress"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_video_playback_progress_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_video_playback_progress_user_id"); + + b.HasIndex("TenantId", "QuestionId") + .HasDatabaseName("ix_video_playback_progress_tenant_id_question_id"); + + b.HasIndex("TenantId", "VideoId") + .HasDatabaseName("ix_video_playback_progress_tenant_id_video_id"); + + b.HasIndex("TenantId", "UserId", "LastPlayedAt") + .HasDatabaseName("ix_video_playback_progress_tenant_id_user_id_last_played_at"); + + b.HasIndex("TenantId", "UserId", "VideoId") + .IsUnique() + .HasDatabaseName("ix_video_playback_progress_tenant_id_user_id_video_id") + .HasFilter("question_id is null"); + + b.HasIndex("TenantId", "UserId", "VideoId", "QuestionId") + .IsUnique() + .HasDatabaseName("ix_video_playback_progress_tenant_id_user_id_video_id_question~") + .HasFilter("question_id is not null"); + + b.ToTable("video_playback_progress", null, t => + { + t.HasCheckConstraint("ck_video_playback_progress_duration", "duration_seconds is null or duration_seconds >= 0"); + + t.HasCheckConstraint("ck_video_playback_progress_play_count", "play_count >= 0"); + + t.HasCheckConstraint("ck_video_playback_progress_position", "position_seconds >= 0"); + + t.HasCheckConstraint("ck_video_playback_progress_watched", "watched_seconds >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Content.VocabularyUnit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ContentNodeId") + .HasColumnType("uuid") + .HasColumnName("content_node_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EntryId") + .HasColumnType("uuid") + .HasColumnName("entry_id"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("SourceHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_hash"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("WordCount") + .HasColumnType("integer") + .HasColumnName("word_count"); + + b.HasKey("Id") + .HasName("pk_vocabulary_units"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_vocabulary_units_tenant_id_id"); + + b.HasIndex("TenantId", "ContentNodeId") + .HasDatabaseName("ix_vocabulary_units_tenant_id_content_node_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_vocabulary_units_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_vocabulary_units_tenant_id_region_id"); + + b.HasIndex("TenantId", "EntryId", "ContentNodeId", "RegionId", "IsActive", "SortOrder") + .HasDatabaseName("ix_vocabulary_units_tenant_id_entry_id_content_node_id_region_~"); + + b.ToTable("vocabulary_units", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.VocabularyWord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ContentNodeId") + .HasColumnType("uuid") + .HasColumnName("content_node_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("EntryId") + .HasColumnType("uuid") + .HasColumnName("entry_id"); + + b.Property("Example") + .HasColumnType("text") + .HasColumnName("example"); + + b.Property("ExampleTranslation") + .HasColumnType("text") + .HasColumnName("example_translation"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Meaning") + .HasColumnType("text") + .HasColumnName("meaning"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Phonetic") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("phonetic"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("SourceHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_hash"); + + b.Property("Tags") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("tags") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UnitId") + .HasColumnType("uuid") + .HasColumnName("unit_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("Word") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("word"); + + b.HasKey("Id") + .HasName("pk_vocabulary_words"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_vocabulary_words_tenant_id_id"); + + b.HasIndex("TenantId", "ContentNodeId") + .HasDatabaseName("ix_vocabulary_words_tenant_id_content_node_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_vocabulary_words_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "UnitId", "IsActive", "SortOrder", "CreatedAt") + .HasDatabaseName("ix_vocabulary_words_tenant_id_unit_id_is_active_sort_order_cre~"); + + b.HasIndex("TenantId", "EntryId", "ContentNodeId", "UnitId", "IsActive", "SortOrder") + .HasDatabaseName("ix_vocabulary_words_tenant_id_entry_id_content_node_id_unit_id~"); + + b.ToTable("vocabulary_words", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.CommissionSettlement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CommissionAmountCents") + .HasColumnType("integer") + .HasColumnName("commission_amount_cents"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DefaultRate") + .HasPrecision(6, 4) + .HasColumnType("numeric(6,4)") + .HasColumnName("default_rate"); + + b.Property("EffectiveRate") + .HasPrecision(6, 4) + .HasColumnType("numeric(6,4)") + .HasColumnName("effective_rate"); + + b.Property("GeneratedBy") + .HasColumnType("uuid") + .HasColumnName("generated_by"); + + b.Property("GrossAmountCents") + .HasColumnType("integer") + .HasColumnName("gross_amount_cents"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("PaidAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("paid_at"); + + b.Property("PaidBy") + .HasColumnType("uuid") + .HasColumnName("paid_by"); + + b.Property("PaidUserCount") + .HasColumnType("integer") + .HasColumnName("paid_user_count"); + + b.Property("PaymentAccount") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("payment_account"); + + b.Property("PaymentMethod") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("payment_method"); + + b.Property("PeriodEnd") + .HasColumnType("date") + .HasColumnName("period_end"); + + b.Property("PeriodStart") + .HasColumnType("date") + .HasColumnName("period_start"); + + b.Property("ReferrerUserId") + .HasColumnType("uuid") + .HasColumnName("referrer_user_id"); + + b.Property("Remark") + .HasColumnType("text") + .HasColumnName("remark"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("reviewed_at"); + + b.Property("ReviewedBy") + .HasColumnType("uuid") + .HasColumnName("reviewed_by"); + + b.Property("SettlementNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("settlement_no"); + + b.Property("SourceCount") + .HasColumnType("integer") + .HasColumnName("source_count"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_commission_settlements"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_commission_settlements_tenant_id_id"); + + b.HasIndex("GeneratedBy") + .HasDatabaseName("ix_commission_settlements_generated_by"); + + b.HasIndex("PaidBy") + .HasDatabaseName("ix_commission_settlements_paid_by"); + + b.HasIndex("ReferrerUserId") + .HasDatabaseName("ix_commission_settlements_referrer_user_id"); + + b.HasIndex("ReviewedBy") + .HasDatabaseName("ix_commission_settlements_reviewed_by"); + + b.HasIndex("TenantId", "SettlementNo") + .IsUnique() + .HasDatabaseName("ix_commission_settlements_tenant_id_settlement_no"); + + b.HasIndex("TenantId", "ReferrerUserId", "PeriodStart") + .HasDatabaseName("ix_commission_settlements_referrer_start"); + + b.HasIndex("TenantId", "ReferrerUserId", "PeriodStart", "PeriodEnd") + .IsUnique() + .HasDatabaseName("ux_commission_settlements_referrer_period"); + + b.ToTable("commission_settlements", null, t => + { + t.HasCheckConstraint("ck_commission_settlements_amounts", "gross_amount_cents >= 0 and commission_amount_cents >= 0"); + + t.HasCheckConstraint("ck_commission_settlements_counts", "source_count >= 0 and paid_user_count >= 0"); + + t.HasCheckConstraint("ck_commission_settlements_period", "period_end >= period_start"); + + t.HasCheckConstraint("ck_commission_settlements_rates", "default_rate >= 0 and default_rate <= 1 and (effective_rate is null or (effective_rate >= 0 and effective_rate <= 1))"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.CommissionSettlementExportEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ContentSha256") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("content_sha256"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("ExportFormat") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("export_format"); + + b.Property("ExportedBy") + .HasColumnType("uuid") + .HasColumnName("exported_by"); + + b.Property("Filename") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("filename"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("RowCount") + .HasColumnType("integer") + .HasColumnName("row_count"); + + b.Property("SettlementId") + .HasColumnType("uuid") + .HasColumnName("settlement_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id") + .HasName("pk_commission_settlement_export_events"); + + b.HasIndex("ExportedBy") + .HasDatabaseName("ix_commission_settlement_export_events_exported_by"); + + b.HasIndex("TenantId", "SettlementId", "CreatedAt") + .HasDatabaseName("ix_commission_settlement_export_events_tenant_id_settlement_id~"); + + b.ToTable("commission_settlement_export_events", null, t => + { + t.HasCheckConstraint("ck_commission_export_events_rows", "row_count >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.CommissionSettlementItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AttributionType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("attribution_type"); + + b.Property("CommissionAmountCents") + .HasColumnType("integer") + .HasColumnName("commission_amount_cents"); + + b.Property("CommissionRate") + .HasPrecision(6, 4) + .HasColumnType("numeric(6,4)") + .HasColumnName("commission_rate"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("GrossAmountCents") + .HasColumnType("integer") + .HasColumnName("gross_amount_cents"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("RateSource") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("rate_source"); + + b.Property("ReferrerUserId") + .HasColumnType("uuid") + .HasColumnName("referrer_user_id"); + + b.Property("SettlementId") + .HasColumnType("uuid") + .HasColumnName("settlement_id"); + + b.Property("SourceId") + .HasColumnType("uuid") + .HasColumnName("source_id"); + + b.Property("SourceNo") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("source_no"); + + b.Property("SourcePaidAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("source_paid_at"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("source_type"); + + b.Property("StudentUserId") + .HasColumnType("uuid") + .HasColumnName("student_user_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_commission_settlement_items"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_commission_settlement_items_tenant_id_id"); + + b.HasIndex("ReferrerUserId") + .HasDatabaseName("ix_commission_settlement_items_referrer_user_id"); + + b.HasIndex("StudentUserId") + .HasDatabaseName("ix_commission_settlement_items_student_user_id"); + + b.HasIndex("TenantId", "SettlementId") + .HasDatabaseName("ix_commission_settlement_items_tenant_id_settlement_id"); + + b.HasIndex("TenantId", "ReferrerUserId", "SourcePaidAt") + .HasDatabaseName("ix_commission_settlement_items_tenant_id_referrer_user_id_sour~"); + + b.HasIndex("TenantId", "SourceType", "SourceId") + .IsUnique() + .HasDatabaseName("ix_commission_settlement_items_tenant_id_source_type_source_id"); + + b.ToTable("commission_settlement_items", null, t => + { + t.HasCheckConstraint("ck_commission_settlement_items_amounts", "gross_amount_cents >= 0 and commission_amount_cents >= 0"); + + t.HasCheckConstraint("ck_commission_settlement_items_rate", "commission_rate >= 0 and commission_rate <= 1"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.CommissionSettlementProof", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AmountCents") + .HasColumnType("integer") + .HasColumnName("amount_cents"); + + b.Property("AssetId") + .HasColumnType("uuid") + .HasColumnName("asset_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ExternalUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("external_url"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("PaidAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("paid_at"); + + b.Property("PaymentAccount") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("payment_account"); + + b.Property("PaymentMethod") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("payment_method"); + + b.Property("ProofType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("proof_type"); + + b.Property("ReviewNote") + .HasColumnType("text") + .HasColumnName("review_note"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("reviewed_at"); + + b.Property("ReviewedBy") + .HasColumnType("uuid") + .HasColumnName("reviewed_by"); + + b.Property("SettlementId") + .HasColumnType("uuid") + .HasColumnName("settlement_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("SubmittedBy") + .HasColumnType("uuid") + .HasColumnName("submitted_by"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_commission_settlement_proofs"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_commission_settlement_proofs_tenant_id_id"); + + b.HasIndex("ReviewedBy") + .HasDatabaseName("ix_commission_settlement_proofs_reviewed_by"); + + b.HasIndex("SubmittedBy") + .HasDatabaseName("ix_commission_settlement_proofs_submitted_by"); + + b.HasIndex("TenantId", "AssetId") + .HasDatabaseName("ix_commission_settlement_proofs_tenant_id_asset_id"); + + b.HasIndex("TenantId", "SettlementId", "Status", "CreatedAt") + .HasDatabaseName("ix_commission_settlement_proofs_tenant_id_settlement_id_status~"); + + b.ToTable("commission_settlement_proofs", null, t => + { + t.HasCheckConstraint("ck_commission_settlement_proofs_amount", "amount_cents is null or amount_cents >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.CrmConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AssignmentConfig") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("assignment_config") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("AssignmentCursor") + .HasColumnType("integer") + .HasColumnName("assignment_cursor"); + + b.Property("AssignmentMode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("assignment_mode"); + + b.Property("AssignmentPool") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("assignment_pool") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DelaySeconds") + .HasColumnType("integer") + .HasColumnName("delay_seconds"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("ExamType") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("exam_type"); + + b.Property("FormName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("form_name"); + + b.Property("SecretRef") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("secret_ref"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("Url") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_crm_config"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_crm_config_tenant_id_id"); + + b.HasIndex("TenantId") + .IsUnique() + .HasDatabaseName("ix_crm_config_tenant_id"); + + b.HasIndex("TenantId", "AssignmentMode") + .HasDatabaseName("ix_crm_config_tenant_id_assignment_mode"); + + b.ToTable("crm_config", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.CrmWebhookLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Attempt") + .HasColumnType("integer") + .HasColumnName("attempt"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("ErrorMessage") + .HasColumnType("text") + .HasColumnName("error_message"); + + b.Property("HttpCode") + .HasColumnType("integer") + .HasColumnName("http_code"); + + b.Property("LeadId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("lead_id"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Outcome") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("outcome"); + + b.Property("RecordId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("record_id"); + + b.Property("RequestBody") + .HasColumnType("text") + .HasColumnName("request_body"); + + b.Property("RequestPayload") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("request_payload") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("ResponseSummary") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("response_summary"); + + b.Property("SignedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("signed_at"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_crm_webhook_log"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_crm_webhook_log_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_crm_webhook_log_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "RecordId", "CreatedAt") + .HasDatabaseName("ix_crm_webhook_log_tenant_id_record_id_created_at"); + + b.ToTable("crm_webhook_log", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.CrmWebhookQueueItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("IdempotencyKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("idempotency_key"); + + b.Property("LastAttemptAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_attempt_at"); + + b.Property("LastError") + .HasColumnType("text") + .HasColumnName("last_error"); + + b.Property("LastHttpCode") + .HasColumnType("integer") + .HasColumnName("last_http_code"); + + b.Property("LastResponseSummary") + .HasColumnType("text") + .HasColumnName("last_response_summary"); + + b.Property("LeadId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("lead_id"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("NextAttemptAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_attempt_at"); + + b.Property("Payload") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("payload") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Provider") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("provider"); + + b.Property("RecordId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("record_id"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("scheduled_at"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("sent_at"); + + b.Property("Source") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("source"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TargetUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("target_url"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_crm_webhook_queue"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_crm_webhook_queue_tenant_id_id"); + + b.HasIndex("TenantId", "IdempotencyKey") + .IsUnique() + .HasDatabaseName("ix_crm_webhook_queue_tenant_id_idempotency_key") + .HasFilter("idempotency_key is not null"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_crm_webhook_queue_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "RecordId") + .IsUnique() + .HasDatabaseName("ix_crm_webhook_queue_tenant_id_record_id") + .HasFilter("record_id is not null"); + + b.HasIndex("TenantId", "RecordId", "CreatedAt") + .HasDatabaseName("ix_crm_webhook_queue_tenant_id_record_id_created_at") + .HasFilter("record_id is not null"); + + b.HasIndex("TenantId", "Source", "CreatedAt") + .HasDatabaseName("ix_crm_webhook_queue_tenant_id_source_created_at") + .HasFilter("source = 'tenant.student.crm_push'"); + + b.HasIndex("TenantId", "Status", "ScheduledAt", "NextAttemptAt") + .HasDatabaseName("ix_crm_webhook_queue_tenant_id_status_scheduled_at_next_attemp~"); + + b.ToTable("crm_webhook_queue", null, t => + { + t.HasCheckConstraint("ck_crm_webhook_queue_attempts", "attempts >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.ReferralCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Channel") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("channel"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("LandingPath") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("landing_path"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_referral_codes"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_referral_codes_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_referral_codes_user_id"); + + b.HasIndex("TenantId", "Code") + .IsUnique() + .HasDatabaseName("ix_referral_codes_tenant_id_code"); + + b.HasIndex("TenantId", "UserId") + .IsUnique() + .HasDatabaseName("ix_referral_codes_tenant_id_user_id"); + + b.HasIndex("TenantId", "Code", "Status") + .HasDatabaseName("ix_referral_codes_tenant_id_code_status"); + + b.ToTable("referral_codes", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.ReferralLead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("assigned_at"); + + b.Property("AssignedBy") + .HasColumnType("uuid") + .HasColumnName("assigned_by"); + + b.Property("AssignedToUserId") + .HasColumnType("uuid") + .HasColumnName("assigned_to_user_id"); + + b.Property("AssignmentMode") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("assignment_mode"); + + b.Property("BindType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("bind_type"); + + b.Property("BoundAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("bound_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("FirstTrackId") + .HasColumnType("uuid") + .HasColumnName("first_track_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("ProtectedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("protected_until"); + + b.Property("RefCode") + .HasMaxLength(100) + .HasColumnType("citext") + .HasColumnName("ref_code"); + + b.Property("ReferrerUserId") + .HasColumnType("uuid") + .HasColumnName("referrer_user_id"); + + b.Property("Source") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("source"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("StudentUserId") + .HasColumnType("uuid") + .HasColumnName("student_user_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_referral_leads"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_referral_leads_tenant_id_id"); + + b.HasIndex("AssignedBy") + .HasDatabaseName("ix_referral_leads_assigned_by"); + + b.HasIndex("AssignedToUserId") + .HasDatabaseName("ix_referral_leads_assigned_to_user_id"); + + b.HasIndex("ReferrerUserId") + .HasDatabaseName("ix_referral_leads_referrer_user_id"); + + b.HasIndex("StudentUserId") + .HasDatabaseName("ix_referral_leads_student_user_id"); + + b.HasIndex("TenantId", "FirstTrackId") + .HasDatabaseName("ix_referral_leads_tenant_id_first_track_id"); + + b.HasIndex("TenantId", "StudentUserId") + .IsUnique() + .HasDatabaseName("ix_referral_leads_tenant_id_student_user_id"); + + b.HasIndex("TenantId", "ReferrerUserId", "BoundAt") + .HasDatabaseName("ix_referral_leads_tenant_id_referrer_user_id_bound_at"); + + b.HasIndex("TenantId", "AssignedToUserId", "Status", "BoundAt") + .HasDatabaseName("ix_referral_leads_tenant_id_assigned_to_user_id_status_bound_at"); + + b.ToTable("referral_leads", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.ReferralQrcode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("ErrorMessage") + .HasColumnType("text") + .HasColumnName("error_message"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Page") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("page"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("provider"); + + b.Property("QrcodeUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("qrcode_url"); + + b.Property("RefCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext") + .HasColumnName("ref_code"); + + b.Property("Scene") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("scene"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_referral_qrcodes"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_referral_qrcodes_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_referral_qrcodes_user_id"); + + b.HasIndex("TenantId", "RefCode", "Status") + .HasDatabaseName("ix_referral_qrcodes_tenant_id_ref_code_status"); + + b.HasIndex("TenantId", "Provider", "Scene", "Page") + .IsUnique() + .HasDatabaseName("ix_referral_qrcodes_tenant_id_provider_scene_page"); + + b.ToTable("referral_qrcodes", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.ReferralTeamEdge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("LeaderUserId") + .HasColumnType("uuid") + .HasColumnName("leader_user_id"); + + b.Property("MemberUserId") + .HasColumnType("uuid") + .HasColumnName("member_user_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("RelationType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("relation_type"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_referral_team_edges"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_referral_team_edges_tenant_id_id"); + + b.HasIndex("LeaderUserId") + .HasDatabaseName("ix_referral_team_edges_leader_user_id"); + + b.HasIndex("MemberUserId") + .HasDatabaseName("ix_referral_team_edges_member_user_id"); + + b.HasIndex("TenantId", "LeaderUserId", "Status") + .HasDatabaseName("ix_referral_team_edges_tenant_id_leader_user_id_status"); + + b.HasIndex("TenantId", "MemberUserId", "RelationType") + .IsUnique() + .HasDatabaseName("ix_referral_team_edges_tenant_id_member_user_id_relation_type"); + + b.ToTable("referral_team_edges", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.ReferralTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("event_type"); + + b.Property("IpAddress") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("ip_address"); + + b.Property("LeadId") + .HasColumnType("uuid") + .HasColumnName("lead_id"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("RefCode") + .HasMaxLength(100) + .HasColumnType("citext") + .HasColumnName("ref_code"); + + b.Property("ReferrerUserId") + .HasColumnType("uuid") + .HasColumnName("referrer_user_id"); + + b.Property("Source") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("source"); + + b.Property("TargetUserId") + .HasColumnType("uuid") + .HasColumnName("target_user_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserAgent") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("user_agent"); + + b.HasKey("Id") + .HasName("pk_referral_tracks"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_referral_tracks_tenant_id_id"); + + b.HasIndex("ReferrerUserId") + .HasDatabaseName("ix_referral_tracks_referrer_user_id"); + + b.HasIndex("TargetUserId") + .HasDatabaseName("ix_referral_tracks_target_user_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_referral_tracks_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "LeadId", "CreatedAt") + .HasDatabaseName("ix_referral_tracks_tenant_id_lead_id_created_at"); + + b.HasIndex("TenantId", "RefCode", "EventType", "CreatedAt") + .HasDatabaseName("ix_referral_tracks_tenant_id_ref_code_event_type_created_at"); + + b.ToTable("referral_tracks", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.TenantCommissionSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Config") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("config") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DefaultRate") + .HasPrecision(6, 4) + .HasColumnType("numeric(6,4)") + .HasColumnName("default_rate"); + + b.Property("MinSettlementCents") + .HasColumnType("integer") + .HasColumnName("min_settlement_cents"); + + b.Property("SettlementCycle") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("settlement_cycle"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_tenant_commission_settings"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_commission_settings_tenant_id_id"); + + b.HasIndex("TenantId") + .IsUnique() + .HasDatabaseName("ix_tenant_commission_settings_tenant_id"); + + b.HasIndex("UpdatedBy") + .HasDatabaseName("ix_tenant_commission_settings_updated_by"); + + b.ToTable("tenant_commission_settings", null, t => + { + t.HasCheckConstraint("ck_tenant_commission_settings_default_rate", "default_rate >= 0 and default_rate <= 1"); + + t.HasCheckConstraint("ck_tenant_commission_settings_min_settlement", "min_settlement_cents >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Identity.StudentProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AvatarPreset") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasDefaultValue("male") + .HasColumnName("avatar_preset"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("LastCheckInDate") + .HasColumnType("date") + .HasColumnName("last_check_in_date"); + + b.Property("LegacyUserId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_user_id"); + + b.Property("MasteredWordsCount") + .HasColumnType("integer") + .HasColumnName("mastered_words_count"); + + b.Property("ModuleSelections") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("module_selections") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Progress") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("progress") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("QuestionsAnsweredToday") + .HasColumnType("integer") + .HasColumnName("questions_answered_today"); + + b.Property("RecentActivities") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("recent_activities") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SelectedMajorId") + .HasColumnType("uuid") + .HasColumnName("selected_major_id"); + + b.Property("SelectedSchoolId") + .HasColumnType("uuid") + .HasColumnName("selected_school_id"); + + b.Property("Stats") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("stats") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_student_profiles"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_student_profiles_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_student_profiles_user_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_student_profiles_tenant_id_region_id"); + + b.HasIndex("TenantId", "SelectedMajorId") + .HasDatabaseName("ix_student_profiles_tenant_id_selected_major_id"); + + b.HasIndex("TenantId", "SelectedSchoolId") + .HasDatabaseName("ix_student_profiles_tenant_id_selected_school_id"); + + b.HasIndex("TenantId", "UserId") + .IsUnique() + .HasDatabaseName("ix_student_profiles_tenant_id_user_id"); + + b.ToTable("student_profiles", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AccessFailedCount") + .HasColumnType("integer") + .HasColumnName("access_failed_count"); + + b.Property("AvatarUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("avatar_url"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("concurrency_stamp"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Email") + .HasMaxLength(320) + .HasColumnType("citext") + .HasColumnName("email"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean") + .HasColumnName("email_confirmed"); + + b.Property("ForcePasswordChange") + .HasColumnType("boolean") + .HasColumnName("force_password_change"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_at"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockout_end"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("NormalizedEmail") + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("normalized_user_name"); + + b.Property("PasswordHash") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("password_hash"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phone"); + + b.Property("PhoneNumber") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean") + .HasColumnName("phone_number_confirmed"); + + b.Property("PrimaryRole") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("primary_role"); + + b.Property("RawProfile") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("raw_profile") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Score") + .HasColumnType("integer") + .HasColumnName("score"); + + b.Property("SecurityStamp") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("security_stamp"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean") + .HasColumnName("two_factor_enabled"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("LegacyId") + .IsUnique() + .HasDatabaseName("ix_users_legacy_id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("email_index"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("user_name_index"); + + b.HasIndex("Phone") + .IsUnique() + .HasDatabaseName("ix_users_phone"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Identity.UserIdentity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Email") + .HasMaxLength(320) + .HasColumnType("citext") + .HasColumnName("email"); + + b.Property("OpenId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("open_id"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phone"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("provider"); + + b.Property("ProviderSubject") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("provider_subject"); + + b.Property("UnionId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("union_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_identities"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_identities_user_id"); + + b.HasIndex("Provider", "ProviderSubject") + .IsUnique() + .HasDatabaseName("ix_user_identities_provider_provider_subject"); + + b.ToTable("user_identities", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Import.PocketBaseImportIssue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CollectionName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("collection_name"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("FieldPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("field_path"); + + b.Property("IssueCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("issue_code"); + + b.Property("LegacyId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("legacy_id"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("RawValueSample") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("raw_value_sample"); + + b.Property("RunId") + .HasColumnType("uuid") + .HasColumnName("run_id"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("severity"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id") + .HasName("pk_pb_import_issues"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_pb_import_issues_tenant_id_id"); + + b.HasIndex("TenantId", "RunId") + .HasDatabaseName("ix_pb_import_issues_tenant_id_run_id"); + + b.HasIndex("RunId", "Severity", "CollectionName") + .HasDatabaseName("ix_pb_import_issues_run_id_severity_collection_name"); + + b.HasIndex("TenantId", "CollectionName", "LegacyId") + .HasDatabaseName("ix_pb_import_issues_tenant_id_collection_name_legacy_id"); + + b.ToTable("pb_import_issues", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Import.PocketBaseImportRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("SourceKind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("source_kind"); + + b.Property("SourceName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("source_name"); + + b.Property("StartedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at") + .HasDefaultValueSql("now()"); + + b.Property("Stats") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("stats") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id") + .HasName("pk_pb_import_runs"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_pb_import_runs_tenant_id_id"); + + b.HasIndex("TenantId", "Status", "StartedAt") + .HasDatabaseName("ix_pb_import_runs_tenant_id_status_started_at"); + + b.ToTable("pb_import_runs", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Import.PocketBaseRawRecord", b => + { + b.Property("RunId") + .HasColumnType("uuid") + .HasColumnName("run_id"); + + b.Property("CollectionName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("collection_name"); + + b.Property("LegacyId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("legacy_id"); + + b.Property("Errors") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("errors") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("ImportedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("imported_at") + .HasDefaultValueSql("now()"); + + b.Property("Normalized") + .HasColumnType("boolean") + .HasColumnName("normalized"); + + b.Property("Record") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("record") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("RunId", "CollectionName", "LegacyId") + .HasName("pk_pb_raw_records"); + + b.HasIndex("TenantId", "RunId") + .HasDatabaseName("ix_pb_raw_records_tenant_id_run_id"); + + b.HasIndex("TenantId", "CollectionName", "LegacyId") + .HasDatabaseName("ix_pb_raw_records_tenant_id_collection_name_legacy_id"); + + b.ToTable("pb_raw_records", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.AnswerRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AnswerText") + .HasColumnType("text") + .HasColumnName("answer_text"); + + b.Property("AnsweredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("answered_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("IsCorrect") + .HasColumnType("boolean") + .HasColumnName("is_correct"); + + b.Property("LegacyCategoryId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_category_id"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("LegacyQuestionId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_question_id"); + + b.Property("PracticeSessionId") + .HasColumnType("uuid") + .HasColumnName("practice_session_id"); + + b.Property("SelectedOptions") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("selected_options") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("SessionQuestionId") + .HasColumnType("uuid") + .HasColumnName("session_question_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_answer_records"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_answer_records_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_answer_records_user_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_answer_records_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "PracticeSessionId", "SessionQuestionId") + .HasDatabaseName("ix_answer_records_tenant_id_practice_session_id_session_questi~"); + + b.HasIndex("TenantId", "UserId", "PracticeSessionId") + .HasDatabaseName("ix_answer_records_tenant_id_user_id_practice_session_id"); + + b.ToTable("answer_records", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.DashboardDailyStat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ActiveUsers") + .HasColumnType("integer") + .HasColumnName("active_users"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("LegacyRegionId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_region_id"); + + b.Property("NewOrders") + .HasColumnType("integer") + .HasColumnName("new_orders"); + + b.Property("NewQuestions") + .HasColumnType("integer") + .HasColumnName("new_questions"); + + b.Property("NewRevenueCents") + .HasColumnType("integer") + .HasColumnName("new_revenue_cents"); + + b.Property("NewUsers") + .HasColumnType("integer") + .HasColumnName("new_users"); + + b.Property("RebuiltAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("rebuilt_at"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("StatDate") + .HasColumnType("date") + .HasColumnName("stat_date"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_dashboard_daily_stats"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_dashboard_daily_stats_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_dashboard_daily_stats_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_dashboard_daily_stats_tenant_id_region_id"); + + b.HasIndex("TenantId", "StatDate", "RegionId") + .IsUnique() + .HasDatabaseName("ix_dashboard_daily_stats_tenant_id_stat_date_region_id"); + + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("TenantId", "StatDate", "RegionId"), false); + + b.ToTable("dashboard_daily_stats", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.ExamDate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ExamAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("exam_at"); + + b.Property("ExamName") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("exam_name"); + + b.Property("ExamType") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("exam_type"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SchoolId") + .HasColumnType("uuid") + .HasColumnName("school_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_exam_dates"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_exam_dates_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_exam_dates_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_exam_dates_tenant_id_region_id"); + + b.HasIndex("TenantId", "SchoolId", "ExamAt") + .HasDatabaseName("ix_exam_dates_tenant_id_school_id_exam_at"); + + b.ToTable("exam_dates", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.FavoriteQuestion", b => + { + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("QuestionReferenceId") + .HasColumnType("uuid") + .HasColumnName("question_reference_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("QuestionId") + .HasColumnType("uuid") + .HasColumnName("question_id"); + + b.Property("QuestionOwnerTenantId") + .HasColumnType("uuid") + .HasColumnName("question_owner_tenant_id"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("source"); + + b.HasKey("TenantId", "UserId", "QuestionReferenceId") + .HasName("pk_favorite_questions"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_favorite_questions_user_id"); + + b.HasIndex("QuestionOwnerTenantId", "QuestionId") + .HasDatabaseName("ix_favorite_questions_question_owner_tenant_id_question_id"); + + b.HasIndex("TenantId", "QuestionReferenceId") + .HasDatabaseName("ix_favorite_questions_tenant_id_question_reference_id"); + + b.ToTable("favorite_questions", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.PracticeAccessEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AccessMode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("access_mode"); + + b.Property("ConsumedFreeQuota") + .HasColumnType("integer") + .HasColumnName("consumed_free_quota"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("EntitlementId") + .HasColumnType("uuid") + .HasColumnName("entitlement_id"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("event_type"); + + b.Property("GrantedCount") + .HasColumnType("integer") + .HasColumnName("granted_count"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("PracticeSessionId") + .HasColumnType("uuid") + .HasColumnName("practice_session_id"); + + b.Property("Reason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("reason"); + + b.Property("RequestedCount") + .HasColumnType("integer") + .HasColumnName("requested_count"); + + b.Property("ScopeId") + .HasColumnType("uuid") + .HasColumnName("scope_id"); + + b.Property("ScopeType") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("scope_type"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_practice_access_events"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_practice_access_events_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_practice_access_events_user_id"); + + b.HasIndex("TenantId", "PracticeSessionId") + .HasDatabaseName("ix_practice_access_events_tenant_id_practice_session_id"); + + b.HasIndex("TenantId", "UserId", "CreatedAt") + .HasDatabaseName("ix_practice_access_events_tenant_id_user_id_created_at"); + + b.ToTable("practice_access_events", null, t => + { + t.HasCheckConstraint("ck_practice_access_events_consumed_free_quota", "consumed_free_quota >= 0"); + + t.HasCheckConstraint("ck_practice_access_events_granted_count", "granted_count >= 0"); + + t.HasCheckConstraint("ck_practice_access_events_requested_count", "requested_count >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.PracticeDailyUsage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("FreeLimit") + .HasColumnType("integer") + .HasColumnName("free_limit"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("ScopeId") + .HasColumnType("uuid") + .HasColumnName("scope_id"); + + b.Property("ScopeType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("scope_type"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UsageDate") + .ValueGeneratedOnAdd() + .HasColumnType("date") + .HasColumnName("usage_date") + .HasDefaultValueSql("current_date"); + + b.Property("UsedCount") + .HasColumnType("integer") + .HasColumnName("used_count"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_practice_daily_usage"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_practice_daily_usage_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_practice_daily_usage_user_id"); + + b.HasIndex("TenantId", "UserId", "UsageDate") + .HasDatabaseName("ix_practice_daily_usage_tenant_id_user_id_usage_date"); + + b.HasIndex("TenantId", "UserId", "UsageDate", "ScopeType", "ScopeId") + .IsUnique() + .HasDatabaseName("ix_practice_daily_usage_tenant_id_user_id_usage_date_scope_typ~"); + + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("TenantId", "UserId", "UsageDate", "ScopeType", "ScopeId"), false); + + b.ToTable("practice_daily_usage", null, t => + { + t.HasCheckConstraint("ck_practice_daily_usage_free_limit", "free_limit >= 0"); + + t.HasCheckConstraint("ck_practice_daily_usage_used_count", "used_count >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.PracticeSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AccessEntitlementId") + .HasColumnType("uuid") + .HasColumnName("access_entitlement_id"); + + b.Property("AccessMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasDefaultValue("free") + .HasColumnName("access_mode"); + + b.Property("AccessSnapshot") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("access_snapshot") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("BlueprintId") + .HasColumnType("uuid") + .HasColumnName("blueprint_id"); + + b.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("ConsumedFreeQuota") + .HasColumnType("integer") + .HasColumnName("consumed_free_quota"); + + b.Property("ContentNodeId") + .HasColumnType("uuid") + .HasColumnName("content_node_id"); + + b.Property("DurationMinutes") + .HasColumnType("integer") + .HasColumnName("duration_minutes"); + + b.Property("EntryId") + .HasColumnType("uuid") + .HasColumnName("entry_id"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("mode"); + + b.Property("QuestionCount") + .HasColumnType("integer") + .HasColumnName("question_count"); + + b.Property("StartedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at") + .HasDefaultValueSql("now()"); + + b.Property("TargetId") + .HasColumnType("uuid") + .HasColumnName("target_id"); + + b.Property("TargetType") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("target_type"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TotalScore") + .HasPrecision(8, 2) + .HasColumnType("numeric(8,2)") + .HasColumnName("total_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_practice_sessions"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_practice_sessions_tenant_id_id"); + + b.HasAlternateKey("TenantId", "UserId", "Id") + .HasName("ak_practice_sessions_tenant_id_user_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_practice_sessions_user_id"); + + b.HasIndex("TenantId", "BlueprintId") + .HasDatabaseName("ix_practice_sessions_tenant_id_blueprint_id"); + + b.HasIndex("TenantId", "CollectionId") + .HasDatabaseName("ix_practice_sessions_tenant_id_collection_id"); + + b.HasIndex("TenantId", "ContentNodeId") + .HasDatabaseName("ix_practice_sessions_tenant_id_content_node_id"); + + b.HasIndex("TenantId", "EntryId") + .HasDatabaseName("ix_practice_sessions_tenant_id_entry_id"); + + b.HasIndex("TenantId", "UserId", "StartedAt") + .HasDatabaseName("ix_practice_sessions_tenant_id_user_id_started_at"); + + b.ToTable("practice_sessions", null, t => + { + t.HasCheckConstraint("ck_practice_sessions_consumed_free_quota", "consumed_free_quota >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.PracticeSessionQuestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("PracticeSessionId") + .HasColumnType("uuid") + .HasColumnName("practice_session_id"); + + b.Property("QuestionId") + .HasColumnType("uuid") + .HasColumnName("question_id"); + + b.Property("QuestionOwnerTenantId") + .HasColumnType("uuid") + .HasColumnName("question_owner_tenant_id"); + + b.Property("QuestionReferenceId") + .HasColumnType("uuid") + .HasColumnName("question_reference_id"); + + b.Property("QuestionVersionId") + .HasColumnType("uuid") + .HasColumnName("question_version_id"); + + b.Property("Score") + .HasPrecision(8, 2) + .HasColumnType("numeric(8,2)") + .HasColumnName("score"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id") + .HasName("pk_practice_session_questions"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_practice_session_questions_tenant_id_id"); + + b.HasAlternateKey("TenantId", "PracticeSessionId", "Id") + .HasName("ak_practice_session_questions_tenant_id_practice_session_id_id"); + + b.HasIndex("QuestionOwnerTenantId", "QuestionId", "QuestionVersionId") + .HasDatabaseName("ix_practice_session_questions_question_owner_tenant_id_questio~"); + + b.HasIndex("TenantId", "PracticeSessionId", "Position") + .IsUnique() + .HasDatabaseName("ix_practice_session_questions_tenant_id_practice_session_id_po~"); + + b.HasIndex("TenantId", "QuestionOwnerTenantId", "QuestionId") + .HasDatabaseName("ix_practice_session_questions_tenant_id_question_owner_tenant_~"); + + b.ToTable("practice_session_questions", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.PracticeSessionReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Accuracy") + .HasPrecision(6, 4) + .HasColumnType("numeric(6,4)") + .HasColumnName("accuracy"); + + b.Property("AnsweredCount") + .HasColumnType("integer") + .HasColumnName("answered_count"); + + b.Property("BlueprintId") + .HasColumnType("uuid") + .HasColumnName("blueprint_id"); + + b.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("CorrectCount") + .HasColumnType("integer") + .HasColumnName("correct_count"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("mode"); + + b.Property("PracticeSessionId") + .HasColumnType("uuid") + .HasColumnName("practice_session_id"); + + b.Property("QuestionResults") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("question_results") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("Score") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)") + .HasColumnName("score"); + + b.Property("SectionStats") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("section_stats") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("SubmittedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("submitted_at") + .HasDefaultValueSql("now()"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TotalQuestions") + .HasColumnType("integer") + .HasColumnName("total_questions"); + + b.Property("TotalScore") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)") + .HasColumnName("total_score"); + + b.Property("UnansweredCount") + .HasColumnType("integer") + .HasColumnName("unanswered_count"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("WrongCount") + .HasColumnType("integer") + .HasColumnName("wrong_count"); + + b.Property("WrongQuestionIds") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("wrong_question_ids") + .HasDefaultValueSql("'[]'::jsonb"); + + b.HasKey("Id") + .HasName("pk_practice_session_reports"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_practice_session_reports_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_practice_session_reports_user_id"); + + b.HasIndex("TenantId", "CollectionId") + .HasDatabaseName("ix_practice_session_reports_tenant_id_collection_id"); + + b.HasIndex("TenantId", "PracticeSessionId") + .IsUnique() + .HasDatabaseName("ix_practice_session_reports_tenant_id_practice_session_id"); + + b.HasIndex("TenantId", "BlueprintId", "SubmittedAt") + .HasDatabaseName("ix_practice_session_reports_tenant_id_blueprint_id_submitted_at") + .HasFilter("blueprint_id is not null"); + + b.HasIndex("TenantId", "UserId", "SubmittedAt") + .HasDatabaseName("ix_practice_session_reports_tenant_id_user_id_submitted_at"); + + b.ToTable("practice_session_reports", null, t => + { + t.HasCheckConstraint("ck_practice_session_reports_accuracy", "accuracy >= 0 and accuracy <= 1"); + + t.HasCheckConstraint("ck_practice_session_reports_counts", "total_questions >= 0 and answered_count >= 0 and correct_count >= 0 and wrong_count >= 0 and unanswered_count >= 0"); + + t.HasCheckConstraint("ck_practice_session_reports_duration", "duration_seconds >= 0"); + + t.HasCheckConstraint("ck_practice_session_reports_scores", "score >= 0 and total_score >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.PracticeSessionReportSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Accuracy") + .HasPrecision(6, 4) + .HasColumnType("numeric(6,4)") + .HasColumnName("accuracy"); + + b.Property("AnsweredCount") + .HasColumnType("integer") + .HasColumnName("answered_count"); + + b.Property("CorrectCount") + .HasColumnType("integer") + .HasColumnName("correct_count"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("PracticeSessionId") + .HasColumnType("uuid") + .HasColumnName("practice_session_id"); + + b.Property("QuestionCount") + .HasColumnType("integer") + .HasColumnName("question_count"); + + b.Property("QuestionType") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("question_type"); + + b.Property("ReportId") + .HasColumnType("uuid") + .HasColumnName("report_id"); + + b.Property("Score") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)") + .HasColumnName("score"); + + b.Property("SectionKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("section_key"); + + b.Property("SectionName") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("section_name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TotalScore") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)") + .HasColumnName("total_score"); + + b.Property("UnansweredCount") + .HasColumnType("integer") + .HasColumnName("unanswered_count"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("WrongCount") + .HasColumnType("integer") + .HasColumnName("wrong_count"); + + b.HasKey("Id") + .HasName("pk_practice_session_report_sections"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_practice_session_report_sections_tenant_id_id"); + + b.HasIndex("TenantId", "PracticeSessionId") + .HasDatabaseName("ix_practice_session_report_sections_tenant_id_practice_session~"); + + b.HasIndex("TenantId", "ReportId", "SectionKey") + .IsUnique() + .HasDatabaseName("ix_practice_session_report_sections_tenant_id_report_id_sectio~"); + + b.HasIndex("TenantId", "ReportId", "SortOrder") + .HasDatabaseName("ix_practice_session_report_sections_tenant_id_report_id_sort_o~"); + + b.ToTable("practice_session_report_sections", null, t => + { + t.HasCheckConstraint("ck_practice_session_report_sections_accuracy", "accuracy >= 0 and accuracy <= 1"); + + t.HasCheckConstraint("ck_practice_session_report_sections_counts", "question_count >= 0 and answered_count >= 0 and correct_count >= 0 and wrong_count >= 0 and unanswered_count >= 0"); + + t.HasCheckConstraint("ck_practice_session_report_sections_scores", "score >= 0 and total_score >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.RecentPractice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Color") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("color"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("LastAccessAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_access_at"); + + b.Property("LastPracticeAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_practice_at"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("PracticeType") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("practice_type"); + + b.Property("Progress") + .HasColumnType("integer") + .HasColumnName("progress"); + + b.Property("TargetLegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("target_legacy_id"); + + b.Property("TargetName") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("target_name"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_recent_practices"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_recent_practices_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_recent_practices_user_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_recent_practices_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "UserId", "LastAccessAt") + .HasDatabaseName("ix_recent_practices_tenant_id_user_id_last_access_at"); + + b.ToTable("recent_practices", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.Report", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Attachments") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("attachments") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("category"); + + b.Property("Contact") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("contact"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("HandledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("handled_at"); + + b.Property("HandledBy") + .HasColumnType("uuid") + .HasColumnName("handled_by"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("priority"); + + b.Property("QuestionId") + .HasColumnType("uuid") + .HasColumnName("question_id"); + + b.Property("Resolution") + .HasColumnType("text") + .HasColumnName("resolution"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("title"); + + b.Property("Type") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_reports"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_reports_tenant_id_id"); + + b.HasIndex("HandledBy") + .HasDatabaseName("ix_reports_handled_by"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_reports_user_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_reports_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "QuestionId", "CreatedAt") + .HasDatabaseName("ix_reports_tenant_id_question_id_created_at") + .HasFilter("question_id is not null"); + + b.HasIndex("TenantId", "Status", "CreatedAt") + .HasDatabaseName("ix_reports_tenant_id_status_created_at"); + + b.ToTable("reports", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.ReportStatusEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("FromStatus") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("from_status"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Note") + .HasColumnType("text") + .HasColumnName("note"); + + b.Property("ReportId") + .HasColumnType("uuid") + .HasColumnName("report_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("ToStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("to_status"); + + b.HasKey("Id") + .HasName("pk_report_status_events"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_report_status_events_tenant_id_id"); + + b.HasIndex("ActorUserId") + .HasDatabaseName("ix_report_status_events_actor_user_id"); + + b.HasIndex("TenantId", "ReportId", "CreatedAt") + .HasDatabaseName("ix_report_status_events_tenant_id_report_id_created_at"); + + b.ToTable("report_status_events", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.RevenueDailyStat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CodeCount") + .HasColumnType("integer") + .HasColumnName("code_count"); + + b.Property("CodeEstimated") + .HasColumnType("integer") + .HasColumnName("code_estimated"); + + b.Property("CodeUsed") + .HasColumnType("integer") + .HasColumnName("code_used"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("EstimatedRevenueCents") + .HasColumnType("integer") + .HasColumnName("estimated_revenue_cents"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("LegacyRegionId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_region_id"); + + b.Property("OrderCount") + .HasColumnType("integer") + .HasColumnName("order_count"); + + b.Property("RealRevenueCents") + .HasColumnType("integer") + .HasColumnName("real_revenue_cents"); + + b.Property("RebuiltAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("rebuilt_at"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SaleType") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("sale_type"); + + b.Property("StatDate") + .HasColumnType("date") + .HasColumnName("stat_date"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_revenue_daily_stats"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_revenue_daily_stats_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_revenue_daily_stats_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_revenue_daily_stats_tenant_id_region_id"); + + b.HasIndex("TenantId", "StatDate", "RegionId", "SaleType") + .IsUnique() + .HasDatabaseName("ix_revenue_daily_stats_tenant_id_stat_date_region_id_sale_type"); + + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("TenantId", "StatDate", "RegionId", "SaleType"), false); + + b.ToTable("revenue_daily_stats", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.UserScoreEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BalanceAfter") + .HasColumnType("integer") + .HasColumnName("balance_after"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("event_type"); + + b.Property("IdempotencyKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("idempotency_key"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Points") + .HasColumnType("integer") + .HasColumnName("points"); + + b.Property("SourceId") + .HasColumnType("uuid") + .HasColumnName("source_id"); + + b.Property("SourceType") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("source_type"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_score_events"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_user_score_events_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_score_events_user_id"); + + b.HasIndex("TenantId", "IdempotencyKey") + .IsUnique() + .HasDatabaseName("ix_user_score_events_tenant_id_idempotency_key"); + + b.HasIndex("TenantId", "UserId", "CreatedAt") + .HasDatabaseName("ix_user_score_events_tenant_id_user_id_created_at"); + + b.ToTable("user_score_events", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.WrongQuestion", b => + { + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("QuestionReferenceId") + .HasColumnType("uuid") + .HasColumnName("question_reference_id"); + + b.Property("LastWrongAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("last_wrong_at") + .HasDefaultValueSql("now()"); + + b.Property("QuestionId") + .HasColumnType("uuid") + .HasColumnName("question_id"); + + b.Property("QuestionOwnerTenantId") + .HasColumnType("uuid") + .HasColumnName("question_owner_tenant_id"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.Property("WrongCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1) + .HasColumnName("wrong_count"); + + b.HasKey("TenantId", "UserId", "QuestionReferenceId") + .HasName("pk_wrong_questions"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_wrong_questions_user_id"); + + b.HasIndex("QuestionOwnerTenantId", "QuestionId") + .HasDatabaseName("ix_wrong_questions_question_owner_tenant_id_question_id"); + + b.HasIndex("TenantId", "QuestionReferenceId") + .HasDatabaseName("ix_wrong_questions_tenant_id_question_reference_id"); + + b.ToTable("wrong_questions", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.Announcement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BackgroundColor") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("background_color"); + + b.Property("Content") + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Link") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("link"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_announcements"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_announcements_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_announcements_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "IsActive", "SortOrder") + .HasDatabaseName("ix_announcements_tenant_id_is_active_sort_order"); + + b.ToTable("announcements", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("action"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Details") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("details") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("IpAddress") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("ip_address"); + + b.Property("TargetId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("target_id"); + + b.Property("TargetType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("target_type"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UserAgent") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("user_agent"); + + b.HasKey("Id") + .HasName("pk_audit_logs"); + + b.HasIndex("ActorUserId") + .HasDatabaseName("ix_audit_logs_actor_user_id"); + + b.HasIndex("TenantId", "CreatedAt") + .HasDatabaseName("ix_audit_logs_tenant_id_created_at"); + + b.HasIndex("TenantId", "TargetType", "TargetId", "CreatedAt") + .HasDatabaseName("ix_audit_logs_tenant_id_target_type_target_id_created_at"); + + b.ToTable("audit_logs", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.BackendMenu", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Area") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("area"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Icon") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("icon"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ParentCode") + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("parent_code"); + + b.Property("Path") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("path"); + + b.Property("PermissionCode") + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("permission_code"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_backend_menus"); + + b.HasAlternateKey("Code") + .HasName("ak_backend_menus_code"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_backend_menus_code"); + + b.HasIndex("PermissionCode") + .HasDatabaseName("ix_backend_menus_permission_code"); + + b.HasIndex("Area", "ParentCode", "SortOrder") + .HasDatabaseName("ix_backend_menus_area_parent_code_sort_order"); + + b.ToTable("backend_menus", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.BackendPermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Area") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("area"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsSystem") + .HasColumnType("boolean") + .HasColumnName("is_system"); + + b.Property("Module") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("module"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_backend_permissions"); + + b.HasAlternateKey("Code") + .HasName("ak_backend_permissions_code"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_backend_permissions_code"); + + b.HasIndex("Area", "Module", "SortOrder") + .HasDatabaseName("ix_backend_permissions_area_module_sort_order"); + + b.ToTable("backend_permissions", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.BackgroundJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("JobType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("job_type"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)") + .HasColumnName("last_error"); + + b.Property("LockExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("lock_expires_at"); + + b.Property("LockedBy") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("locked_by"); + + b.Property("MaxRetries") + .HasColumnType("integer") + .HasColumnName("max_retries"); + + b.Property("OutputAssetId") + .HasColumnType("uuid") + .HasColumnName("output_asset_id"); + + b.Property("Payload") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("payload") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Result") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("result") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("RetryCount") + .HasColumnType("integer") + .HasColumnName("retry_count"); + + b.Property("RunAfter") + .HasColumnType("timestamp with time zone") + .HasColumnName("run_after"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_background_jobs"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_background_jobs_tenant_id_id"); + + b.HasIndex("TenantId", "OutputAssetId") + .HasDatabaseName("ix_background_jobs_tenant_id_output_asset_id"); + + b.HasIndex("TenantId", "JobType", "Status", "CreatedAt") + .HasDatabaseName("ix_background_jobs_tenant_id_job_type_status_created_at"); + + b.HasIndex("TenantId", "Status", "RunAfter", "CreatedAt") + .HasDatabaseName("ix_background_jobs_tenant_id_status_run_after_created_at"); + + b.ToTable("background_jobs", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.Badge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("category"); + + b.Property("ConditionExtra") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("condition_extra") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("ConditionField") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("condition_field"); + + b.Property("ConditionOperator") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("condition_operator"); + + b.Property("ConditionValue") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)") + .HasColumnName("condition_value"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IconUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("icon_url"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Level") + .HasColumnType("integer") + .HasColumnName("level"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UnlockType") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("unlock_type"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_badges"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_badges_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_badges_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "Category", "IsActive", "SortOrder") + .HasDatabaseName("ix_badges_tenant_id_category_is_active_sort_order"); + + b.ToTable("badges", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.Banner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BackgroundColor") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("background_color"); + + b.Property("BorderColor") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("border_color"); + + b.Property("ButtonLink") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("button_link"); + + b.Property("ButtonText") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("button_text"); + + b.Property("Content") + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Subtitle") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("subtitle"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_banners"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_banners_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_banners_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "RegionId", "IsActive", "SortOrder") + .HasDatabaseName("ix_banners_tenant_id_region_id_is_active_sort_order"); + + b.ToTable("banners", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.Faq", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Answer") + .HasColumnType("text") + .HasColumnName("answer"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Question") + .HasColumnType("text") + .HasColumnName("question"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_faqs"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_faqs_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_faqs_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "RegionId", "IsActive", "SortOrder") + .HasDatabaseName("ix_faqs_tenant_id_region_id_is_active_sort_order"); + + b.ToTable("faqs", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsSystem") + .HasColumnType("boolean") + .HasColumnName("is_system"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_platform_backend_roles"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_platform_backend_roles_code"); + + b.HasIndex("Status", "Code") + .HasDatabaseName("ix_platform_backend_roles_status_code"); + + b.ToTable("platform_backend_roles", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRoleMenu", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("MenuCode") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("menu_code"); + + b.Property("RoleId") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("Id") + .HasName("pk_platform_backend_role_menus"); + + b.HasIndex("MenuCode") + .HasDatabaseName("ix_platform_backend_role_menus_menu_code"); + + b.HasIndex("RoleId", "MenuCode") + .IsUnique() + .HasDatabaseName("ix_platform_backend_role_menus_role_id_menu_code"); + + b.ToTable("platform_backend_role_menus", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("PermissionCode") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("permission_code"); + + b.Property("RoleId") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.HasKey("Id") + .HasName("pk_platform_backend_role_permissions"); + + b.HasIndex("PermissionCode") + .HasDatabaseName("ix_platform_backend_role_permissions_permission_code"); + + b.HasIndex("RoleId", "PermissionCode") + .IsUnique() + .HasDatabaseName("ix_platform_backend_role_permissions_role_id_permission_code"); + + b.ToTable("platform_backend_role_permissions", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendUserRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("RoleId") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_platform_backend_user_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("ix_platform_backend_user_roles_role_id"); + + b.HasIndex("UserId", "RoleId") + .IsUnique() + .HasDatabaseName("ix_platform_backend_user_roles_user_id_role_id"); + + b.ToTable("platform_backend_user_roles", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DataScope") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("data_scope") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsSystem") + .HasColumnType("boolean") + .HasColumnName("is_system"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_tenant_backend_roles"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_backend_roles_tenant_id_id"); + + b.HasIndex("TenantId", "Code") + .IsUnique() + .HasDatabaseName("ix_tenant_backend_roles_tenant_id_code"); + + b.HasIndex("TenantId", "Status", "Code") + .HasDatabaseName("ix_tenant_backend_roles_tenant_id_status_code"); + + b.ToTable("tenant_backend_roles", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRoleMenu", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("MenuCode") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("menu_code"); + + b.Property("RoleId") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id") + .HasName("pk_tenant_backend_role_menus"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_backend_role_menus_tenant_id_id"); + + b.HasIndex("MenuCode") + .HasDatabaseName("ix_tenant_backend_role_menus_menu_code"); + + b.HasIndex("TenantId", "RoleId", "MenuCode") + .IsUnique() + .HasDatabaseName("ix_tenant_backend_role_menus_tenant_id_role_id_menu_code"); + + b.ToTable("tenant_backend_role_menus", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("PermissionCode") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("permission_code"); + + b.Property("RoleId") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id") + .HasName("pk_tenant_backend_role_permissions"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_backend_role_permissions_tenant_id_id"); + + b.HasIndex("PermissionCode") + .HasDatabaseName("ix_tenant_backend_role_permissions_permission_code"); + + b.HasIndex("TenantId", "RoleId", "PermissionCode") + .IsUnique() + .HasDatabaseName("ix_tenant_backend_role_permissions_tenant_id_role_id_permissio~"); + + b.ToTable("tenant_backend_role_permissions", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendUserRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("RoleId") + .HasColumnType("uuid") + .HasColumnName("role_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_tenant_backend_user_roles"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_backend_user_roles_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_tenant_backend_user_roles_user_id"); + + b.HasIndex("TenantId", "RoleId") + .HasDatabaseName("ix_tenant_backend_user_roles_tenant_id_role_id"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique() + .HasDatabaseName("ix_tenant_backend_user_roles_tenant_id_user_id_role_id"); + + b.ToTable("tenant_backend_user_roles", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.TenantContentNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ActionLabel") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("action_label"); + + b.Property("ActionPath") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("action_path"); + + b.Property("AdoptionId") + .HasColumnType("uuid") + .HasColumnName("adoption_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DedupeKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("dedupe_key"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("NotificationType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("notification_type"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("read_at"); + + b.Property("ReadBy") + .HasColumnType("uuid") + .HasColumnName("read_by"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("severity"); + + b.Property("SourceQuestionBankId") + .HasColumnType("uuid") + .HasColumnName("source_question_bank_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_tenant_content_notifications"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_content_notifications_tenant_id_id"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_tenant_content_notifications_created_by"); + + b.HasIndex("ReadBy") + .HasDatabaseName("ix_tenant_content_notifications_read_by"); + + b.HasIndex("TenantId", "SourceQuestionBankId") + .HasDatabaseName("ix_tenant_content_notifications_tenant_id_source_question_bank~"); + + b.HasIndex("TenantId", "NotificationType", "DedupeKey") + .IsUnique() + .HasDatabaseName("ix_tenant_content_notifications_tenant_id_notification_type_de~") + .HasFilter("dedupe_key is not null"); + + b.HasIndex("TenantId", "Status", "CreatedAt") + .HasDatabaseName("ix_tenant_content_notifications_tenant_id_status_created_at"); + + b.HasIndex("TenantId", "AdoptionId", "NotificationType", "Status") + .HasDatabaseName("ix_tenant_content_notifications_tenant_id_adoption_id_notifica~"); + + b.ToTable("tenant_content_notifications", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.TenantThemeConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ActivePublicAssets") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("active_public_assets") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("ActiveTemplateCode") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("active_template_code"); + + b.Property("ActiveTheme") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("active_theme") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DraftPublicAssets") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("draft_public_assets") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("DraftTemplateCode") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("draft_template_code"); + + b.Property("DraftTheme") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("draft_theme") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("DraftUpdatedBy") + .HasColumnType("uuid") + .HasColumnName("draft_updated_by"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("PublishedBy") + .HasColumnType("uuid") + .HasColumnName("published_by"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_tenant_theme_configs"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_theme_configs_tenant_id_id"); + + b.HasIndex("ActiveTemplateCode") + .HasDatabaseName("ix_tenant_theme_configs_active_template_code"); + + b.HasIndex("DraftTemplateCode") + .HasDatabaseName("ix_tenant_theme_configs_draft_template_code"); + + b.HasIndex("DraftUpdatedBy") + .HasDatabaseName("ix_tenant_theme_configs_draft_updated_by"); + + b.HasIndex("PublishedBy") + .HasDatabaseName("ix_tenant_theme_configs_published_by"); + + b.HasIndex("TenantId") + .IsUnique() + .HasDatabaseName("ix_tenant_theme_configs_tenant_id"); + + b.ToTable("tenant_theme_configs", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.TenantThemeTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("PreviewImageUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("preview_image_url"); + + b.Property("PublicAssets") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("public_assets") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("Theme") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("theme") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_tenant_theme_templates"); + + b.HasAlternateKey("Code") + .HasName("ak_tenant_theme_templates_code"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_tenant_theme_templates_code"); + + b.HasIndex("Status", "SortOrder", "Code") + .HasDatabaseName("ix_tenant_theme_templates_status_sort_order_code"); + + b.ToTable("tenant_theme_templates", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.UserBadge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BadgeId") + .HasColumnType("uuid") + .HasColumnName("badge_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("GrantedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("granted_at"); + + b.Property("GrantedBy") + .HasColumnType("uuid") + .HasColumnName("granted_by"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Note") + .HasColumnType("text") + .HasColumnName("note"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_badges"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_user_badges_tenant_id_id"); + + b.HasIndex("GrantedBy") + .HasDatabaseName("ix_user_badges_granted_by"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_badges_user_id"); + + b.HasIndex("TenantId", "BadgeId") + .HasDatabaseName("ix_user_badges_tenant_id_badge_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_user_badges_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "UserId", "BadgeId") + .IsUnique() + .HasDatabaseName("ix_user_badges_tenant_id_user_id_badge_id"); + + b.ToTable("user_badges", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.UserNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ActionLabel") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("action_label"); + + b.Property("ActionPath") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("action_path"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DedupeKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("dedupe_key"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("NotificationType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("notification_type"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("read_at"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("severity"); + + b.Property("SourceId") + .HasColumnType("uuid") + .HasColumnName("source_id"); + + b.Property("SourceType") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("source_type"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_notifications"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_user_notifications_tenant_id_id"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_user_notifications_created_by"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_notifications_user_id"); + + b.HasIndex("TenantId", "SourceType", "SourceId") + .HasDatabaseName("ix_user_notifications_tenant_id_source_type_source_id"); + + b.HasIndex("TenantId", "UserId", "NotificationType", "DedupeKey") + .IsUnique() + .HasDatabaseName("ix_user_notifications_tenant_id_user_id_notification_type_dedu~") + .HasFilter("dedupe_key is not null"); + + b.HasIndex("TenantId", "UserId", "Status", "CreatedAt") + .HasDatabaseName("ix_user_notifications_tenant_id_user_id_status_created_at"); + + b.ToTable("user_notifications", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlanModuleEntitlement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("ModuleCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("module_code"); + + b.Property("PlanCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("plan_code"); + + b.HasKey("Id") + .HasName("pk_plan_module_entitlements"); + + b.HasIndex("ModuleCode") + .HasDatabaseName("ix_plan_module_entitlements_module_code"); + + b.HasIndex("PlanCode", "ModuleCode") + .IsUnique() + .HasDatabaseName("ix_plan_module_entitlements_plan_code_module_code"); + + b.ToTable("plan_module_entitlements", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformAuditAlert", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AcknowledgedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("acknowledged_at"); + + b.Property("AcknowledgedBy") + .HasColumnType("uuid") + .HasColumnName("acknowledged_by"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("action"); + + b.Property("AuditLogId") + .HasColumnType("uuid") + .HasColumnName("audit_log_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Details") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("details") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("FirstSeenAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("first_seen_at") + .HasDefaultValueSql("now()"); + + b.Property("LastSeenAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_at") + .HasDefaultValueSql("now()"); + + b.Property("ResolutionNote") + .HasColumnType("text") + .HasColumnName("resolution_note"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.Property("ResolvedBy") + .HasColumnType("uuid") + .HasColumnName("resolved_by"); + + b.Property("RuleId") + .HasColumnType("uuid") + .HasColumnName("rule_id"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("severity"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("Summary") + .HasColumnType("text") + .HasColumnName("summary"); + + b.Property("TargetId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("target_id"); + + b.Property("TargetType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("target_type"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_platform_audit_alerts"); + + b.HasIndex("AcknowledgedBy") + .HasDatabaseName("ix_platform_audit_alerts_acknowledged_by"); + + b.HasIndex("AuditLogId") + .HasDatabaseName("ix_platform_audit_alerts_audit_log_id"); + + b.HasIndex("ResolvedBy") + .HasDatabaseName("ix_platform_audit_alerts_resolved_by"); + + b.HasIndex("RuleId", "AuditLogId") + .IsUnique() + .HasDatabaseName("ix_platform_audit_alerts_rule_id_audit_log_id"); + + b.HasIndex("Status", "Severity", "CreatedAt") + .HasDatabaseName("ix_platform_audit_alerts_status_severity_created_at"); + + b.HasIndex("TenantId", "Status", "CreatedAt") + .HasDatabaseName("ix_platform_audit_alerts_tenant_id_status_created_at"); + + b.ToTable("platform_audit_alerts", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformAuditAlertRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.PrimitiveCollection("ActionPatterns") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text[]") + .HasColumnName("action_patterns") + .HasDefaultValueSql("'{}'::text[]"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code"); + + b.Property("Conditions") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("conditions") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("severity"); + + b.PrimitiveCollection("TargetTypes") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text[]") + .HasColumnName("target_types") + .HasDefaultValueSql("'{}'::text[]"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_platform_audit_alert_rules"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_platform_audit_alert_rules_code"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_platform_audit_alert_rules_tenant_id"); + + b.HasIndex("Enabled", "Severity", "Code") + .HasDatabaseName("ix_platform_audit_alert_rules_enabled_severity_code"); + + b.ToTable("platform_audit_alert_rules", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformDunningNotificationChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ChannelCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("channel_code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("MinReminderLevel") + .HasColumnType("integer") + .HasColumnName("min_reminder_level"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("provider"); + + b.PrimitiveCollection("ReminderChannels") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text[]") + .HasColumnName("reminder_channels") + .HasDefaultValueSql("array['internal']::text[]"); + + b.PrimitiveCollection("ReminderTypes") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text[]") + .HasColumnName("reminder_types") + .HasDefaultValueSql("array['overdue', 'final_notice']::text[]"); + + b.Property("SecretRef") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("secret_ref"); + + b.PrimitiveCollection("TenantIds") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("uuid[]") + .HasColumnName("tenant_ids") + .HasDefaultValueSql("'{}'::uuid[]"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("WebhookUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("webhook_url"); + + b.HasKey("Id") + .HasName("pk_platform_dunning_notification_channels"); + + b.HasIndex("ChannelCode") + .IsUnique() + .HasDatabaseName("ix_platform_dunning_notification_channels_channel_code"); + + b.HasIndex("Enabled", "MinReminderLevel", "ChannelCode") + .HasDatabaseName("ix_platform_dunning_notification_channels_enabled_min_reminder~"); + + b.ToTable("platform_dunning_notification_channels", null, t => + { + t.HasCheckConstraint("ck_platform_dunning_channels_level", "min_reminder_level between 1 and 20"); + + t.HasCheckConstraint("ck_platform_dunning_channels_timeout", "timeout_seconds between 1 and 60"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformDunningNotificationEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("ChannelId") + .HasColumnType("uuid") + .HasColumnName("channel_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("InvoiceId") + .HasColumnType("uuid") + .HasColumnName("invoice_id"); + + b.Property("LastAttemptAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_attempt_at"); + + b.Property("LastError") + .HasColumnType("text") + .HasColumnName("last_error"); + + b.Property("LastHttpCode") + .HasColumnType("integer") + .HasColumnName("last_http_code"); + + b.Property("LastResponseSummary") + .HasColumnType("text") + .HasColumnName("last_response_summary"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("NextAttemptAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_attempt_at"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("provider"); + + b.Property("ReminderId") + .HasColumnType("uuid") + .HasColumnName("reminder_id"); + + b.Property("RequestPayload") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("request_payload") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("ScheduledAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("scheduled_at") + .HasDefaultValueSql("now()"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("sent_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_platform_dunning_notification_events"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_platform_dunning_notification_events_tenant_id_id"); + + b.HasIndex("ChannelId") + .HasDatabaseName("ix_platform_dunning_notification_events_channel_id"); + + b.HasIndex("TenantId", "InvoiceId") + .HasDatabaseName("ix_platform_dunning_notification_events_tenant_id_invoice_id"); + + b.HasIndex("TenantId", "ReminderId") + .HasDatabaseName("ix_platform_dunning_notification_events_tenant_id_reminder_id"); + + b.HasIndex("InvoiceId", "Status", "CreatedAt") + .HasDatabaseName("ix_platform_dunning_notification_events_invoice_id_status_crea~"); + + b.HasIndex("ReminderId", "Status", "CreatedAt") + .HasDatabaseName("ix_platform_dunning_notification_events_reminder_id_status_cre~"); + + b.HasIndex("TenantId", "ChannelId", "ReminderId") + .IsUnique() + .HasDatabaseName("ix_platform_dunning_notification_events_tenant_id_channel_id_r~"); + + b.HasIndex("TenantId", "Status", "CreatedAt") + .HasDatabaseName("ix_platform_dunning_notification_events_tenant_id_status_creat~"); + + b.HasIndex("Status", "NextAttemptAt", "ScheduledAt", "CreatedAt") + .HasDatabaseName("ix_platform_dunning_notification_events_status_next_attempt_at~"); + + b.ToTable("platform_dunning_notification_events", null, t => + { + t.HasCheckConstraint("ck_platform_dunning_events_attempts", "attempts >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformSaasPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BaseAmountCents") + .HasColumnType("integer") + .HasColumnName("base_amount_cents"); + + b.Property("BillingCycle") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("billing_cycle"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("currency"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("FeatureFlags") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("feature_flags") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("IncludedQuotas") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("included_quotas") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("OveragePrices") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("overage_prices") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_platform_saas_plans"); + + b.HasAlternateKey("Code") + .HasName("ak_platform_saas_plans_code"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_platform_saas_plans_code"); + + b.HasIndex("Status", "SortOrder") + .HasDatabaseName("ix_platform_saas_plans_status_sort_order"); + + b.ToTable("platform_saas_plans", null, t => + { + t.HasCheckConstraint("ck_platform_saas_plans_amount", "base_amount_cents >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.ProductModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_product_modules"); + + b.HasAlternateKey("Code") + .HasName("ak_product_modules_code"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_product_modules_code"); + + b.ToTable("product_modules", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.TenantBillingProfile", b => + { + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("BankAccountMasked") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("bank_account_masked"); + + b.Property("BankName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("bank_name"); + + b.Property("BillingAddress") + .HasColumnType("text") + .HasColumnName("billing_address"); + + b.Property("BillingName") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("billing_name"); + + b.Property("ContactEmail") + .HasMaxLength(300) + .HasColumnType("citext") + .HasColumnName("contact_email"); + + b.Property("ContactName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("contact_name"); + + b.Property("ContactPhone") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("contact_phone"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("InvoiceTitle") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("invoice_title"); + + b.Property("InvoiceType") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("invoice_type"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("TaxId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("tax_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("TenantId") + .HasName("pk_tenant_billing_profiles"); + + b.ToTable("tenant_billing_profiles", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.TenantInvoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BalanceCents") + .HasColumnType("integer") + .HasColumnName("balance_cents"); + + b.Property("BillingPeriodEnd") + .HasColumnType("date") + .HasColumnName("billing_period_end"); + + b.Property("BillingPeriodStart") + .HasColumnType("date") + .HasColumnName("billing_period_start"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("currency"); + + b.Property("DiscountCents") + .HasColumnType("integer") + .HasColumnName("discount_cents"); + + b.Property("DueDate") + .HasColumnType("date") + .HasColumnName("due_date"); + + b.Property("InvoiceNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("invoice_no"); + + b.Property("InvoiceType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("invoice_type"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("issued_at"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Note") + .HasColumnType("text") + .HasColumnName("note"); + + b.Property("PaidAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("paid_at"); + + b.Property("PaidCents") + .HasColumnType("integer") + .HasColumnName("paid_cents"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("SubtotalCents") + .HasColumnType("integer") + .HasColumnName("subtotal_cents"); + + b.Property("TaxCents") + .HasColumnType("integer") + .HasColumnName("tax_cents"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TotalCents") + .HasColumnType("integer") + .HasColumnName("total_cents"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_tenant_invoices"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_invoices_tenant_id_id"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_tenant_invoices_created_by"); + + b.HasIndex("TenantId", "InvoiceNo") + .IsUnique() + .HasDatabaseName("ix_tenant_invoices_tenant_id_invoice_no"); + + b.HasIndex("TenantId", "BillingPeriodStart", "BillingPeriodEnd") + .IsUnique() + .HasDatabaseName("ix_tenant_invoices_tenant_id_billing_period_start_billing_peri~") + .HasFilter("invoice_type = 'usage_overage' and status <> 'void' and metadata->>'source' = 'usage_overage_auto'"); + + b.HasIndex("TenantId", "Status", "DueDate") + .HasDatabaseName("ix_tenant_invoices_tenant_id_status_due_date"); + + b.ToTable("tenant_invoices", null, t => + { + t.HasCheckConstraint("ck_tenant_invoices_amounts", "subtotal_cents >= 0 and discount_cents >= 0 and tax_cents >= 0 and total_cents >= 0 and paid_cents >= 0 and balance_cents >= 0"); + + t.HasCheckConstraint("ck_tenant_invoices_period", "billing_period_end is null or billing_period_start is null or billing_period_end >= billing_period_start"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.TenantInvoiceItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AmountCents") + .HasColumnType("integer") + .HasColumnName("amount_cents"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.Property("InvoiceId") + .HasColumnType("uuid") + .HasColumnName("invoice_id"); + + b.Property("ItemRefId") + .HasColumnType("uuid") + .HasColumnName("item_ref_id"); + + b.Property("ItemType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("item_type"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Quantity") + .HasPrecision(12, 2) + .HasColumnType("numeric(12,2)") + .HasColumnName("quantity"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UnitAmountCents") + .HasColumnType("integer") + .HasColumnName("unit_amount_cents"); + + b.HasKey("Id") + .HasName("pk_tenant_invoice_items"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_invoice_items_tenant_id_id"); + + b.HasIndex("InvoiceId") + .HasDatabaseName("ix_tenant_invoice_items_invoice_id"); + + b.HasIndex("TenantId", "InvoiceId") + .HasDatabaseName("ix_tenant_invoice_items_tenant_id_invoice_id"); + + b.ToTable("tenant_invoice_items", null, t => + { + t.HasCheckConstraint("ck_tenant_invoice_items_amounts", "unit_amount_cents >= 0 and amount_cents >= 0"); + + t.HasCheckConstraint("ck_tenant_invoice_items_quantity", "quantity > 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.TenantInvoicePayment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AmountCents") + .HasColumnType("integer") + .HasColumnName("amount_cents"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("InvoiceId") + .HasColumnType("uuid") + .HasColumnName("invoice_id"); + + b.Property("Method") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("method"); + + b.Property("PaidAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("paid_at"); + + b.Property("PaymentNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("payment_no"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("provider"); + + b.Property("ProviderTradeNo") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("provider_trade_no"); + + b.Property("RawPayload") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("raw_payload") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("ReceivedBy") + .HasColumnType("uuid") + .HasColumnName("received_by"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_tenant_invoice_payments"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_invoice_payments_tenant_id_id"); + + b.HasIndex("ReceivedBy") + .HasDatabaseName("ix_tenant_invoice_payments_received_by"); + + b.HasIndex("InvoiceId", "Status") + .HasDatabaseName("ix_tenant_invoice_payments_invoice_id_status"); + + b.HasIndex("TenantId", "InvoiceId") + .HasDatabaseName("ix_tenant_invoice_payments_tenant_id_invoice_id"); + + b.HasIndex("TenantId", "PaymentNo") + .IsUnique() + .HasDatabaseName("ix_tenant_invoice_payments_tenant_id_payment_no"); + + b.ToTable("tenant_invoice_payments", null, t => + { + t.HasCheckConstraint("ck_tenant_invoice_payments_amount", "amount_cents >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.TenantInvoiceReminder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AcknowledgedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("acknowledged_at"); + + b.Property("BalanceCentsSnapshot") + .HasColumnType("integer") + .HasColumnName("balance_cents_snapshot"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("channel"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DueDate") + .HasColumnType("date") + .HasColumnName("due_date"); + + b.Property("InvoiceId") + .HasColumnType("uuid") + .HasColumnName("invoice_id"); + + b.Property("Message") + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("ReminderDate") + .HasColumnType("date") + .HasColumnName("reminder_date"); + + b.Property("ReminderLevel") + .HasColumnType("integer") + .HasColumnName("reminder_level"); + + b.Property("ReminderType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("reminder_type"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("sent_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_tenant_invoice_reminders"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_invoice_reminders_tenant_id_id"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_tenant_invoice_reminders_created_by"); + + b.HasIndex("InvoiceId", "ReminderDate") + .HasDatabaseName("ix_tenant_invoice_reminders_invoice_id_reminder_date"); + + b.HasIndex("TenantId", "Status", "ReminderDate") + .HasDatabaseName("ix_tenant_invoice_reminders_tenant_id_status_reminder_date"); + + b.HasIndex("TenantId", "InvoiceId", "ReminderType", "Channel", "ReminderDate") + .IsUnique() + .HasDatabaseName("ix_tenant_invoice_reminders_tenant_id_invoice_id_reminder_type~"); + + b.ToTable("tenant_invoice_reminders", null, t => + { + t.HasCheckConstraint("ck_tenant_invoice_reminders_balance", "balance_cents_snapshot >= 0"); + + t.HasCheckConstraint("ck_tenant_invoice_reminders_level", "reminder_level between 1 and 20"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.TenantModuleOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("mode"); + + b.Property("ModuleCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("module_code"); + + b.Property("Reason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("reason"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_tenant_module_overrides"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_module_overrides_tenant_id_id"); + + b.HasIndex("ModuleCode") + .HasDatabaseName("ix_tenant_module_overrides_module_code"); + + b.HasIndex("TenantId", "ModuleCode") + .IsUnique() + .HasDatabaseName("ix_tenant_module_overrides_tenant_id_module_code"); + + b.ToTable("tenant_module_overrides", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.QuestionBanks.Question", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CategoryId") + .HasColumnType("uuid") + .HasColumnName("category_id"); + + b.Property("ContentNodeId") + .HasColumnType("uuid") + .HasColumnName("content_node_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CurrentVersionId") + .HasColumnType("uuid") + .HasColumnName("current_version_id"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("EntryId") + .HasColumnType("uuid") + .HasColumnName("entry_id"); + + b.Property("ExamMarkers") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("exam_markers") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("HasVideoExplanation") + .HasColumnType("boolean") + .HasColumnName("has_video_explanation"); + + b.Property("LegacyCategoryId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_category_id"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("LegacyNodeId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_node_id"); + + b.Property("LegacySubjectId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_subject_id"); + + b.Property("MediaUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("media_url"); + + b.Property("NodeId") + .HasColumnType("uuid") + .HasColumnName("node_id"); + + b.Property("PrimaryCollectionId") + .HasColumnType("uuid") + .HasColumnName("primary_collection_id"); + + b.Property("QuestionBankId") + .HasColumnType("uuid") + .HasColumnName("question_bank_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("SubjectId") + .HasColumnType("uuid") + .HasColumnName("subject_id"); + + b.Property("Tags") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("tags") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("type"); + + b.Property("TypeLabel") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("type_label"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_questions"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_questions_tenant_id_id"); + + b.HasIndex("TenantId", "CategoryId") + .HasDatabaseName("ix_questions_tenant_id_category_id"); + + b.HasIndex("TenantId", "ContentNodeId") + .HasDatabaseName("ix_questions_tenant_id_content_node_id"); + + b.HasIndex("TenantId", "EntryId") + .HasDatabaseName("ix_questions_tenant_id_entry_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_questions_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "NodeId") + .HasDatabaseName("ix_questions_tenant_id_node_id"); + + b.HasIndex("TenantId", "PrimaryCollectionId") + .HasDatabaseName("ix_questions_tenant_id_primary_collection_id"); + + b.HasIndex("TenantId", "QuestionBankId") + .HasDatabaseName("ix_questions_tenant_id_question_bank_id"); + + b.HasIndex("TenantId", "SubjectId") + .HasDatabaseName("ix_questions_tenant_id_subject_id"); + + b.HasIndex("TenantId", "Id", "CurrentVersionId") + .HasDatabaseName("ix_questions_tenant_id_id_current_version_id"); + + b.ToTable("questions", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.QuestionBanks.QuestionBank", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_question_banks"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_question_banks_tenant_id_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_question_banks_tenant_id_region_id"); + + b.ToTable("question_banks", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.QuestionBanks.QuestionVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AnswerText") + .HasColumnType("text") + .HasColumnName("answer_text"); + + b.Property("CodeLang") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("code_lang"); + + b.Property("CodeTemplate") + .HasColumnType("text") + .HasColumnName("code_template"); + + b.Property("Content") + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("CorrectOptionIndex") + .HasColumnType("integer") + .HasColumnName("correct_option_index"); + + b.Property("CorrectOptionIndices") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("correct_option_indices") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("Explanation") + .HasColumnType("text") + .HasColumnName("explanation"); + + b.Property("Options") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("options") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("QuestionId") + .HasColumnType("uuid") + .HasColumnName("question_id"); + + b.Property("SourceHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_hash"); + + b.Property("SubQuestions") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("sub_questions") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("VersionNo") + .HasColumnType("integer") + .HasColumnName("version_no"); + + b.HasKey("Id") + .HasName("pk_question_versions"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_question_versions_tenant_id_id"); + + b.HasAlternateKey("TenantId", "QuestionId", "Id") + .HasName("ak_question_versions_tenant_id_question_id_id"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_question_versions_created_by"); + + b.HasIndex("TenantId", "QuestionId", "VersionNo") + .IsUnique() + .HasDatabaseName("ix_question_versions_tenant_id_question_id_version_no"); + + b.ToTable("question_versions", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.AuthChallenge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("consumed_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("IpAddress") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("ip_address"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("provider"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("purpose"); + + b.Property("Realm") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("realm"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("security_stamp"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.Property("UserAgent") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_auth_challenges"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_auth_challenges_tenant_id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_auth_challenges_token_hash"); + + b.HasIndex("UserId", "Purpose", "ExpiresAt") + .HasDatabaseName("ix_auth_challenges_user_id_purpose_expires_at"); + + b.ToTable("auth_challenges", null, t => + { + t.HasCheckConstraint("ck_auth_challenges_realm_tenant", "(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.AuthLoginEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("FailureCode") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("failure_code"); + + b.Property("Identifier") + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("identifier"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("ip_address"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("provider"); + + b.Property("Result") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("result"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UserAgent") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_auth_login_events"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_auth_login_events_user_id"); + + b.HasIndex("TenantId", "UserId", "CreatedAt") + .HasDatabaseName("ix_auth_login_events_tenant_id_user_id_created_at"); + + b.ToTable("auth_login_events", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.AuthSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("ip_address"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("ParentSessionId") + .HasColumnType("uuid") + .HasColumnName("parent_session_id"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("provider"); + + b.Property("Realm") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("realm"); + + b.Property("ReplacedBySessionId") + .HasColumnType("uuid") + .HasColumnName("replaced_by_session_id"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("RevokedReason") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("revoked_reason"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("security_stamp"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TokenFamilyId") + .HasColumnType("uuid") + .HasColumnName("token_family_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("token_hash"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserAgent") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_auth_sessions"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_auth_sessions_tenant_id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_auth_sessions_token_hash") + .HasAnnotation("Tiku:GlobalUnique", true); + + b.HasIndex("UserId") + .HasDatabaseName("ix_auth_sessions_user_id"); + + b.HasIndex("TokenFamilyId", "RevokedAt") + .HasDatabaseName("ix_auth_sessions_token_family_id_revoked_at"); + + b.HasIndex("Realm", "TenantId", "UserId", "ExpiresAt") + .HasDatabaseName("ix_auth_sessions_realm_tenant_id_user_id_expires_at") + .HasFilter("revoked_at is null"); + + b.ToTable("auth_sessions", null, t => + { + t.HasCheckConstraint("ck_auth_sessions_realm_tenant", "(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.SmsSendRateLimit", b => + { + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Dimension") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("dimension"); + + b.Property("ScopeHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("scope_hash"); + + b.Property("BucketStart") + .HasColumnType("timestamp with time zone") + .HasColumnName("bucket_start"); + + b.Property("RequestCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("request_count"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("TenantId", "Dimension", "ScopeHash", "BucketStart") + .HasName("pk_sms_send_rate_limits"); + + b.HasIndex("UpdatedAt") + .HasDatabaseName("ix_sms_send_rate_limits_updated_at"); + + b.ToTable("sms_send_rate_limits", null, t => + { + t.HasCheckConstraint("ck_sms_send_rate_limits_request_count", "request_count >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.SmsVerificationCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("code_hash"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("consumed_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("ip_address"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phone"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("provider"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("purpose"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UserAgent") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("user_agent"); + + b.HasKey("Id") + .HasName("pk_sms_verification_codes"); + + b.HasIndex("TenantId", "Phone", "Purpose") + .IsUnique() + .HasDatabaseName("ix_sms_verification_codes_tenant_id_phone_purpose") + .HasFilter("consumed_at is null and status in ('pending', 'sent')"); + + b.HasIndex("TenantId", "Phone", "Purpose", "CreatedAt") + .HasDatabaseName("ix_sms_verification_codes_tenant_id_phone_purpose_created_at") + .HasFilter("consumed_at is null and status in ('pending', 'sent')"); + + b.HasIndex("TenantId", "Phone", "Purpose", "ExpiresAt") + .HasDatabaseName("ix_sms_verification_codes_tenant_id_phone_purpose_expires_at"); + + b.ToTable("sms_verification_codes", null, t => + { + t.HasCheckConstraint("ck_sms_verification_codes_attempts", "attempts >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BillingStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("billing_status"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("LegalName") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("legal_name"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("mode"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("OwnerUserId") + .HasColumnType("uuid") + .HasColumnName("owner_user_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext") + .HasColumnName("slug"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_tenants"); + + b.HasIndex("LegacyId") + .IsUnique() + .HasDatabaseName("ix_tenants_legacy_id"); + + b.HasIndex("Mode") + .IsUnique() + .HasDatabaseName("ix_tenants_mode") + .HasFilter("mode = 'platform_owned'"); + + b.HasIndex("OwnerUserId") + .HasDatabaseName("ix_tenants_owner_user_id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_tenants_slug"); + + b.ToTable("tenants", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantAuthPolicy", b => + { + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("AllowExternalStudentSelfRegistration") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("allow_external_student_self_registration"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("TenantId") + .HasName("pk_tenant_auth_policies"); + + b.ToTable("tenant_auth_policies", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantBranding", b => + { + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("BrandName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("brand_name"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("FaviconUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("favicon_url"); + + b.Property("LogoUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("logo_url"); + + b.Property("OrganizationName") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("organization_name"); + + b.Property("PublicAssets") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("public_assets") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("ServiceAccountName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("service_account_name"); + + b.Property("ServiceWechat") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("service_wechat"); + + b.Property("ShortName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("short_name"); + + b.Property("Slogan") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("slogan"); + + b.Property("Theme") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("theme") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("TenantId") + .HasName("pk_tenant_branding"); + + b.ToTable("tenant_branding", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantClass", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Code") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_tenant_classes"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_classes_tenant_id_id"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_tenant_classes_created_by"); + + b.HasIndex("UpdatedBy") + .HasDatabaseName("ix_tenant_classes_updated_by"); + + b.HasIndex("TenantId", "Code") + .IsUnique() + .HasDatabaseName("ix_tenant_classes_tenant_id_code") + .HasFilter("code is not null and code <> ''"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_tenant_classes_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_tenant_classes_tenant_id_region_id") + .HasFilter("region_id is not null"); + + b.HasIndex("TenantId", "Status", "SortOrder", "CreatedAt") + .HasDatabaseName("ix_tenant_classes_tenant_id_status_sort_order_created_at"); + + b.ToTable("tenant_classes", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantClassMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ClassId") + .HasColumnType("uuid") + .HasColumnName("class_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("JoinedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at") + .HasDefaultValueSql("now()"); + + b.Property("LeftAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("left_at"); + + b.Property("MemberType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("member_type"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_tenant_class_members"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_class_members_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_tenant_class_members_user_id"); + + b.HasIndex("TenantId", "ClassId", "Status", "MemberType") + .HasDatabaseName("ix_tenant_class_members_tenant_id_class_id_status_member_type"); + + b.HasIndex("TenantId", "ClassId", "UserId", "MemberType") + .IsUnique() + .HasDatabaseName("ix_tenant_class_members_tenant_id_class_id_user_id_member_type"); + + b.HasIndex("TenantId", "UserId", "Status", "MemberType") + .HasDatabaseName("ix_tenant_class_members_tenant_id_user_id_status_member_type"); + + b.ToTable("tenant_class_members", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantDomain", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DnsVerifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("dns_verified_at"); + + b.Property("DomainType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("domain_type"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("citext") + .HasColumnName("host"); + + b.Property("IsPrimary") + .HasColumnType("boolean") + .HasColumnName("is_primary"); + + b.Property("LastCheckedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_checked_at"); + + b.Property("LastFailureReason") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("last_failure_reason"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TlsReadyAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("tls_ready_at"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("VerificationToken") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("verification_token"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("verified_at"); + + b.HasKey("Id") + .HasName("pk_tenant_domains"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_domains_tenant_id_id"); + + b.HasIndex("Host") + .IsUnique() + .HasDatabaseName("ix_tenant_domains_host") + .HasAnnotation("Tiku:GlobalUnique", true); + + b.HasIndex("TenantId", "IsPrimary") + .IsUnique() + .HasDatabaseName("ix_tenant_domains_tenant_id_is_primary") + .HasFilter("is_primary"); + + b.ToTable("tenant_domains", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantExternalProvider", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Capability") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("capability"); + + b.Property("ConfigPublic") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("config_public") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("provider"); + + b.Property("SecretRef") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("secret_ref"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_tenant_external_providers"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_external_providers_tenant_id_id"); + + b.HasIndex("TenantId", "Capability", "Provider") + .IsUnique() + .HasDatabaseName("ix_tenant_external_providers_tenant_id_capability_provider"); + + b.HasIndex("TenantId", "Capability", "Status", "Priority") + .HasDatabaseName("ix_tenant_external_providers_tenant_id_capability_status_prior~"); + + b.ToTable("tenant_external_providers", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantFrontendConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ConfigVersion") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("config_version"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DraftBranding") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("draft_branding") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("DraftFeatures") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("draft_features") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("DraftHomeModules") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("draft_home_modules") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("DraftNavigation") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("draft_navigation") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("DraftTheme") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("draft_theme") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("PublishedBranding") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("published_branding") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("PublishedFeatures") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("published_features") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("PublishedHomeModules") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("published_home_modules") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("PublishedNavigation") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("published_navigation") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("PublishedTheme") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("published_theme") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("SchemaVersion") + .HasColumnType("integer") + .HasColumnName("schema_version"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_tenant_frontend_configs"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_frontend_configs_tenant_id_id"); + + b.HasIndex("TenantId") + .IsUnique() + .HasDatabaseName("ix_tenant_frontend_configs_tenant_id"); + + b.ToTable("tenant_frontend_configs", null, t => + { + t.HasCheckConstraint("ck_tenant_frontend_configs_config_version", "config_version > 0"); + + t.HasCheckConstraint("ck_tenant_frontend_configs_schema_version", "schema_version > 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("LegacyRole") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("legacy_role"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("role"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_tenant_memberships"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_memberships_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_tenant_memberships_user_id"); + + b.HasIndex("TenantId", "UserId", "Role") + .IsUnique() + .HasDatabaseName("ix_tenant_memberships_tenant_id_user_id_role"); + + b.ToTable("tenant_memberships", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("encrypted_payload"); + + b.Property("EncryptionKeyId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("encryption_key_id"); + + b.Property("EncryptionNonce") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("encryption_nonce"); + + b.Property("EncryptionTag") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("encryption_tag"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("provider"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .HasColumnName("purpose"); + + b.Property("RotatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("rotated_at"); + + b.Property("SecretKey") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("secret_key"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("secret_ref"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_tenant_secrets"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_secrets_tenant_id_id"); + + b.HasIndex("TenantId", "SecretRef") + .IsUnique() + .HasDatabaseName("ix_tenant_secrets_tenant_id_secret_ref"); + + b.HasIndex("TenantId", "Purpose", "Provider", "SecretKey") + .IsUnique() + .HasDatabaseName("ix_tenant_secrets_tenant_id_purpose_provider_secret_key"); + + b.HasIndex("TenantId", "Purpose", "Provider", "Status") + .HasDatabaseName("ix_tenant_secrets_tenant_id_purpose_provider_status"); + + b.ToTable("tenant_secrets", null, t => + { + t.HasCheckConstraint("ck_tenant_secrets_encryption_envelope", "octet_length(encrypted_payload) > 0 and octet_length(encryption_nonce) = 12 and octet_length(encryption_tag) = 16 and encryption_key_id <> ''"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantSettings", b => + { + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("AdminFeatureFlags") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("admin_feature_flags") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("FeatureFlags") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("feature_flags") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("PublicConfig") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("public_config") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("TenantId") + .HasName("pk_tenant_settings"); + + b.ToTable("tenant_settings", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantStudentFollowup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AssignedToUserId") + .HasColumnType("uuid") + .HasColumnName("assigned_to_user_id"); + + b.Property("ClassId") + .HasColumnType("uuid") + .HasColumnName("class_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CompletedBy") + .HasColumnType("uuid") + .HasColumnName("completed_by"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("DueAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("due_at"); + + b.Property("FollowupType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("followup_type"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("priority"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("StudentUserId") + .HasColumnType("uuid") + .HasColumnName("student_user_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_tenant_student_followups"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_student_followups_tenant_id_id"); + + b.HasIndex("AssignedToUserId") + .HasDatabaseName("ix_tenant_student_followups_assigned_to_user_id"); + + b.HasIndex("CompletedBy") + .HasDatabaseName("ix_tenant_student_followups_completed_by"); + + b.HasIndex("StudentUserId") + .HasDatabaseName("ix_tenant_student_followups_student_user_id"); + + b.HasIndex("TenantId", "AssignedToUserId", "Status", "DueAt") + .HasDatabaseName("ix_tenant_student_followups_tenant_id_assigned_to_user_id_stat~") + .HasFilter("assigned_to_user_id is not null"); + + b.HasIndex("TenantId", "ClassId", "Status", "DueAt") + .HasDatabaseName("ix_tenant_student_followups_tenant_id_class_id_status_due_at") + .HasFilter("class_id is not null"); + + b.HasIndex("TenantId", "StudentUserId", "Status", "DueAt", "CreatedAt") + .HasDatabaseName("ix_tenant_student_followups_tenant_id_student_user_id_status_d~"); + + b.ToTable("tenant_student_followups", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantStudentNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("IsPinned") + .HasColumnType("boolean") + .HasColumnName("is_pinned"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("NoteType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("note_type"); + + b.Property("StudentUserId") + .HasColumnType("uuid") + .HasColumnName("student_user_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Visibility") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("visibility"); + + b.HasKey("Id") + .HasName("pk_tenant_student_notes"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_student_notes_tenant_id_id"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_tenant_student_notes_created_by"); + + b.HasIndex("StudentUserId") + .HasDatabaseName("ix_tenant_student_notes_student_user_id"); + + b.HasIndex("UpdatedBy") + .HasDatabaseName("ix_tenant_student_notes_updated_by"); + + b.HasIndex("TenantId", "CreatedBy", "CreatedAt") + .HasDatabaseName("ix_tenant_student_notes_tenant_id_created_by_created_at") + .HasFilter("created_by is not null"); + + b.HasIndex("TenantId", "StudentUserId", "IsPinned", "CreatedAt") + .HasDatabaseName("ix_tenant_student_notes_tenant_id_student_user_id_is_pinned_cr~"); + + b.ToTable("tenant_student_notes", (string)null); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId") + .HasConstraintName("fk_outbox_message_outbox_state_outbox_id"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId") + .HasConstraintName("fk_outbox_message_inbox_state_inbox_message_id_inbox_consumer_~"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_claims_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_logins_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_tokens_users_user_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.Category", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_categories_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.ModuleNode", null) + .WithMany() + .HasForeignKey("TenantId", "NodeId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_categories_module_nodes_tenant_id_node_id"); + + b.HasOne("Tiku.Domain.Catalog.Subject", null) + .WithMany() + .HasForeignKey("TenantId", "SubjectId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_categories_subjects_tenant_id_subject_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.Major", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_majors_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_majors_regions_tenant_id_region_id"); + + b.HasOne("Tiku.Domain.Catalog.School", null) + .WithMany() + .HasForeignKey("TenantId", "SchoolId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_majors_schools_tenant_id_school_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.ModuleNode", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_module_nodes_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.RegionModule", null) + .WithMany() + .HasForeignKey("TenantId", "ModuleId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_module_nodes_region_modules_tenant_id_module_id"); + + b.HasOne("Tiku.Domain.Catalog.ModuleNode", null) + .WithMany() + .HasForeignKey("TenantId", "ParentId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_module_nodes_module_nodes_tenant_id_parent_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_module_nodes_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.QuestionTaxonomyAssignment", b => + { + b.HasOne("Tiku.Domain.Catalog.TaxonomyNode", null) + .WithMany() + .HasForeignKey("TaxonomyOwnerTenantId", "TaxonomyNodeId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_question_taxonomy_assignments_taxonomy_nodes_taxonomy_owner~"); + + b.HasOne("Tiku.Domain.QuestionBanks.Question", null) + .WithMany() + .HasForeignKey("TenantId", "QuestionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_question_taxonomy_assignments_questions_tenant_id_question_~"); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.Region", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_regions_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.RegionModule", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_region_modules_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_region_modules_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.School", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_schools_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.RegionModule", null) + .WithMany() + .HasForeignKey("TenantId", "ModuleId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_schools_region_modules_tenant_id_module_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_schools_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.ScorelineField", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_scoreline_fields_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_scoreline_fields_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.ScorelineRecord", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_scoreline_records_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Major", null) + .WithMany() + .HasForeignKey("TenantId", "MajorId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_scoreline_records_majors_tenant_id_major_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_scoreline_records_regions_tenant_id_region_id"); + + b.HasOne("Tiku.Domain.Catalog.School", null) + .WithMany() + .HasForeignKey("TenantId", "SchoolId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_scoreline_records_schools_tenant_id_school_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.Subject", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_subjects_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Major", null) + .WithMany() + .HasForeignKey("TenantId", "MajorId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subjects_majors_tenant_id_major_id"); + + b.HasOne("Tiku.Domain.Catalog.RegionModule", null) + .WithMany() + .HasForeignKey("TenantId", "ModuleId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subjects_region_modules_tenant_id_module_id"); + + b.HasOne("Tiku.Domain.Catalog.ModuleNode", null) + .WithMany() + .HasForeignKey("TenantId", "NodeId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subjects_module_nodes_tenant_id_node_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subjects_regions_tenant_id_region_id"); + + b.HasOne("Tiku.Domain.Catalog.School", null) + .WithMany() + .HasForeignKey("TenantId", "SchoolId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subjects_schools_tenant_id_school_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.TaxonomyNode", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_taxonomy_nodes_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.TaxonomyNode", null) + .WithMany() + .HasForeignKey("ParentOwnerTenantId", "ParentId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_taxonomy_nodes_taxonomy_nodes_parent_owner_tenant_id_parent~"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.ActivationCode", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("AgentUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_activation_codes_users_agent_user_id"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_activation_codes_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UsedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_activation_codes_users_used_by"); + + b.HasOne("Tiku.Domain.Commerce.CodeBatch", null) + .WithMany() + .HasForeignKey("TenantId", "BatchId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_activation_codes_code_batches_tenant_id_batch_id"); + + b.HasOne("Tiku.Domain.Commerce.CouponRedemption", null) + .WithMany() + .HasForeignKey("TenantId", "CouponRedemptionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_activation_codes_coupon_redemptions_tenant_id_coupon_redemp~"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "UsedRegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_activation_codes_regions_tenant_id_used_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.CodeBatch", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_code_batches_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.CommerceAdjustmentVoucher", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_adjustment_vouchers_users_created_by"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ReviewedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_adjustment_vouchers_users_reviewed_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commerce_adjustment_vouchers_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Commerce.CommerceReconciliationBatch", null) + .WithMany() + .HasForeignKey("TenantId", "BatchId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_adjustment_vouchers_commerce_reconciliation_batche~"); + + b.HasOne("Tiku.Domain.Commerce.CommerceReconciliationIssue", null) + .WithMany() + .HasForeignKey("TenantId", "IssueId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_adjustment_vouchers_commerce_reconciliation_issues~"); + + b.HasOne("Tiku.Domain.Commerce.CommerceReconciliationItem", null) + .WithMany() + .HasForeignKey("TenantId", "ItemId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_adjustment_vouchers_commerce_reconciliation_items_~"); + + b.HasOne("Tiku.Domain.Commerce.Order", null) + .WithMany() + .HasForeignKey("TenantId", "OrderId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_adjustment_vouchers_orders_tenant_id_order_id"); + + b.HasOne("Tiku.Domain.Commerce.Payment", null) + .WithMany() + .HasForeignKey("TenantId", "PaymentId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_adjustment_vouchers_payments_tenant_id_payment_id"); + + b.HasOne("Tiku.Domain.Commerce.CommerceRefundRequest", null) + .WithMany() + .HasForeignKey("TenantId", "RefundRequestId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_adjustment_vouchers_commerce_refund_requests_tenan~"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.CommerceAdjustmentVoucherEvent", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ActorUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_adjustment_voucher_events_users_actor_user_id"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commerce_adjustment_voucher_events_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Commerce.CommerceAdjustmentVoucher", null) + .WithMany() + .HasForeignKey("TenantId", "VoucherId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commerce_adjustment_voucher_events_commerce_adjustment_vouc~"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.CommerceReconciliationBatch", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_reconciliation_batches_users_created_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commerce_reconciliation_batches_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.CommerceReconciliationIssue", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("AssignedTo") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_reconciliation_issues_users_assigned_to"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_reconciliation_issues_users_created_by"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ResolvedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_reconciliation_issues_users_resolved_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commerce_reconciliation_issues_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Commerce.CommerceReconciliationBatch", null) + .WithMany() + .HasForeignKey("TenantId", "BatchId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_commerce_reconciliation_issues_commerce_reconciliation_batc~"); + + b.HasOne("Tiku.Domain.Commerce.CommerceReconciliationItem", null) + .WithMany() + .HasForeignKey("TenantId", "ItemId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_commerce_reconciliation_issues_commerce_reconciliation_item~"); + + b.HasOne("Tiku.Domain.Commerce.Order", null) + .WithMany() + .HasForeignKey("TenantId", "OrderId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_commerce_reconciliation_issues_orders_tenant_id_order_id"); + + b.HasOne("Tiku.Domain.Commerce.Payment", null) + .WithMany() + .HasForeignKey("TenantId", "PaymentId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_commerce_reconciliation_issues_payments_tenant_id_payment_id"); + + b.HasOne("Tiku.Domain.Commerce.CommerceRefundRequest", null) + .WithMany() + .HasForeignKey("TenantId", "RefundRequestId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_commerce_reconciliation_issues_commerce_refund_requests_ten~"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.CommerceReconciliationIssueEvent", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ActorUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_reconciliation_issue_events_users_actor_user_id"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commerce_reconciliation_issue_events_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Commerce.CommerceReconciliationIssue", null) + .WithMany() + .HasForeignKey("TenantId", "IssueId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commerce_reconciliation_issue_events_commerce_reconciliatio~"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.CommerceReconciliationItem", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commerce_reconciliation_items_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Commerce.CommerceReconciliationBatch", null) + .WithMany() + .HasForeignKey("TenantId", "BatchId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commerce_reconciliation_items_commerce_reconciliation_batch~"); + + b.HasOne("Tiku.Domain.Commerce.Order", null) + .WithMany() + .HasForeignKey("TenantId", "OrderId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_commerce_reconciliation_items_orders_tenant_id_order_id"); + + b.HasOne("Tiku.Domain.Commerce.Payment", null) + .WithMany() + .HasForeignKey("TenantId", "PaymentId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_commerce_reconciliation_items_payments_tenant_id_payment_id"); + + b.HasOne("Tiku.Domain.Commerce.CommerceRefundRequest", null) + .WithMany() + .HasForeignKey("TenantId", "RefundRequestId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_commerce_reconciliation_items_commerce_refund_requests_tena~"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.CommerceRefundEvent", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ActorUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_refund_events_users_actor_user_id"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commerce_refund_events_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Commerce.CommerceRefundRequest", null) + .WithMany() + .HasForeignKey("TenantId", "RefundRequestId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commerce_refund_events_commerce_refund_requests_tenant_id_r~"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.CommerceRefundRequest", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ProcessedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_refund_requests_users_processed_by"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("RequestedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_refund_requests_users_requested_by"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ReviewedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_refund_requests_users_reviewed_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commerce_refund_requests_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Commerce.Order", null) + .WithMany() + .HasForeignKey("TenantId", "OrderId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commerce_refund_requests_orders_tenant_id_order_id"); + + b.HasOne("Tiku.Domain.Commerce.Payment", null) + .WithMany() + .HasForeignKey("TenantId", "PaymentId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_commerce_refund_requests_payments_tenant_id_payment_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.Coupon", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_coupons_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Commerce.SvipPlan", null) + .WithMany() + .HasForeignKey("TenantId", "PlanId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_coupons_svip_plans_tenant_id_plan_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.CouponRedemption", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_coupon_redemptions_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_coupon_redemptions_users_user_id"); + + b.HasOne("Tiku.Domain.Commerce.Coupon", null) + .WithMany() + .HasForeignKey("TenantId", "CouponId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_coupon_redemptions_coupons_tenant_id_coupon_id"); + + b.HasOne("Tiku.Domain.Commerce.Order", null) + .WithMany() + .HasForeignKey("TenantId", "OrderId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_coupon_redemptions_orders_tenant_id_order_id"); + + b.HasOne("Tiku.Domain.Commerce.SvipPlan", null) + .WithMany() + .HasForeignKey("TenantId", "PlanId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_coupon_redemptions_svip_plans_tenant_id_plan_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_coupon_redemptions_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.Entitlement", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("RevokedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_entitlements_users_revoked_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_entitlements_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_entitlements_users_user_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.Order", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_orders_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_orders_users_user_id"); + + b.HasOne("Tiku.Domain.Commerce.SvipPlan", null) + .WithMany() + .HasForeignKey("TenantId", "PlanId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_orders_svip_plans_tenant_id_plan_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_orders_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.OrderItem", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_order_items_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Commerce.Order", null) + .WithMany() + .HasForeignKey("TenantId", "OrderId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_order_items_orders_tenant_id_order_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.Payment", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_payments_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Commerce.Order", null) + .WithMany() + .HasForeignKey("TenantId", "OrderId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_payments_orders_tenant_id_order_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.PaymentEvent", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_payment_events_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Commerce.Payment", null) + .WithMany() + .HasForeignKey("TenantId", "PaymentId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_payment_events_payments_tenant_id_payment_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.PointActivityClaim", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_point_activity_claims_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_point_activity_claims_users_user_id"); + + b.HasOne("Tiku.Domain.Commerce.PointActivityTask", null) + .WithMany() + .HasForeignKey("TenantId", "TaskId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_point_activity_claims_point_activity_tasks_tenant_id_task_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.PointActivityTask", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_point_activity_tasks_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.PointExchangeItem", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_point_exchange_items_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_point_exchange_items_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.PointExchangeOrder", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_point_exchange_orders_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_point_exchange_orders_users_user_id"); + + b.HasOne("Tiku.Domain.Commerce.PointExchangeItem", null) + .WithMany() + .HasForeignKey("TenantId", "ItemId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_point_exchange_orders_point_exchange_items_tenant_id_item_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.Product", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_products_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_products_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.SvipPlan", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_svip_plans_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_svip_plans_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.TenantSubscription", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_subscriptions_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.TenantUsageRecord", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_usage_records_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.AiRecommendationReport", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ai_recommendation_reports_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ai_recommendation_reports_users_user_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_ai_recommendation_reports_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.AppAsset", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_app_assets_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentAsset", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_content_assets_users_created_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_content_assets_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_content_assets_users_updated_by"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("VerifiedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_content_assets_users_verified_by"); + + b.HasOne("Tiku.Domain.Catalog.Category", null) + .WithMany() + .HasForeignKey("TenantId", "CategoryId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_content_assets_categories_tenant_id_category_id"); + + b.HasOne("Tiku.Domain.Content.ContentNode", null) + .WithMany() + .HasForeignKey("TenantId", "ContentNodeId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_content_assets_content_nodes_tenant_id_content_node_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_content_assets_regions_tenant_id_region_id"); + + b.HasOne("Tiku.Domain.Catalog.Subject", null) + .WithMany() + .HasForeignKey("TenantId", "SubjectId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_content_assets_subjects_tenant_id_subject_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentAssetAccessEvent", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_content_asset_access_events_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_content_asset_access_events_users_user_id"); + + b.HasOne("Tiku.Domain.Content.ContentAsset", null) + .WithMany() + .HasForeignKey("TenantId", "AssetId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_content_asset_access_events_content_assets_tenant_id_asset_~"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentAssetSecurityScanEvent", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_content_asset_security_scan_events_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Content.ContentAsset", null) + .WithMany() + .HasForeignKey("TenantId", "AssetId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_content_asset_security_scan_events_content_assets_tenant_id~"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentEntry", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_content_entries_users_created_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_content_entries_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_content_entries_users_updated_by"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_content_entries_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentImportIssue", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_content_import_issues_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Content.ContentImportItem", null) + .WithMany() + .HasForeignKey("TenantId", "ItemId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_content_import_issues_content_import_items_tenant_id_item_id"); + + b.HasOne("Tiku.Domain.Content.ContentImportJob", null) + .WithMany() + .HasForeignKey("TenantId", "JobId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_content_import_issues_content_import_jobs_tenant_id_job_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentImportItem", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_content_import_items_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Content.ContentImportJob", null) + .WithMany() + .HasForeignKey("TenantId", "JobId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_content_import_items_content_import_jobs_tenant_id_job_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentImportJob", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_content_import_jobs_users_created_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_content_import_jobs_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Category", null) + .WithMany() + .HasForeignKey("TenantId", "TargetCategoryId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_content_import_jobs_categories_tenant_id_target_category_id"); + + b.HasOne("Tiku.Domain.Content.ContentNode", null) + .WithMany() + .HasForeignKey("TenantId", "TargetContentNodeId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_content_import_jobs_content_nodes_tenant_id_target_content_~"); + + b.HasOne("Tiku.Domain.QuestionBanks.QuestionBank", null) + .WithMany() + .HasForeignKey("TenantId", "TargetQuestionBankId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_content_import_jobs_question_banks_tenant_id_target_questio~"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "TargetRegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_content_import_jobs_regions_tenant_id_target_region_id"); + + b.HasOne("Tiku.Domain.Catalog.Subject", null) + .WithMany() + .HasForeignKey("TenantId", "TargetSubjectId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_content_import_jobs_subjects_tenant_id_target_subject_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentNode", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_content_nodes_users_created_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_content_nodes_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_content_nodes_users_updated_by"); + + b.HasOne("Tiku.Domain.Content.ContentEntry", null) + .WithMany() + .HasForeignKey("TenantId", "EntryId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_content_nodes_content_entries_tenant_id_entry_id"); + + b.HasOne("Tiku.Domain.Content.ContentNode", null) + .WithMany() + .HasForeignKey("TenantId", "ParentId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_content_nodes_content_nodes_tenant_id_parent_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_content_nodes_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.HandbookChapter", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_handbook_chapters_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Content.ContentNode", null) + .WithMany() + .HasForeignKey("TenantId", "ContentNodeId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_handbook_chapters_content_nodes_tenant_id_content_node_id"); + + b.HasOne("Tiku.Domain.Content.ContentEntry", null) + .WithMany() + .HasForeignKey("TenantId", "EntryId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_handbook_chapters_content_entries_tenant_id_entry_id"); + + b.HasOne("Tiku.Domain.Content.HandbookSubject", null) + .WithMany() + .HasForeignKey("TenantId", "SubjectId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_handbook_chapters_handbook_subjects_tenant_id_subject_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.HandbookEntry", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_handbook_entries_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Content.HandbookChapter", null) + .WithMany() + .HasForeignKey("TenantId", "ChapterId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_handbook_entries_handbook_chapters_tenant_id_chapter_id"); + + b.HasOne("Tiku.Domain.Content.ContentNode", null) + .WithMany() + .HasForeignKey("TenantId", "ContentNodeId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_handbook_entries_content_nodes_tenant_id_content_node_id"); + + b.HasOne("Tiku.Domain.Content.ContentEntry", null) + .WithMany() + .HasForeignKey("TenantId", "EntryId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_handbook_entries_content_entries_tenant_id_entry_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.HandbookSubject", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_handbook_subjects_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Content.ContentNode", null) + .WithMany() + .HasForeignKey("TenantId", "ContentNodeId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_handbook_subjects_content_nodes_tenant_id_content_node_id"); + + b.HasOne("Tiku.Domain.Content.ContentEntry", null) + .WithMany() + .HasForeignKey("TenantId", "EntryId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_handbook_subjects_content_entries_tenant_id_entry_id"); + + b.HasOne("Tiku.Domain.Catalog.Major", null) + .WithMany() + .HasForeignKey("TenantId", "MajorId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_handbook_subjects_majors_tenant_id_major_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_handbook_subjects_regions_tenant_id_region_id"); + + b.HasOne("Tiku.Domain.Catalog.School", null) + .WithMany() + .HasForeignKey("TenantId", "SchoolId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_handbook_subjects_schools_tenant_id_school_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ImageAsset", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_images_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.PracticeBlueprint", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_practice_blueprints_users_created_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_practice_blueprints_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_practice_blueprints_users_updated_by"); + + b.HasOne("Tiku.Domain.Content.QuestionCollection", null) + .WithMany() + .HasForeignKey("TenantId", "CollectionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_practice_blueprints_question_collections_tenant_id_collecti~"); + + b.HasOne("Tiku.Domain.Content.ContentEntry", null) + .WithMany() + .HasForeignKey("TenantId", "EntryId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_practice_blueprints_content_entries_tenant_id_entry_id"); + + b.HasOne("Tiku.Domain.Content.ContentNode", null) + .WithMany() + .HasForeignKey("TenantId", "NodeId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_practice_blueprints_content_nodes_tenant_id_node_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_practice_blueprints_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.QuestionCollection", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_question_collections_users_created_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_question_collections_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_question_collections_users_updated_by"); + + b.HasOne("Tiku.Domain.Catalog.Category", null) + .WithMany() + .HasForeignKey("TenantId", "CategoryId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_question_collections_categories_tenant_id_category_id"); + + b.HasOne("Tiku.Domain.Content.ContentEntry", null) + .WithMany() + .HasForeignKey("TenantId", "EntryId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_question_collections_content_entries_tenant_id_entry_id"); + + b.HasOne("Tiku.Domain.Content.ContentNode", null) + .WithMany() + .HasForeignKey("TenantId", "NodeId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_question_collections_content_nodes_tenant_id_node_id"); + + b.HasOne("Tiku.Domain.QuestionBanks.QuestionBank", null) + .WithMany() + .HasForeignKey("TenantId", "QuestionBankId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_question_collections_question_banks_tenant_id_question_bank~"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_question_collections_regions_tenant_id_region_id"); + + b.HasOne("Tiku.Domain.Catalog.Subject", null) + .WithMany() + .HasForeignKey("TenantId", "SubjectId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_question_collections_subjects_tenant_id_subject_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.QuestionCollectionItem", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_question_collection_items_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.QuestionBanks.Question", null) + .WithMany() + .HasForeignKey("QuestionOwnerTenantId", "QuestionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_question_collection_items_questions_question_owner_tenant_i~"); + + b.HasOne("Tiku.Domain.Content.QuestionCollection", null) + .WithMany() + .HasForeignKey("TenantId", "CollectionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_question_collection_items_question_collections_tenant_id_co~"); + + b.HasOne("Tiku.Domain.Content.TenantQuestionReference", null) + .WithMany() + .HasForeignKey("TenantId", "QuestionReferenceId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_question_collection_items_tenant_question_references_tenant~"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.QuestionTypeGroup", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_question_type_groups_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Subject", null) + .WithMany() + .HasForeignKey("TenantId", "SubjectId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_question_type_groups_subjects_tenant_id_subject_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.QuestionVideo", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_question_videos_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.QuestionBanks.Question", null) + .WithMany() + .HasForeignKey("TenantId", "QuestionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_question_videos_questions_tenant_id_question_id"); + + b.HasOne("Tiku.Domain.Content.VideoExplanation", null) + .WithMany() + .HasForeignKey("TenantId", "VideoId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_question_videos_video_explanations_tenant_id_video_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.SubjectShare", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_subject_shares_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Subject", null) + .WithMany() + .HasForeignKey("TenantId", "SourceSubjectId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_subject_shares_subjects_tenant_id_source_subject_id"); + + b.HasOne("Tiku.Domain.Catalog.Subject", null) + .WithMany() + .HasForeignKey("TenantId", "TargetSubjectId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_subject_shares_subjects_tenant_id_target_subject_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.TenantQuestionBankPreference", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenant_question_bank_preferences_users_created_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_question_bank_preferences_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenant_question_bank_preferences_users_updated_by"); + + b.HasOne("Tiku.Domain.QuestionBanks.QuestionBank", null) + .WithMany() + .HasForeignKey("QuestionBankOwnerTenantId", "QuestionBankId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_tenant_question_bank_preferences_question_banks_question_ba~"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.TenantQuestionReference", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenant_question_references_users_created_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_question_references_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.QuestionBanks.Question", null) + .WithMany() + .HasForeignKey("QuestionOwnerTenantId", "QuestionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_tenant_question_references_questions_question_owner_tenant_~"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.UserWordFavorite", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_word_favorites_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_word_favorites_users_user_id"); + + b.HasOne("Tiku.Domain.Content.VocabularyWord", null) + .WithMany() + .HasForeignKey("TenantId", "WordId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_word_favorites_vocabulary_words_tenant_id_word_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.UserWordProgress", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_word_progress_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_word_progress_users_user_id"); + + b.HasOne("Tiku.Domain.Content.VocabularyWord", null) + .WithMany() + .HasForeignKey("TenantId", "WordId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_word_progress_vocabulary_words_tenant_id_word_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.VideoExplanation", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_video_explanations_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Subject", null) + .WithMany() + .HasForeignKey("TenantId", "SubjectId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_video_explanations_subjects_tenant_id_subject_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.VideoPlaybackProgress", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_video_playback_progress_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_video_playback_progress_users_user_id"); + + b.HasOne("Tiku.Domain.QuestionBanks.Question", null) + .WithMany() + .HasForeignKey("TenantId", "QuestionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_video_playback_progress_questions_tenant_id_question_id"); + + b.HasOne("Tiku.Domain.Content.VideoExplanation", null) + .WithMany() + .HasForeignKey("TenantId", "VideoId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_video_playback_progress_video_explanations_tenant_id_video_~"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.VocabularyUnit", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_vocabulary_units_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Content.ContentNode", null) + .WithMany() + .HasForeignKey("TenantId", "ContentNodeId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_vocabulary_units_content_nodes_tenant_id_content_node_id"); + + b.HasOne("Tiku.Domain.Content.ContentEntry", null) + .WithMany() + .HasForeignKey("TenantId", "EntryId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_vocabulary_units_content_entries_tenant_id_entry_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_vocabulary_units_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.VocabularyWord", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_vocabulary_words_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Content.ContentNode", null) + .WithMany() + .HasForeignKey("TenantId", "ContentNodeId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_vocabulary_words_content_nodes_tenant_id_content_node_id"); + + b.HasOne("Tiku.Domain.Content.ContentEntry", null) + .WithMany() + .HasForeignKey("TenantId", "EntryId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_vocabulary_words_content_entries_tenant_id_entry_id"); + + b.HasOne("Tiku.Domain.Content.VocabularyUnit", null) + .WithMany() + .HasForeignKey("TenantId", "UnitId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_vocabulary_words_vocabulary_units_tenant_id_unit_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.CommissionSettlement", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("GeneratedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commission_settlements_users_generated_by"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("PaidBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commission_settlements_users_paid_by"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ReferrerUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commission_settlements_users_referrer_user_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ReviewedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commission_settlements_users_reviewed_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commission_settlements_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.CommissionSettlementExportEvent", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ExportedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commission_settlement_export_events_users_exported_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commission_settlement_export_events_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Growth.CommissionSettlement", null) + .WithMany() + .HasForeignKey("TenantId", "SettlementId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commission_settlement_export_events_commission_settlements_~"); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.CommissionSettlementItem", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ReferrerUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commission_settlement_items_users_referrer_user_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("StudentUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commission_settlement_items_users_student_user_id"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commission_settlement_items_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Growth.CommissionSettlement", null) + .WithMany() + .HasForeignKey("TenantId", "SettlementId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commission_settlement_items_commission_settlements_tenant_i~"); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.CommissionSettlementProof", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ReviewedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commission_settlement_proofs_users_reviewed_by"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("SubmittedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commission_settlement_proofs_users_submitted_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commission_settlement_proofs_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Content.ContentAsset", null) + .WithMany() + .HasForeignKey("TenantId", "AssetId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commission_settlement_proofs_content_assets_tenant_id_asset~"); + + b.HasOne("Tiku.Domain.Growth.CommissionSettlement", null) + .WithMany() + .HasForeignKey("TenantId", "SettlementId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commission_settlement_proofs_commission_settlements_tenant_~"); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.CrmConfig", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_crm_config_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.CrmWebhookLog", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_crm_webhook_log_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.CrmWebhookQueueItem", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_crm_webhook_queue_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.ReferralCode", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_referral_codes_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_referral_codes_users_user_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.ReferralLead", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("AssignedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_referral_leads_users_assigned_by"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("AssignedToUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_referral_leads_users_assigned_to_user_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ReferrerUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_referral_leads_users_referrer_user_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("StudentUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_referral_leads_users_student_user_id"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_referral_leads_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Growth.ReferralTrack", null) + .WithMany() + .HasForeignKey("TenantId", "FirstTrackId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_referral_leads_referral_tracks_tenant_id_first_track_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.ReferralQrcode", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_referral_qrcodes_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_referral_qrcodes_users_user_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.ReferralTeamEdge", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("LeaderUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_referral_team_edges_users_leader_user_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("MemberUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_referral_team_edges_users_member_user_id"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_referral_team_edges_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.ReferralTrack", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ReferrerUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_referral_tracks_users_referrer_user_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("TargetUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_referral_tracks_users_target_user_id"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_referral_tracks_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Growth.ReferralLead", null) + .WithMany() + .HasForeignKey("TenantId", "LeadId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_referral_tracks_referral_leads_tenant_id_lead_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.TenantCommissionSetting", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_commission_settings_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenant_commission_settings_users_updated_by"); + }); + + modelBuilder.Entity("Tiku.Domain.Identity.StudentProfile", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_student_profiles_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_student_profiles_users_user_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_student_profiles_regions_tenant_id_region_id"); + + b.HasOne("Tiku.Domain.Catalog.Major", null) + .WithMany() + .HasForeignKey("TenantId", "SelectedMajorId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_student_profiles_majors_tenant_id_selected_major_id"); + + b.HasOne("Tiku.Domain.Catalog.School", null) + .WithMany() + .HasForeignKey("TenantId", "SelectedSchoolId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_student_profiles_schools_tenant_id_selected_school_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Identity.UserIdentity", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_identities_users_user_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Import.PocketBaseImportIssue", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_pb_import_issues_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Import.PocketBaseImportRun", null) + .WithMany() + .HasForeignKey("TenantId", "RunId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_pb_import_issues_pb_import_runs_tenant_id_run_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Import.PocketBaseImportRun", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_pb_import_runs_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Import.PocketBaseRawRecord", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_pb_raw_records_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Import.PocketBaseImportRun", null) + .WithMany() + .HasForeignKey("TenantId", "RunId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_pb_raw_records_pb_import_runs_tenant_id_run_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.AnswerRecord", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_answer_records_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_answer_records_users_user_id"); + + b.HasOne("Tiku.Domain.Learning.PracticeSessionQuestion", null) + .WithMany() + .HasForeignKey("TenantId", "PracticeSessionId", "SessionQuestionId") + .HasPrincipalKey("TenantId", "PracticeSessionId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_answer_records_practice_session_questions_tenant_id_practic~"); + + b.HasOne("Tiku.Domain.Learning.PracticeSession", null) + .WithMany() + .HasForeignKey("TenantId", "UserId", "PracticeSessionId") + .HasPrincipalKey("TenantId", "UserId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_answer_records_practice_sessions_tenant_id_user_id_practice~"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.DashboardDailyStat", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_dashboard_daily_stats_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_dashboard_daily_stats_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.ExamDate", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_exam_dates_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_exam_dates_regions_tenant_id_region_id"); + + b.HasOne("Tiku.Domain.Catalog.School", null) + .WithMany() + .HasForeignKey("TenantId", "SchoolId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_exam_dates_schools_tenant_id_school_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.FavoriteQuestion", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_favorite_questions_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_favorite_questions_users_user_id"); + + b.HasOne("Tiku.Domain.QuestionBanks.Question", null) + .WithMany() + .HasForeignKey("QuestionOwnerTenantId", "QuestionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_favorite_questions_questions_question_owner_tenant_id_quest~"); + + b.HasOne("Tiku.Domain.Content.TenantQuestionReference", null) + .WithMany() + .HasForeignKey("TenantId", "QuestionReferenceId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_favorite_questions_tenant_question_references_tenant_id_que~"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.PracticeAccessEvent", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_practice_access_events_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_practice_access_events_users_user_id"); + + b.HasOne("Tiku.Domain.Learning.PracticeSession", null) + .WithMany() + .HasForeignKey("TenantId", "PracticeSessionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_practice_access_events_practice_sessions_tenant_id_practice~"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.PracticeDailyUsage", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_practice_daily_usage_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_practice_daily_usage_users_user_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.PracticeSession", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_practice_sessions_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_practice_sessions_users_user_id"); + + b.HasOne("Tiku.Domain.Content.PracticeBlueprint", null) + .WithMany() + .HasForeignKey("TenantId", "BlueprintId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_practice_sessions_practice_blueprints_tenant_id_blueprint_id"); + + b.HasOne("Tiku.Domain.Content.QuestionCollection", null) + .WithMany() + .HasForeignKey("TenantId", "CollectionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_practice_sessions_question_collections_tenant_id_collection~"); + + b.HasOne("Tiku.Domain.Content.ContentNode", null) + .WithMany() + .HasForeignKey("TenantId", "ContentNodeId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_practice_sessions_content_nodes_tenant_id_content_node_id"); + + b.HasOne("Tiku.Domain.Content.ContentEntry", null) + .WithMany() + .HasForeignKey("TenantId", "EntryId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_practice_sessions_content_entries_tenant_id_entry_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.PracticeSessionQuestion", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_practice_session_questions_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Learning.PracticeSession", null) + .WithMany() + .HasForeignKey("TenantId", "PracticeSessionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_practice_session_questions_practice_sessions_tenant_id_prac~"); + + b.HasOne("Tiku.Domain.QuestionBanks.QuestionVersion", null) + .WithMany() + .HasForeignKey("QuestionOwnerTenantId", "QuestionId", "QuestionVersionId") + .HasPrincipalKey("TenantId", "QuestionId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_practice_session_questions_question_versions_question_owner~"); + + b.HasOne("Tiku.Domain.Content.TenantQuestionReference", null) + .WithMany() + .HasForeignKey("TenantId", "QuestionOwnerTenantId", "QuestionId") + .HasPrincipalKey("TenantId", "QuestionOwnerTenantId", "QuestionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_practice_session_questions_tenant_question_references_tenan~"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.PracticeSessionReport", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_practice_session_reports_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_practice_session_reports_users_user_id"); + + b.HasOne("Tiku.Domain.Content.PracticeBlueprint", null) + .WithMany() + .HasForeignKey("TenantId", "BlueprintId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_practice_session_reports_practice_blueprints_tenant_id_blue~"); + + b.HasOne("Tiku.Domain.Content.QuestionCollection", null) + .WithMany() + .HasForeignKey("TenantId", "CollectionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_practice_session_reports_question_collections_tenant_id_col~"); + + b.HasOne("Tiku.Domain.Learning.PracticeSession", null) + .WithMany() + .HasForeignKey("TenantId", "PracticeSessionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_practice_session_reports_practice_sessions_tenant_id_practi~"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.PracticeSessionReportSection", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_practice_session_report_sections_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Learning.PracticeSession", null) + .WithMany() + .HasForeignKey("TenantId", "PracticeSessionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_practice_session_report_sections_practice_sessions_tenant_i~"); + + b.HasOne("Tiku.Domain.Learning.PracticeSessionReport", null) + .WithMany() + .HasForeignKey("TenantId", "ReportId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_practice_session_report_sections_practice_session_reports_t~"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.RecentPractice", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_recent_practices_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_recent_practices_users_user_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.Report", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("HandledBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_reports_users_handled_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_reports_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_reports_users_user_id"); + + b.HasOne("Tiku.Domain.QuestionBanks.Question", null) + .WithMany() + .HasForeignKey("TenantId", "QuestionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_reports_questions_tenant_id_question_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.ReportStatusEvent", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ActorUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_report_status_events_users_actor_user_id"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_report_status_events_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Learning.Report", null) + .WithMany() + .HasForeignKey("TenantId", "ReportId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_report_status_events_reports_tenant_id_report_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.RevenueDailyStat", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_revenue_daily_stats_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_revenue_daily_stats_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.UserScoreEvent", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_score_events_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_score_events_users_user_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.WrongQuestion", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_wrong_questions_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_wrong_questions_users_user_id"); + + b.HasOne("Tiku.Domain.QuestionBanks.Question", null) + .WithMany() + .HasForeignKey("QuestionOwnerTenantId", "QuestionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_wrong_questions_questions_question_owner_tenant_id_question~"); + + b.HasOne("Tiku.Domain.Content.TenantQuestionReference", null) + .WithMany() + .HasForeignKey("TenantId", "QuestionReferenceId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_wrong_questions_tenant_question_references_tenant_id_questi~"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.Announcement", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_announcements_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.AuditLog", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ActorUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_audit_logs_users_actor_user_id"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_audit_logs_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.BackendMenu", b => + { + b.HasOne("Tiku.Domain.Operations.BackendPermission", null) + .WithMany() + .HasForeignKey("PermissionCode") + .HasPrincipalKey("Code") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_backend_menus_backend_permissions_permission_code"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.BackgroundJob", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_background_jobs_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Content.ContentAsset", null) + .WithMany() + .HasForeignKey("TenantId", "OutputAssetId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_background_jobs_content_assets_tenant_id_output_asset_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.Badge", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_badges_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.Banner", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_banners_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_banners_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.Faq", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_faqs_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_faqs_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRoleMenu", b => + { + b.HasOne("Tiku.Domain.Operations.BackendMenu", null) + .WithMany() + .HasForeignKey("MenuCode") + .HasPrincipalKey("Code") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_platform_backend_role_menus_backend_menus_menu_code"); + + b.HasOne("Tiku.Domain.Operations.PlatformBackendRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_backend_role_menus_platform_backend_roles_role_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendRolePermission", b => + { + b.HasOne("Tiku.Domain.Operations.BackendPermission", null) + .WithMany() + .HasForeignKey("PermissionCode") + .HasPrincipalKey("Code") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_platform_backend_role_permissions_backend_permissions_permi~"); + + b.HasOne("Tiku.Domain.Operations.PlatformBackendRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_backend_role_permissions_platform_backend_roles_ro~"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.PlatformBackendUserRole", b => + { + b.HasOne("Tiku.Domain.Operations.PlatformBackendRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_backend_user_roles_platform_backend_roles_role_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_backend_user_roles_users_user_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRole", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_backend_roles_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRoleMenu", b => + { + b.HasOne("Tiku.Domain.Operations.BackendMenu", null) + .WithMany() + .HasForeignKey("MenuCode") + .HasPrincipalKey("Code") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_tenant_backend_role_menus_backend_menus_menu_code"); + + b.HasOne("Tiku.Domain.Operations.TenantBackendRole", null) + .WithMany() + .HasForeignKey("TenantId", "RoleId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_backend_role_menus_tenant_backend_roles_tenant_id_ro~"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendRolePermission", b => + { + b.HasOne("Tiku.Domain.Operations.BackendPermission", null) + .WithMany() + .HasForeignKey("PermissionCode") + .HasPrincipalKey("Code") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_tenant_backend_role_permissions_backend_permissions_permiss~"); + + b.HasOne("Tiku.Domain.Operations.TenantBackendRole", null) + .WithMany() + .HasForeignKey("TenantId", "RoleId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_backend_role_permissions_tenant_backend_roles_tenant~"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.TenantBackendUserRole", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_backend_user_roles_users_user_id"); + + b.HasOne("Tiku.Domain.Operations.TenantBackendRole", null) + .WithMany() + .HasForeignKey("TenantId", "RoleId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_backend_user_roles_tenant_backend_roles_tenant_id_ro~"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.TenantContentNotification", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenant_content_notifications_users_created_by"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ReadBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenant_content_notifications_users_read_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_content_notifications_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.QuestionBanks.QuestionBank", null) + .WithMany() + .HasForeignKey("TenantId", "SourceQuestionBankId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_tenant_content_notifications_question_banks_tenant_id_sourc~"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.TenantThemeConfig", b => + { + b.HasOne("Tiku.Domain.Operations.TenantThemeTemplate", null) + .WithMany() + .HasForeignKey("ActiveTemplateCode") + .HasPrincipalKey("Code") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenant_theme_configs_tenant_theme_templates_active_template~"); + + b.HasOne("Tiku.Domain.Operations.TenantThemeTemplate", null) + .WithMany() + .HasForeignKey("DraftTemplateCode") + .HasPrincipalKey("Code") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenant_theme_configs_tenant_theme_templates_draft_template_~"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("DraftUpdatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenant_theme_configs_users_draft_updated_by"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("PublishedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenant_theme_configs_users_published_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_theme_configs_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.UserBadge", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("GrantedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_user_badges_users_granted_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_badges_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_user_badges_users_user_id"); + + b.HasOne("Tiku.Domain.Operations.Badge", null) + .WithMany() + .HasForeignKey("TenantId", "BadgeId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_user_badges_badges_tenant_id_badge_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.UserNotification", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_user_notifications_users_created_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_notifications_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_notifications_users_user_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlanModuleEntitlement", b => + { + b.HasOne("Tiku.Domain.Platform.ProductModule", null) + .WithMany() + .HasForeignKey("ModuleCode") + .HasPrincipalKey("Code") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_plan_module_entitlements_product_modules_module_code"); + + b.HasOne("Tiku.Domain.Platform.PlatformSaasPlan", null) + .WithMany() + .HasForeignKey("PlanCode") + .HasPrincipalKey("Code") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_plan_module_entitlements_platform_saas_plans_plan_code"); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformAuditAlert", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("AcknowledgedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_platform_audit_alerts_users_acknowledged_by"); + + b.HasOne("Tiku.Domain.Operations.AuditLog", null) + .WithMany() + .HasForeignKey("AuditLogId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_audit_alerts_audit_logs_audit_log_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ResolvedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_platform_audit_alerts_users_resolved_by"); + + b.HasOne("Tiku.Domain.Platform.PlatformAuditAlertRule", null) + .WithMany() + .HasForeignKey("RuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_audit_alerts_platform_audit_alert_rules_rule_id"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_platform_audit_alerts_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformAuditAlertRule", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_platform_audit_alert_rules_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformDunningNotificationEvent", b => + { + b.HasOne("Tiku.Domain.Platform.PlatformDunningNotificationChannel", null) + .WithMany() + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_dunning_notification_events_platform_dunning_notif~"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_dunning_notification_events_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Platform.TenantInvoice", null) + .WithMany() + .HasForeignKey("TenantId", "InvoiceId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_dunning_notification_events_tenant_invoices_tenant~"); + + b.HasOne("Tiku.Domain.Platform.TenantInvoiceReminder", null) + .WithMany() + .HasForeignKey("TenantId", "ReminderId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_dunning_notification_events_tenant_invoice_reminde~"); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.TenantBillingProfile", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithOne() + .HasForeignKey("Tiku.Domain.Platform.TenantBillingProfile", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_billing_profiles_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.TenantInvoice", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenant_invoices_users_created_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_invoices_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.TenantInvoiceItem", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_invoice_items_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Platform.TenantInvoice", null) + .WithMany() + .HasForeignKey("TenantId", "InvoiceId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_invoice_items_tenant_invoices_tenant_id_invoice_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.TenantInvoicePayment", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ReceivedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenant_invoice_payments_users_received_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_invoice_payments_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Platform.TenantInvoice", null) + .WithMany() + .HasForeignKey("TenantId", "InvoiceId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_invoice_payments_tenant_invoices_tenant_id_invoice_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.TenantInvoiceReminder", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenant_invoice_reminders_users_created_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_invoice_reminders_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Platform.TenantInvoice", null) + .WithMany() + .HasForeignKey("TenantId", "InvoiceId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_invoice_reminders_tenant_invoices_tenant_id_invoice_~"); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.TenantModuleOverride", b => + { + b.HasOne("Tiku.Domain.Platform.ProductModule", null) + .WithMany() + .HasForeignKey("ModuleCode") + .HasPrincipalKey("Code") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_tenant_module_overrides_product_modules_module_code"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_module_overrides_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.QuestionBanks.Question", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_questions_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Category", null) + .WithMany() + .HasForeignKey("TenantId", "CategoryId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_questions_categories_tenant_id_category_id"); + + b.HasOne("Tiku.Domain.Content.ContentNode", null) + .WithMany() + .HasForeignKey("TenantId", "ContentNodeId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_questions_content_nodes_tenant_id_content_node_id"); + + b.HasOne("Tiku.Domain.Content.ContentEntry", null) + .WithMany() + .HasForeignKey("TenantId", "EntryId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_questions_content_entries_tenant_id_entry_id"); + + b.HasOne("Tiku.Domain.Catalog.ModuleNode", null) + .WithMany() + .HasForeignKey("TenantId", "NodeId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_questions_module_nodes_tenant_id_node_id"); + + b.HasOne("Tiku.Domain.Content.QuestionCollection", null) + .WithMany() + .HasForeignKey("TenantId", "PrimaryCollectionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_questions_question_collections_tenant_id_primary_collection~"); + + b.HasOne("Tiku.Domain.QuestionBanks.QuestionBank", null) + .WithMany() + .HasForeignKey("TenantId", "QuestionBankId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_questions_question_banks_tenant_id_question_bank_id"); + + b.HasOne("Tiku.Domain.Catalog.Subject", null) + .WithMany() + .HasForeignKey("TenantId", "SubjectId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_questions_subjects_tenant_id_subject_id"); + + b.HasOne("Tiku.Domain.QuestionBanks.QuestionVersion", null) + .WithMany() + .HasForeignKey("TenantId", "Id", "CurrentVersionId") + .HasPrincipalKey("TenantId", "QuestionId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_questions_question_versions_tenant_id_id_current_version_id"); + }); + + modelBuilder.Entity("Tiku.Domain.QuestionBanks.QuestionBank", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_question_banks_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_question_banks_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.QuestionBanks.QuestionVersion", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_question_versions_users_created_by"); + + b.HasOne("Tiku.Domain.QuestionBanks.Question", null) + .WithMany() + .HasForeignKey("TenantId", "QuestionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_question_versions_questions_tenant_id_question_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.AuthChallenge", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_auth_challenges_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_auth_challenges_users_user_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.AuthLoginEvent", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_auth_login_events_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_auth_login_events_users_user_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.AuthSession", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_auth_sessions_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_auth_sessions_users_user_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.SmsSendRateLimit", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_sms_send_rate_limits_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.SmsVerificationCode", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_sms_verification_codes_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.Tenant", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenants_users_owner_user_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantAuthPolicy", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithOne() + .HasForeignKey("Tiku.Domain.Tenancy.TenantAuthPolicy", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_auth_policies_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantBranding", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithOne() + .HasForeignKey("Tiku.Domain.Tenancy.TenantBranding", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_branding_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantClass", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenant_classes_users_created_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_classes_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenant_classes_users_updated_by"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_tenant_classes_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantClassMember", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_class_members_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_class_members_users_user_id"); + + b.HasOne("Tiku.Domain.Tenancy.TenantClass", null) + .WithMany() + .HasForeignKey("TenantId", "ClassId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_class_members_tenant_classes_tenant_id_class_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantDomain", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_domains_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantExternalProvider", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_external_providers_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantFrontendConfig", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_frontend_configs_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantMembership", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_memberships_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_memberships_users_user_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantSecret", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_secrets_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantSettings", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithOne() + .HasForeignKey("Tiku.Domain.Tenancy.TenantSettings", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_settings_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantStudentFollowup", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("AssignedToUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenant_student_followups_users_assigned_to_user_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CompletedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenant_student_followups_users_completed_by"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("StudentUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_student_followups_users_student_user_id"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_student_followups_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Tenancy.TenantClass", null) + .WithMany() + .HasForeignKey("TenantId", "ClassId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenant_student_followups_tenant_classes_tenant_id_class_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantStudentNote", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenant_student_notes_users_created_by"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("StudentUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_student_notes_users_student_user_id"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_student_notes_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenant_student_notes_users_updated_by"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Tiku.Infrastructure/Persistence/Migrations/20260729012205_AddDistributedSecurityFoundation.cs b/Tiku.Infrastructure/Persistence/Migrations/20260729012205_AddDistributedSecurityFoundation.cs new file mode 100644 index 0000000..92e999e --- /dev/null +++ b/Tiku.Infrastructure/Persistence/Migrations/20260729012205_AddDistributedSecurityFoundation.cs @@ -0,0 +1,297 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Tiku.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddDistributedSecurityFoundation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddUniqueConstraint( + name: "ak_platform_saas_plans_code", + table: "platform_saas_plans", + column: "code"); + + migrationBuilder.CreateTable( + name: "inbox_state", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + message_id = table.Column(type: "uuid", nullable: false), + consumer_id = table.Column(type: "uuid", nullable: false), + lock_id = table.Column(type: "uuid", nullable: false), + row_version = table.Column(type: "bytea", rowVersion: true, nullable: true), + received = table.Column(type: "timestamp with time zone", nullable: false), + receive_count = table.Column(type: "integer", nullable: false), + expiration_time = table.Column(type: "timestamp with time zone", nullable: true), + consumed = table.Column(type: "timestamp with time zone", nullable: true), + delivered = table.Column(type: "timestamp with time zone", nullable: true), + last_sequence_number = table.Column(type: "bigint", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_inbox_state", x => x.id); + table.UniqueConstraint("ak_inbox_state_message_id_consumer_id", x => new { x.message_id, x.consumer_id }); + }); + + migrationBuilder.CreateTable( + name: "outbox_state", + columns: table => new + { + outbox_id = table.Column(type: "uuid", nullable: false), + lock_id = table.Column(type: "uuid", nullable: false), + row_version = table.Column(type: "bytea", rowVersion: true, nullable: true), + created = table.Column(type: "timestamp with time zone", nullable: false), + delivered = table.Column(type: "timestamp with time zone", nullable: true), + last_sequence_number = table.Column(type: "bigint", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_outbox_state", x => x.outbox_id); + }); + + migrationBuilder.CreateTable( + name: "product_modules", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + code = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + sort_order = table.Column(type: "integer", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + updated_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("pk_product_modules", x => x.id); + table.UniqueConstraint("ak_product_modules_code", x => x.code); + }); + + migrationBuilder.CreateTable( + name: "tenant_auth_policies", + columns: table => new + { + tenant_id = table.Column(type: "uuid", nullable: false), + allow_external_student_self_registration = table.Column(type: "boolean", nullable: false, defaultValue: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + updated_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("pk_tenant_auth_policies", x => x.tenant_id); + table.ForeignKey( + name: "fk_tenant_auth_policies_tenants_tenant_id", + column: x => x.tenant_id, + principalTable: "tenants", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + // Existing tenants retain the historical external-login behavior. New tenants + // receive an explicit fail-closed policy when created by PlatformAdminService. + migrationBuilder.Sql(""" + INSERT INTO tenant_auth_policies + (tenant_id, allow_external_student_self_registration, created_at, updated_at) + SELECT id, TRUE, now(), now() + FROM tenants + ON CONFLICT (tenant_id) DO NOTHING; + """); + + migrationBuilder.CreateTable( + name: "outbox_message", + columns: table => new + { + sequence_number = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + enqueue_time = table.Column(type: "timestamp with time zone", nullable: true), + sent_time = table.Column(type: "timestamp with time zone", nullable: false), + headers = table.Column(type: "text", nullable: true), + properties = table.Column(type: "text", nullable: true), + inbox_message_id = table.Column(type: "uuid", nullable: true), + inbox_consumer_id = table.Column(type: "uuid", nullable: true), + outbox_id = table.Column(type: "uuid", nullable: true), + message_id = table.Column(type: "uuid", nullable: false), + content_type = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + message_type = table.Column(type: "text", nullable: false), + body = table.Column(type: "text", nullable: false), + conversation_id = table.Column(type: "uuid", nullable: true), + correlation_id = table.Column(type: "uuid", nullable: true), + initiator_id = table.Column(type: "uuid", nullable: true), + request_id = table.Column(type: "uuid", nullable: true), + source_address = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + destination_address = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + response_address = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + fault_address = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + expiration_time = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_outbox_message", x => x.sequence_number); + table.ForeignKey( + name: "fk_outbox_message_inbox_state_inbox_message_id_inbox_consumer_~", + columns: x => new { x.inbox_message_id, x.inbox_consumer_id }, + principalTable: "inbox_state", + principalColumns: new[] { "message_id", "consumer_id" }); + table.ForeignKey( + name: "fk_outbox_message_outbox_state_outbox_id", + column: x => x.outbox_id, + principalTable: "outbox_state", + principalColumn: "outbox_id"); + }); + + migrationBuilder.CreateTable( + name: "plan_module_entitlements", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + plan_code = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + module_code = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + enabled = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_plan_module_entitlements", x => x.id); + table.ForeignKey( + name: "fk_plan_module_entitlements_platform_saas_plans_plan_code", + column: x => x.plan_code, + principalTable: "platform_saas_plans", + principalColumn: "code", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_plan_module_entitlements_product_modules_module_code", + column: x => x.module_code, + principalTable: "product_modules", + principalColumn: "code", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "tenant_module_overrides", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + module_code = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + mode = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + expires_at = table.Column(type: "timestamp with time zone", nullable: true), + reason = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + tenant_id = table.Column(type: "uuid", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + updated_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("pk_tenant_module_overrides", x => x.id); + table.UniqueConstraint("ak_tenant_module_overrides_tenant_id_id", x => new { x.tenant_id, x.id }); + table.ForeignKey( + name: "fk_tenant_module_overrides_product_modules_module_code", + column: x => x.module_code, + principalTable: "product_modules", + principalColumn: "code", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "fk_tenant_module_overrides_tenants_tenant_id", + column: x => x.tenant_id, + principalTable: "tenants", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_inbox_state_delivered", + table: "inbox_state", + column: "delivered"); + + migrationBuilder.CreateIndex( + name: "ix_outbox_message_enqueue_time", + table: "outbox_message", + column: "enqueue_time"); + + migrationBuilder.CreateIndex( + name: "ix_outbox_message_expiration_time", + table: "outbox_message", + column: "expiration_time"); + + migrationBuilder.CreateIndex( + name: "ix_outbox_message_inbox_message_id_inbox_consumer_id_sequence_~", + table: "outbox_message", + columns: new[] { "inbox_message_id", "inbox_consumer_id", "sequence_number" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_outbox_message_outbox_id_sequence_number", + table: "outbox_message", + columns: new[] { "outbox_id", "sequence_number" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_outbox_state_created", + table: "outbox_state", + column: "created"); + + migrationBuilder.CreateIndex( + name: "ix_plan_module_entitlements_module_code", + table: "plan_module_entitlements", + column: "module_code"); + + migrationBuilder.CreateIndex( + name: "ix_plan_module_entitlements_plan_code_module_code", + table: "plan_module_entitlements", + columns: new[] { "plan_code", "module_code" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_product_modules_code", + table: "product_modules", + column: "code", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_tenant_module_overrides_module_code", + table: "tenant_module_overrides", + column: "module_code"); + + migrationBuilder.CreateIndex( + name: "ix_tenant_module_overrides_tenant_id_module_code", + table: "tenant_module_overrides", + columns: new[] { "tenant_id", "module_code" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "outbox_message"); + + migrationBuilder.DropTable( + name: "plan_module_entitlements"); + + migrationBuilder.DropTable( + name: "tenant_auth_policies"); + + migrationBuilder.DropTable( + name: "tenant_module_overrides"); + + migrationBuilder.DropTable( + name: "inbox_state"); + + migrationBuilder.DropTable( + name: "outbox_state"); + + migrationBuilder.DropTable( + name: "product_modules"); + + migrationBuilder.DropUniqueConstraint( + name: "ak_platform_saas_plans_code", + table: "platform_saas_plans"); + } + } +} diff --git a/Tiku.Infrastructure/Persistence/Migrations/TikuDbContextModelSnapshot.cs b/Tiku.Infrastructure/Persistence/Migrations/TikuDbContextModelSnapshot.cs index 189fa3e..8a1f2e1 100644 --- a/Tiku.Infrastructure/Persistence/Migrations/TikuDbContextModelSnapshot.cs +++ b/Tiku.Infrastructure/Persistence/Migrations/TikuDbContextModelSnapshot.cs @@ -25,6 +25,224 @@ namespace Tiku.Infrastructure.Persistence.Migrations NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "ltree"); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone") + .HasColumnName("consumed"); + + b.Property("ConsumerId") + .HasColumnType("uuid") + .HasColumnName("consumer_id"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone") + .HasColumnName("delivered"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_time"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint") + .HasColumnName("last_sequence_number"); + + b.Property("LockId") + .HasColumnType("uuid") + .HasColumnName("lock_id"); + + b.Property("MessageId") + .HasColumnType("uuid") + .HasColumnName("message_id"); + + b.Property("ReceiveCount") + .HasColumnType("integer") + .HasColumnName("receive_count"); + + b.Property("Received") + .HasColumnType("timestamp with time zone") + .HasColumnName("received"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_inbox_state"); + + b.HasAlternateKey("MessageId", "ConsumerId") + .HasName("ak_inbox_state_message_id_consumer_id"); + + b.HasIndex("Delivered") + .HasDatabaseName("ix_inbox_state_delivered"); + + b.ToTable("inbox_state", (string)null); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("sequence_number"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text") + .HasColumnName("body"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("content_type"); + + b.Property("ConversationId") + .HasColumnType("uuid") + .HasColumnName("conversation_id"); + + b.Property("CorrelationId") + .HasColumnType("uuid") + .HasColumnName("correlation_id"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("destination_address"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("enqueue_time"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_time"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("fault_address"); + + b.Property("Headers") + .HasColumnType("text") + .HasColumnName("headers"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid") + .HasColumnName("inbox_consumer_id"); + + b.Property("InboxMessageId") + .HasColumnType("uuid") + .HasColumnName("inbox_message_id"); + + b.Property("InitiatorId") + .HasColumnType("uuid") + .HasColumnName("initiator_id"); + + b.Property("MessageId") + .HasColumnType("uuid") + .HasColumnName("message_id"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("message_type"); + + b.Property("OutboxId") + .HasColumnType("uuid") + .HasColumnName("outbox_id"); + + b.Property("Properties") + .HasColumnType("text") + .HasColumnName("properties"); + + b.Property("RequestId") + .HasColumnType("uuid") + .HasColumnName("request_id"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("response_address"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("sent_time"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("source_address"); + + b.HasKey("SequenceNumber") + .HasName("pk_outbox_message"); + + b.HasIndex("EnqueueTime") + .HasDatabaseName("ix_outbox_message_enqueue_time"); + + b.HasIndex("ExpirationTime") + .HasDatabaseName("ix_outbox_message_expiration_time"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique() + .HasDatabaseName("ix_outbox_message_outbox_id_sequence_number"); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique() + .HasDatabaseName("ix_outbox_message_inbox_message_id_inbox_consumer_id_sequence_~"); + + b.ToTable("outbox_message", (string)null); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("outbox_id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone") + .HasColumnName("delivered"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint") + .HasColumnName("last_sequence_number"); + + b.Property("LockId") + .HasColumnType("uuid") + .HasColumnName("lock_id"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("row_version"); + + b.HasKey("OutboxId") + .HasName("pk_outbox_state"); + + b.HasIndex("Created") + .HasDatabaseName("ix_outbox_state_created"); + + b.ToTable("outbox_state", (string)null); + }); + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => { b.Property("Id") @@ -11755,6 +11973,43 @@ namespace Tiku.Infrastructure.Persistence.Migrations b.ToTable("user_notifications", (string)null); }); + modelBuilder.Entity("Tiku.Domain.Platform.PlanModuleEntitlement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("ModuleCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("module_code"); + + b.Property("PlanCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("plan_code"); + + b.HasKey("Id") + .HasName("pk_plan_module_entitlements"); + + b.HasIndex("ModuleCode") + .HasDatabaseName("ix_plan_module_entitlements_module_code"); + + b.HasIndex("PlanCode", "ModuleCode") + .IsUnique() + .HasDatabaseName("ix_plan_module_entitlements_plan_code_module_code"); + + b.ToTable("plan_module_entitlements", (string)null); + }); + modelBuilder.Entity("Tiku.Domain.Platform.PlatformAuditAlert", b => { b.Property("Id") @@ -12304,6 +12559,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations b.HasKey("Id") .HasName("pk_platform_saas_plans"); + b.HasAlternateKey("Code") + .HasName("ak_platform_saas_plans_code"); + b.HasIndex("Code") .IsUnique() .HasDatabaseName("ix_platform_saas_plans_code"); @@ -12317,6 +12575,66 @@ namespace Tiku.Infrastructure.Persistence.Migrations }); }); + modelBuilder.Entity("Tiku.Domain.Platform.ProductModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_product_modules"); + + b.HasAlternateKey("Code") + .HasName("ak_product_modules_code"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_product_modules_code"); + + b.ToTable("product_modules", (string)null); + }); + modelBuilder.Entity("Tiku.Domain.Platform.TenantBillingProfile", b => { b.Property("TenantId") @@ -12818,6 +13136,67 @@ namespace Tiku.Infrastructure.Persistence.Migrations }); }); + modelBuilder.Entity("Tiku.Domain.Platform.TenantModuleOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("mode"); + + b.Property("ModuleCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("module_code"); + + b.Property("Reason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("reason"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_tenant_module_overrides"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_module_overrides_tenant_id_id"); + + b.HasIndex("ModuleCode") + .HasDatabaseName("ix_tenant_module_overrides_module_code"); + + b.HasIndex("TenantId", "ModuleCode") + .IsUnique() + .HasDatabaseName("ix_tenant_module_overrides_tenant_id_module_code"); + + b.ToTable("tenant_module_overrides", (string)null); + }); + modelBuilder.Entity("Tiku.Domain.QuestionBanks.Question", b => { b.Property("Id") @@ -13642,6 +14021,36 @@ namespace Tiku.Infrastructure.Persistence.Migrations b.ToTable("tenants", (string)null); }); + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantAuthPolicy", b => + { + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("AllowExternalStudentSelfRegistration") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("allow_external_student_self_registration"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("TenantId") + .HasName("pk_tenant_auth_policies"); + + b.ToTable("tenant_auth_policies", (string)null); + }); + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantBranding", b => { b.Property("TenantId") @@ -14618,6 +15027,20 @@ namespace Tiku.Infrastructure.Persistence.Migrations b.ToTable("tenant_student_notes", (string)null); }); + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId") + .HasConstraintName("fk_outbox_message_outbox_state_outbox_id"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId") + .HasConstraintName("fk_outbox_message_inbox_state_inbox_message_id_inbox_consumer_~"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => { b.HasOne("Tiku.Domain.Identity.User", null) @@ -17550,6 +17973,25 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasConstraintName("fk_user_notifications_users_user_id"); }); + modelBuilder.Entity("Tiku.Domain.Platform.PlanModuleEntitlement", b => + { + b.HasOne("Tiku.Domain.Platform.ProductModule", null) + .WithMany() + .HasForeignKey("ModuleCode") + .HasPrincipalKey("Code") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_plan_module_entitlements_product_modules_module_code"); + + b.HasOne("Tiku.Domain.Platform.PlatformSaasPlan", null) + .WithMany() + .HasForeignKey("PlanCode") + .HasPrincipalKey("Code") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_plan_module_entitlements_platform_saas_plans_plan_code"); + }); + modelBuilder.Entity("Tiku.Domain.Platform.PlatformAuditAlert", b => { b.HasOne("Tiku.Domain.Identity.User", null) @@ -17719,6 +18161,24 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasConstraintName("fk_tenant_invoice_reminders_tenant_invoices_tenant_id_invoice_~"); }); + modelBuilder.Entity("Tiku.Domain.Platform.TenantModuleOverride", b => + { + b.HasOne("Tiku.Domain.Platform.ProductModule", null) + .WithMany() + .HasForeignKey("ModuleCode") + .HasPrincipalKey("Code") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_tenant_module_overrides_product_modules_module_code"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_module_overrides_tenants_tenant_id"); + }); + modelBuilder.Entity("Tiku.Domain.QuestionBanks.Question", b => { b.HasOne("Tiku.Domain.Tenancy.Tenant", null) @@ -17895,6 +18355,16 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasConstraintName("fk_tenants_users_owner_user_id"); }); + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantAuthPolicy", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithOne() + .HasForeignKey("Tiku.Domain.Tenancy.TenantAuthPolicy", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_auth_policies_tenants_tenant_id"); + }); + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantBranding", b => { b.HasOne("Tiku.Domain.Tenancy.Tenant", null) diff --git a/Tiku.Infrastructure/Persistence/TikuDbContext.cs b/Tiku.Infrastructure/Persistence/TikuDbContext.cs index 343890d..2b71f7e 100644 --- a/Tiku.Infrastructure/Persistence/TikuDbContext.cs +++ b/Tiku.Infrastructure/Persistence/TikuDbContext.cs @@ -16,6 +16,8 @@ using Tiku.Domain.Operations; using Tiku.Domain.Platform; using Tiku.Domain.QuestionBanks; using Tiku.Domain.Tenancy; +using MassTransit; +using MassTransit.EntityFrameworkCoreIntegration; namespace Tiku.Infrastructure.Persistence; @@ -47,6 +49,7 @@ public sealed class TikuDbContext( public DbSet TenantDomains => Set(); public DbSet TenantBrandings => Set(); public DbSet TenantSettings => Set(); + public DbSet TenantAuthPolicies => Set(); public DbSet TenantFrontendConfigs => Set(); public DbSet TenantExternalProviders => Set(); public DbSet TenantSecrets => Set(); @@ -178,6 +181,9 @@ public sealed class TikuDbContext( public DbSet TenantThemeTemplates => Set(); public DbSet TenantThemeConfigs => Set(); public DbSet PlatformSaasPlans => Set(); + public DbSet ProductModules => Set(); + public DbSet PlanModuleEntitlements => Set(); + public DbSet TenantModuleOverrides => Set(); public DbSet TenantBillingProfiles => Set(); public DbSet TenantInvoices => Set(); public DbSet TenantInvoiceItems => Set(); @@ -201,6 +207,12 @@ public sealed class TikuDbContext( modelBuilder.HasPostgresExtension("ltree"); modelBuilder.ApplyConfigurationsFromAssembly(typeof(TikuDbContext).Assembly); modelBuilder.Entity().ToTable("data_protection_keys"); + modelBuilder.AddInboxStateEntity(); + modelBuilder.AddOutboxMessageEntity(); + modelBuilder.AddOutboxStateEntity(); + modelBuilder.Entity().ToTable("inbox_state"); + modelBuilder.Entity().ToTable("outbox_message"); + modelBuilder.Entity().ToTable("outbox_state"); ApplyTenantQueryFilters(modelBuilder); ValidateTenantModel(modelBuilder); modelBuilder.UseSnakeCaseIdentifiers(); diff --git a/Tiku.Infrastructure/PlatformAdmin/PlatformAdminService.cs b/Tiku.Infrastructure/PlatformAdmin/PlatformAdminService.cs index 58fa20c..423b193 100644 --- a/Tiku.Infrastructure/PlatformAdmin/PlatformAdminService.cs +++ b/Tiku.Infrastructure/PlatformAdmin/PlatformAdminService.cs @@ -10,6 +10,7 @@ using Tiku.Domain.Platform; using Tiku.Domain.QuestionBanks; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; +using Tiku.Infrastructure.Messaging; namespace Tiku.Infrastructure.PlatformAdmin; @@ -152,6 +153,11 @@ internal sealed class PlatformAdminService( Metadata = JsonObjectOrDefault(command.Metadata) }; dbContext.Tenants.Add(tenant); + dbContext.TenantAuthPolicies.Add(new TenantAuthPolicy + { + TenantId = tenant.Id, + AllowExternalStudentSelfRegistration = false + }); AddAudit(dbContext, actor, "platform.tenant.created", tenant.Id, new { tenant.Slug, tenant.Name, tenant.Status, tenant.BillingStatus }); await dbContext.SaveChangesAsync(cancellationToken); return ToTenantItem(tenant, 0, null); @@ -164,7 +170,7 @@ internal sealed class PlatformAdminService( CancellationToken cancellationToken = default) { await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); - return await ExecuteSystemAsync("platform tenant status update", async dbContext => + return await ExecuteSystemAsync("platform tenant status update", async (provider, dbContext) => { var tenant = await dbContext.Tenants .SingleOrDefaultAsync(item => item.Id == command.TenantId && item.Mode != TenantMode.PlatformOwned, cancellationToken) @@ -181,6 +187,13 @@ internal sealed class PlatformAdminService( ToBillingStatus = tenant.BillingStatus, command.Reason }); + await provider.GetRequiredService().AuthorizationChangedAsync( + tenant.Id, + null, + "tenant_status_changed", + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + $"tenant-status-{tenant.Id:N}", + cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); var domainCount = await dbContext.TenantDomains.CountAsync(domain => domain.TenantId == tenant.Id, cancellationToken); var expiresAt = await dbContext.TenantSubscriptions @@ -255,7 +268,7 @@ internal sealed class PlatformAdminService( CancellationToken cancellationToken = default) { await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); - return await ExecuteSystemAsync("platform tenant subscription upsert", async dbContext => + return await ExecuteSystemAsync("platform tenant subscription upsert", async (provider, dbContext) => { await RequireTenantAsync(dbContext, command.TenantId, cancellationToken); if (!await dbContext.PlatformSaasPlans.AnyAsync(plan => plan.Code == NormalizeCode(command.PlanCode), cancellationToken)) @@ -285,6 +298,27 @@ internal sealed class PlatformAdminService( subscription.Status, subscription.ExpiresAt }); + var moduleCodes = await dbContext.PlanModuleEntitlements.AsNoTracking() + .Where(item => item.PlanCode == subscription.PlanCode) + .Select(item => item.ModuleCode) + .Distinct() + .ToArrayAsync(cancellationToken); + if (moduleCodes.Length == 0) + { + moduleCodes = ["*"]; + } + var eventPublisher = provider.GetRequiredService(); + var version = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + foreach (var moduleCode in moduleCodes) + { + await eventPublisher.CapabilityChangedAsync( + command.TenantId, + moduleCode, + "subscription_changed", + version, + $"tenant-subscription-{subscription.Id:N}", + cancellationToken); + } await dbContext.SaveChangesAsync(cancellationToken); return ToSubscriptionItem(subscription); }, cancellationToken); @@ -719,11 +753,23 @@ internal sealed class PlatformAdminService( string reason, Func> operation, CancellationToken cancellationToken) + { + return ExecuteSystemAsync(reason, (_, dbContext) => operation(dbContext), cancellationToken); + } + + private Task ExecuteSystemAsync( + string reason, + Func> operation, + CancellationToken cancellationToken) { return tenantExecutionScope.ExecuteAsync( - null, - reason, - async (provider, _) => await operation(provider.GetRequiredService()), + new SystemScopeRequest( + null, + SystemScopeCallerType.Platform, + nameof(PlatformAdminService), + reason, + Guid.NewGuid().ToString("N")), + async (provider, _) => await operation(provider, provider.GetRequiredService()), cancellationToken); } diff --git a/Tiku.Infrastructure/QuestionBanks/QuestionBankQueryService.cs b/Tiku.Infrastructure/QuestionBanks/QuestionBankQueryService.cs index 0863d60..e8f9992 100644 --- a/Tiku.Infrastructure/QuestionBanks/QuestionBankQueryService.cs +++ b/Tiku.Infrastructure/QuestionBanks/QuestionBankQueryService.cs @@ -50,8 +50,9 @@ public sealed class QuestionBankQueryService( var platformItems = filter.Source == QuestionSource.Tenant || !await CanAccessPlatformAsync(filter.TenantId, cancellationToken) ? [] : await tenantExecutionScope.ExecuteAsync( - filter.TenantId, - "List platform question banks for an entitled tenant", + new SystemScopeRequest( + filter.TenantId, SystemScopeCallerType.PublicQuestionBank, nameof(QuestionBankQueryService), + "List platform question banks for an entitled tenant", Guid.NewGuid().ToString("N")), async (provider, token) => { var systemDbContext = provider.GetRequiredService(); @@ -336,8 +337,9 @@ public sealed class QuestionBankQueryService( CancellationToken cancellationToken) { return tenantExecutionScope.ExecuteAsync( - filter.TenantId, - "List platform questions for an entitled tenant", + new SystemScopeRequest( + filter.TenantId, SystemScopeCallerType.PublicQuestionBank, nameof(QuestionBankQueryService), + "List platform questions for an entitled tenant", Guid.NewGuid().ToString("N")), async (provider, token) => { var systemDbContext = provider.GetRequiredService(); @@ -380,8 +382,9 @@ public sealed class QuestionBankQueryService( CancellationToken cancellationToken) { return tenantExecutionScope.ExecuteAsync( - tenantId, - "Read platform question versions for an entitled tenant", + new SystemScopeRequest( + tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(QuestionBankQueryService), + "Read platform question versions for an entitled tenant", Guid.NewGuid().ToString("N")), async (provider, token) => { var systemDbContext = provider.GetRequiredService(); diff --git a/Tiku.Infrastructure/QuestionBanks/QuestionReferenceService.cs b/Tiku.Infrastructure/QuestionBanks/QuestionReferenceService.cs index f1d9a39..1a24974 100644 --- a/Tiku.Infrastructure/QuestionBanks/QuestionReferenceService.cs +++ b/Tiku.Infrastructure/QuestionBanks/QuestionReferenceService.cs @@ -73,8 +73,9 @@ public sealed class QuestionReferenceService( { await accessPolicy.EnsureCanStartAsync(tenantId, cancellationToken); var ownerTenantId = await tenantExecutionScope.ExecuteAsync( - tenantId, - "Resolve a platform question for an entitled tenant", + new SystemScopeRequest( + tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(QuestionReferenceService), + "Resolve a platform question for an entitled tenant", Guid.NewGuid().ToString("N")), async (provider, token) => { var systemDbContext = provider.GetRequiredService(); diff --git a/Tiku.Infrastructure/Security/CapabilityAccessEvaluator.cs b/Tiku.Infrastructure/Security/CapabilityAccessEvaluator.cs new file mode 100644 index 0000000..2c22dbd --- /dev/null +++ b/Tiku.Infrastructure/Security/CapabilityAccessEvaluator.cs @@ -0,0 +1,87 @@ +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Security; +using Tiku.Domain.Commerce; +using Tiku.Domain.Platform; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Security; + +internal sealed class CapabilityAccessEvaluator(TikuDbContext dbContext) : ICapabilityAccessEvaluator +{ + public async Task IsAllowedAsync( + Guid tenantId, + string moduleCode, + CapabilityOperation operation, + CancellationToken cancellationToken = default) + { + var normalized = moduleCode.Trim().ToLowerInvariant(); + var moduleExists = await dbContext.ProductModules.AsNoTracking() + .AnyAsync(item => item.Code == normalized && item.Status == ProductModuleStatus.Active, cancellationToken); + if (!moduleExists) + { + // Compatibility while the fixed module catalog is introduced module-by-module. + return true; + } + + var tenantActive = await dbContext.Tenants.AsNoTracking() + .AnyAsync(item => item.Id == tenantId && item.Status == TenantStatus.Active, cancellationToken); + if (!tenantActive) + { + return false; + } + + var now = DateTimeOffset.UtcNow; + var overrideMode = await dbContext.TenantModuleOverrides.AsNoTracking() + .Where(item => item.TenantId == tenantId && item.ModuleCode == normalized && + (item.ExpiresAt == null || item.ExpiresAt > now)) + .Select(item => (TenantModuleOverrideMode?)item.Mode) + .SingleOrDefaultAsync(cancellationToken); + if (overrideMode == TenantModuleOverrideMode.Disabled) + { + return false; + } + + var subscription = await dbContext.TenantSubscriptions.AsNoTracking() + .Where(item => item.TenantId == tenantId) + .OrderByDescending(item => item.UpdatedAt) + .Select(item => new { item.PlanCode, item.Status, item.StartsAt, item.ExpiresAt }) + .FirstOrDefaultAsync(cancellationToken); + if (subscription is null || subscription.StartsAt > now || subscription.ExpiresAt <= now) + { + return false; + } + + var entitled = overrideMode == TenantModuleOverrideMode.Enabled || + await dbContext.PlanModuleEntitlements.AsNoTracking().AnyAsync( + item => item.PlanCode == subscription.PlanCode && item.ModuleCode == normalized && item.Enabled, + cancellationToken); + if (!entitled) + { + return false; + } + + return operation == CapabilityOperation.Read || + subscription.Status is TenantSubscriptionStatus.Trial or TenantSubscriptionStatus.Active; + } + + public async Task> GetEnabledModulesAsync( + Guid tenantId, + CapabilityOperation operation = CapabilityOperation.Read, + CancellationToken cancellationToken = default) + { + var modules = await dbContext.ProductModules.AsNoTracking() + .Where(item => item.Status == ProductModuleStatus.Active) + .Select(item => item.Code) + .ToArrayAsync(cancellationToken); + var enabled = new HashSet(StringComparer.Ordinal); + foreach (var module in modules) + { + if (await IsAllowedAsync(tenantId, module, operation, cancellationToken)) + { + enabled.Add(module); + } + } + return enabled; + } +} diff --git a/Tiku.Infrastructure/Security/RedisSecurityConnectionOptions.cs b/Tiku.Infrastructure/Security/RedisSecurityConnectionOptions.cs new file mode 100644 index 0000000..6c48096 --- /dev/null +++ b/Tiku.Infrastructure/Security/RedisSecurityConnectionOptions.cs @@ -0,0 +1,6 @@ +namespace Tiku.Infrastructure.Security; + +public sealed class RedisSecurityConnectionOptions +{ + public string ConnectionString { get; set; } = string.Empty; +} diff --git a/Tiku.Infrastructure/Security/RedisSecurityStore.cs b/Tiku.Infrastructure/Security/RedisSecurityStore.cs new file mode 100644 index 0000000..b69a1de --- /dev/null +++ b/Tiku.Infrastructure/Security/RedisSecurityStore.cs @@ -0,0 +1,135 @@ +using Microsoft.Extensions.Logging; +using StackExchange.Redis; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using Tiku.Application.Security; + +namespace Tiku.Infrastructure.Security; + +internal sealed class RedisSecurityStore( + IConnectionMultiplexer connection, + string environmentName, + ILogger logger) : IRedisSecurityStore +{ + private static readonly Meter Meter = new("Tiku.Security.Redis", "1.0.0"); + private static readonly Counter OperationCounter = Meter.CreateCounter("tiku.redis.security.operations"); + private static readonly Counter RejectionCounter = Meter.CreateCounter("tiku.redis.rate_limit.rejections"); + private static readonly Counter ErrorCounter = Meter.CreateCounter("tiku.redis.security.errors"); + private static readonly Histogram ScriptDuration = Meter.CreateHistogram( + "tiku.redis.lua.duration", "ms"); + private const string ConsumeScript = """ + local now = redis.call('TIME') + local nowMs = now[1] * 1000 + math.floor(now[2] / 1000) + local retryAfter = 0 + for i = 1, #KEYS do + local current = tonumber(redis.call('GET', KEYS[i]) or '0') + local limit = tonumber(ARGV[(i - 1) * 2 + 1]) + if current >= limit then + local ttl = redis.call('PTTL', KEYS[i]) + if ttl > retryAfter then retryAfter = ttl end + end + end + if retryAfter > 0 then return {0, retryAfter} end + for i = 1, #KEYS do + local window = tonumber(ARGV[(i - 1) * 2 + 2]) + local value = redis.call('INCR', KEYS[i]) + if value == 1 then redis.call('PEXPIRE', KEYS[i], window) end + end + return {1, 0} + """; + + private readonly string prefix = $"tiku:{Normalize(environmentName)}"; + + public bool IsConfigured => true; + + public async Task ConsumeAsync( + IReadOnlyCollection buckets, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (buckets.Count == 0) + { + return new DistributedRateLimitResult(true); + } + + try + { + var started = Stopwatch.GetTimestamp(); + var keys = buckets.Select(bucket => (RedisKey)$"{prefix}:rl:{bucket.Key}").ToArray(); + var values = buckets + .SelectMany(bucket => new RedisValue[] { bucket.PermitLimit, (long)bucket.Window.TotalMilliseconds }) + .ToArray(); + var result = (RedisResult[])(await connection.GetDatabase() + .ScriptEvaluateAsync(ConsumeScript, keys, values).WaitAsync(cancellationToken))!; + var allowed = (long)result[0] == 1; + var retryMs = (long)result[1]; + OperationCounter.Add(1, new KeyValuePair("operation", "rate_limit")); + ScriptDuration.Record(Stopwatch.GetElapsedTime(started).TotalMilliseconds); + if (!allowed) + { + RejectionCounter.Add(1); + } + return new DistributedRateLimitResult( + allowed, + retryMs > 0 ? TimeSpan.FromMilliseconds(retryMs) : null); + } + catch (Exception exception) when (exception is RedisException or TimeoutException) + { + ErrorCounter.Add(1, new KeyValuePair("operation", "rate_limit")); + logger.LogError(exception, "Redis security operation failed closed."); + throw new RedisSecurityUnavailableException(exception); + } + } + + public async Task PingAsync(CancellationToken cancellationToken = default) + { + try + { + await connection.GetDatabase().PingAsync().WaitAsync(cancellationToken); + OperationCounter.Add(1, new KeyValuePair("operation", "ping")); + return true; + } + catch (Exception exception) when (exception is RedisException or TimeoutException) + { + ErrorCounter.Add(1, new KeyValuePair("operation", "ping")); + return false; + } + } + + public async Task SetInvalidationVersionAsync( + string realm, + Guid? tenantId, + Guid? userId, + long version, + CancellationToken cancellationToken = default) + { + var key = $"{prefix}:auth-inv:{Normalize(realm)}:{tenantId?.ToString("N") ?? "-"}:{userId?.ToString("N") ?? "-"}"; + await connection.GetDatabase().StringSetAsync(key, version, TimeSpan.FromDays(2)).WaitAsync(cancellationToken); + } + + private static string Normalize(string value) => + string.Concat(value.Trim().ToLowerInvariant().Select(character => + char.IsLetterOrDigit(character) || character is '-' or '_' ? character : '-')); +} + +public sealed class NullRedisSecurityStore : IRedisSecurityStore +{ + public bool IsConfigured => false; + + public Task ConsumeAsync( + IReadOnlyCollection buckets, + CancellationToken cancellationToken = default) => + Task.FromResult(new DistributedRateLimitResult(true)); + + public Task PingAsync(CancellationToken cancellationToken = default) => Task.FromResult(false); + + public Task SetInvalidationVersionAsync( + string realm, + Guid? tenantId, + Guid? userId, + long version, + CancellationToken cancellationToken = default) => Task.CompletedTask; +} + +public sealed class RedisSecurityUnavailableException(Exception innerException) + : Exception("Redis security services are unavailable.", innerException); diff --git a/Tiku.Infrastructure/Tenancy/TenantExecutionScope.cs b/Tiku.Infrastructure/Tenancy/TenantExecutionScope.cs index 82a8cdc..2909b61 100644 --- a/Tiku.Infrastructure/Tenancy/TenantExecutionScope.cs +++ b/Tiku.Infrastructure/Tenancy/TenantExecutionScope.cs @@ -1,6 +1,11 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.EntityFrameworkCore; using Tiku.Application.Security; +using System.Diagnostics; +using System.Text.Json; +using Tiku.Domain.Operations; +using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Tenancy; @@ -9,42 +14,102 @@ public sealed class TenantExecutionScope( ILogger logger) : ITenantExecutionScope { public Task ExecuteAsync( - Guid? targetTenantId, - string reason, + SystemScopeRequest request, Func operation, - CancellationToken cancellationToken = default) - { - return ExecuteAsync( - targetTenantId, - reason, - async (provider, token) => - { - await operation(provider, token); - return null; - }, - cancellationToken); - } + CancellationToken cancellationToken = default) => + ExecuteAsync(request, async (provider, token) => + { + await operation(provider, token); + return null; + }, cancellationToken); public async Task ExecuteAsync( - Guid? targetTenantId, - string reason, + SystemScopeRequest request, Func> operation, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(operation); - if (string.IsNullOrWhiteSpace(reason)) - { - throw new ArgumentException("A system scope requires an audit reason.", nameof(reason)); - } - + Validate(request, operation); await using var scope = scopeFactory.CreateAsyncScope(); var initializer = scope.ServiceProvider.GetRequiredService(); - initializer.InitializeSystem(targetTenantId, reason); + initializer.InitializeSystem(request.TargetTenantId, request.Reason); logger.LogWarning( - "Entering audited system tenant scope. TargetTenantId={TargetTenantId} Reason={Reason}", - targetTenantId, - reason); + "Entering audited system scope. CallerType={CallerType} Caller={Caller} TargetTenantId={TargetTenantId} CorrelationId={CorrelationId} Reason={Reason}", + request.CallerType, + request.Caller, + request.TargetTenantId, + request.CorrelationId, + request.Reason); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var started = DateTimeOffset.UtcNow; + var stopwatch = Stopwatch.StartNew(); + await using var transaction = dbContext.Database.IsRelational() + ? await dbContext.Database.BeginTransactionAsync(cancellationToken) + : null; + await WriteAuditAsync(dbContext, request, "system_scope.entered", started, null, null, cancellationToken); + try + { + var result = await operation(scope.ServiceProvider, cancellationToken); + await WriteAuditAsync( + dbContext, request, "system_scope.completed", started, stopwatch.ElapsedMilliseconds, null, cancellationToken); + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + } + return result; + } + catch (Exception exception) + { + if (transaction is not null) + { + await transaction.RollbackAsync(CancellationToken.None); + dbContext.ChangeTracker.Clear(); + await WriteAuditAsync( + dbContext, request, "system_scope.entered", started, null, null, CancellationToken.None); + } + await WriteAuditAsync( + dbContext, request, "system_scope.failed", started, stopwatch.ElapsedMilliseconds, + exception.GetType().Name, CancellationToken.None); + throw; + } + } - return await operation(scope.ServiceProvider, cancellationToken); + private static void Validate( + SystemScopeRequest request, + Func> operation) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(operation); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Caller); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Reason); + ArgumentException.ThrowIfNullOrWhiteSpace(request.CorrelationId); + } + + private static async Task WriteAuditAsync( + TikuDbContext dbContext, + SystemScopeRequest request, + string action, + DateTimeOffset startedAt, + long? elapsedMilliseconds, + string? failureType, + CancellationToken cancellationToken) + { + dbContext.AuditLogs.Add(new AuditLog + { + TenantId = request.TargetTenantId, + Action = action, + TargetType = "system_scope", + TargetId = request.CorrelationId, + Details = JsonSerializer.SerializeToElement(new + { + callerType = request.CallerType.ToString(), + request.Caller, + request.Reason, + request.CorrelationId, + startedAt, + elapsedMilliseconds, + failureType + }) + }); + await dbContext.SaveChangesAsync(cancellationToken); } } diff --git a/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs b/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs index a91f7dc..0ad0754 100644 --- a/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs +++ b/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs @@ -15,6 +15,7 @@ using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.Security; +using Tiku.Infrastructure.Messaging; using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus; using OrderStatus = Tiku.Domain.Commerce.OrderStatus; using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus; @@ -28,7 +29,8 @@ public sealed class TenantAdminDirectService( ITenantExternalProviderConfigService providerConfigService, INotificationProvider notificationProvider, ICurrentAccessContext currentAccessContext, - IAuthSessionStore sessionStore) : ITenantAdminDirectService + IAuthSessionStore sessionStore, + ISecurityEventPublisher securityEventPublisher) : ITenantAdminDirectService { public async Task GetOverviewAsync( TenantAdminActor actor, @@ -1154,6 +1156,7 @@ public sealed class TenantAdminDirectService( } var isNew = membership is null; + var previousStatus = membership?.Status.ToString() ?? "none"; if (membership?.Role == TenantRole.TenantOwner && role != TenantRole.TenantOwner) { throw new TenantAdminDirectException("Tenant owner membership cannot be downgraded.", "tenant_owner_required"); @@ -1182,6 +1185,9 @@ public sealed class TenantAdminDirectService( } await AddAuditAsync(actor, "tenant.member.upserted", "tenant_memberships", membership.Id, cancellationToken); + await securityEventPublisher.MembershipChangedAsync( + actor.TenantId, user.Id, previousStatus, status.ToString(), + $"tenant-member-{membership.Id:N}", cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); return new ContentManagementResult(ToMemberItem(membership, user)); } @@ -1206,9 +1212,13 @@ public sealed class TenantAdminDirectService( } await AssertGrantableAsync(actor, membership.Role, cancellationToken); + var previousStatus = membership.Status; membership.Status = MembershipStatus.Disabled; await RevokeSessionsAsync(actor.TenantId, membership.UserId, cancellationToken); await AddAuditAsync(actor, "tenant.member.disabled", "tenant_memberships", membership.Id, cancellationToken); + await securityEventPublisher.MembershipChangedAsync( + actor.TenantId, membership.UserId, previousStatus.ToString(), MembershipStatus.Disabled.ToString(), + $"tenant-member-{membership.Id:N}", cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); var user = await dbContext.Users.AsNoTracking().SingleAsync(item => item.Id == membership.UserId, cancellationToken); return new ContentManagementResult(ToMemberItem(membership, user)); diff --git a/Tiku.Infrastructure/Tiku.Infrastructure.csproj b/Tiku.Infrastructure/Tiku.Infrastructure.csproj index 3ff3704..3226c7a 100644 --- a/Tiku.Infrastructure/Tiku.Infrastructure.csproj +++ b/Tiku.Infrastructure/Tiku.Infrastructure.csproj @@ -3,6 +3,7 @@ + @@ -16,6 +17,11 @@ + + + + + diff --git a/Tiku.IntegrationTests/Api/ApiTestFactory.cs b/Tiku.IntegrationTests/Api/ApiTestFactory.cs index 54666ec..6e66ec8 100644 --- a/Tiku.IntegrationTests/Api/ApiTestFactory.cs +++ b/Tiku.IntegrationTests/Api/ApiTestFactory.cs @@ -34,8 +34,17 @@ public sealed class ApiTestFactory( { private readonly PostgresTestDatabase database = PostgresTestDatabase.Create(); + public string DatabaseConnectionString => database.ConnectionString; + protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder) { + if (configurationOverrides is not null) + { + foreach (var pair in configurationOverrides.Where(pair => pair.Value is not null)) + { + builder.UseSetting(pair.Key, pair.Value); + } + } builder.ConfigureAppConfiguration((_, configuration) => { var values = new Dictionary @@ -61,7 +70,7 @@ public sealed class ApiTestFactory( descriptor.ServiceType == typeof(NpgsqlDataSource) || descriptor.ServiceType == typeof(TenantIsolationSaveChangesInterceptor) || descriptor.ServiceType == typeof(DbContextOptions) || - descriptor.ServiceType.FullName?.Contains(nameof(TikuDbContext), StringComparison.Ordinal) == true) + descriptor.ServiceType == typeof(TikuDbContext)) .ToArray()) { services.Remove(descriptor); diff --git a/Tiku.IntegrationTests/Api/AuthEndpointTests.cs b/Tiku.IntegrationTests/Api/AuthEndpointTests.cs index 16f6b62..e3e070d 100644 --- a/Tiku.IntegrationTests/Api/AuthEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/AuthEndpointTests.cs @@ -305,6 +305,123 @@ public sealed class AuthEndpointTests StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task Wechat_first_login_is_rejected_without_self_registration_and_leaves_no_identity() + { + await using var factory = new ApiTestFactory(new FakeWechatOAuthClient()); + var tenantId = Guid.NewGuid(); + await SeedWechatProviderAsync(factory, tenantId, + new TenantAuthPolicy + { + TenantId = tenantId, + AllowExternalStudentSelfRegistration = false + }); + using var client = factory.CreateClient(); + client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); + + var response = await client.PostAsJsonAsync( + "/api/auth/oauth/wechat-miniapp", + new OAuthCodeDto + { + Realm = AuthRealm.Tenant, + TenantCode = tenantId.ToString("N"), + Code = "wx-code" + }); + using var scope = factory.CreateSystemScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.DoesNotContain(dbContext.UserIdentities, item => item.Provider == "wechat_miniapp"); + Assert.DoesNotContain(dbContext.TenantMemberships, item => item.TenantId == tenantId); + Assert.DoesNotContain(dbContext.AuthSessions, item => item.TenantId == tenantId); + } + + [Theory] + [InlineData(MembershipStatus.Invited)] + [InlineData(MembershipStatus.Disabled)] + public async Task Wechat_login_does_not_reactivate_non_active_membership(MembershipStatus status) + { + await using var factory = new ApiTestFactory(new FakeWechatOAuthClient()); + var tenantId = Guid.NewGuid(); + var user = new User { Id = Guid.NewGuid(), Name = "Existing Wechat User" }; + await SeedWechatProviderAsync(factory, tenantId, + user, + new UserIdentity + { + UserId = user.Id, + Provider = "wechat_miniapp", + ProviderSubject = "wx-app-id:mini-open-id", + OpenId = "mini-open-id", + UnionId = "union-id" + }, + new TenantMembership + { + TenantId = tenantId, + UserId = user.Id, + Role = TenantRole.Student, + Status = status + }); + using var client = factory.CreateClient(); + client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); + + var response = await client.PostAsJsonAsync( + "/api/auth/oauth/wechat-miniapp", + new OAuthCodeDto + { + Realm = AuthRealm.Tenant, + TenantCode = tenantId.ToString("N"), + Code = "wx-code" + }); + using var scope = factory.CreateSystemScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Equal(status, dbContext.TenantMemberships.Single(item => + item.TenantId == tenantId && item.UserId == user.Id).Status); + Assert.DoesNotContain(dbContext.AuthSessions, item => + item.TenantId == tenantId && item.UserId == user.Id); + } + + private static async Task SeedWechatProviderAsync( + ApiTestFactory factory, + Guid tenantId, + params object[] additionalEntities) + { + const string secretRef = "tenant_secrets:identity:wechat_miniapp:default"; + var protectedSecret = ProtectTenantSecret( + tenantId, + secretRef, + JsonSerializer.SerializeToElement(new { appSecret = "wx-app-secret" })); + var entities = new List + { + new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Wechat Tenant" }, + new TenantExternalProvider + { + TenantId = tenantId, + Provider = "wechat_miniapp", + Capability = TenantExternalProviderCapability.Identity, + Status = TenantExternalProviderStatus.Active, + SecretRef = secretRef, + ConfigPublic = JsonSerializer.SerializeToElement(new { appId = "wx-app-id" }) + }, + new TenantSecret + { + TenantId = tenantId, + Purpose = "identity", + Provider = "wechat_miniapp", + SecretKey = "default", + SecretRef = secretRef, + Status = TenantSecretStatus.Active, + EncryptionKeyId = protectedSecret.KeyId, + EncryptedPayload = protectedSecret.Ciphertext, + EncryptionNonce = protectedSecret.Nonce, + EncryptionTag = protectedSecret.Tag + } + }; + entities.AddRange(additionalEntities); + await factory.SeedAsync(entities.ToArray()); + } + private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedLoginUserAsync( ApiTestFactory factory) { diff --git a/Tiku.IntegrationTests/Api/AuthorizationManifestTests.cs b/Tiku.IntegrationTests/Api/AuthorizationManifestTests.cs new file mode 100644 index 0000000..3d5f6d3 --- /dev/null +++ b/Tiku.IntegrationTests/Api/AuthorizationManifestTests.cs @@ -0,0 +1,78 @@ +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Tiku.Api.Security; + +namespace Tiku.IntegrationTests.Api; + +public sealed class AuthorizationManifestTests +{ + private const int ExpectedActionCount = 330; + private const string ExpectedSha256 = "ad09167662cb9dc25111f40902c5f16a6633465f0ca7e7da0e50cdc10cfb8bb5"; + + [Fact] + public void Controller_authorization_surface_matches_reviewed_manifest() + { + var descriptors = typeof(Tiku.Api.ApiProgramMarker).Assembly.GetTypes() + .Where(type => !type.IsAbstract && typeof(ControllerBase).IsAssignableFrom(type)) + .SelectMany(type => type.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly) + .Where(method => method.GetCustomAttributes().Any()) + .Select(method => Describe(type, method))) + .OrderBy(value => value, StringComparer.Ordinal) + .ToArray(); + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join('\n', descriptors)))) + .ToLowerInvariant(); + + Assert.True( + descriptors.Length == ExpectedActionCount && hash == ExpectedSha256, + $"Authorization manifest changed. count={descriptors.Length}, sha256={hash}"); + } + + [Fact] + public async Task Runtime_controller_endpoints_have_authorization_and_audit_metadata() + { + await using var factory = new ApiTestFactory(); + using var client = factory.CreateClient(); + _ = await client.GetAsync("/api/health"); + var endpoints = factory.Services.GetRequiredService().Endpoints + .Where(endpoint => endpoint.Metadata.GetMetadata() is not null) + .ToArray(); + + Assert.NotEmpty(endpoints); + foreach (var endpoint in endpoints) + { + var anonymous = endpoint.Metadata.GetMetadata() is not null; + var metadata = endpoint.Metadata.GetMetadata(); + if (anonymous) + { + Assert.Null(metadata); + continue; + } + + Assert.NotNull(metadata); + Assert.False(string.IsNullOrWhiteSpace(metadata.AuditAction)); + Assert.Contains(metadata.Realm, new[] { "authenticated", "tenant", "platform" }); + } + } + + private static string Describe(Type controller, MethodInfo action) + { + var controllerRoute = controller.GetCustomAttribute()?.Template ?? string.Empty; + var http = action.GetCustomAttributes().ToArray(); + var methods = string.Join(',', http.SelectMany(attribute => attribute.HttpMethods).Distinct().Order(StringComparer.Ordinal)); + var templates = string.Join(',', http.Select(attribute => attribute.Template ?? string.Empty).Distinct().Order(StringComparer.Ordinal)); + var policies = controller.GetCustomAttributes() + .Concat(action.GetCustomAttributes()) + .Select(attribute => attribute.Policy ?? "authenticated") + .Order(StringComparer.Ordinal); + var anonymous = controller.IsDefined(typeof(AllowAnonymousAttribute)) || + action.IsDefined(typeof(AllowAnonymousAttribute)); + return $"{methods}|{controllerRoute}/{templates}|{controller.Name}.{action.Name}|anonymous={anonymous}|policies={string.Join(',', policies)}"; + } +} diff --git a/Tiku.IntegrationTests/Api/BrowserAuthenticationTests.cs b/Tiku.IntegrationTests/Api/BrowserAuthenticationTests.cs new file mode 100644 index 0000000..1eb1154 --- /dev/null +++ b/Tiku.IntegrationTests/Api/BrowserAuthenticationTests.cs @@ -0,0 +1,183 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Tiku.Api.Contracts; +using Tiku.Api.Options; +using Tiku.Application.Auth; +using Tiku.Domain.Identity; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Auth; + +namespace Tiku.IntegrationTests.Api; + +public sealed class BrowserAuthenticationTests +{ + [Fact] + public async Task Password_login_uses_secure_cookies_and_does_not_return_tokens() + { + await using var factory = new ApiTestFactory(); + var tenantId = Guid.NewGuid(); + const string phone = "13890000000"; + var user = new User { Id = Guid.NewGuid(), Phone = phone, Name = "Browser User" }.WithTestPassword(); + await factory.SeedAsync( + new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Browser Tenant" }, + user, + new TenantMembership + { + TenantId = tenantId, + UserId = user.Id, + Role = TenantRole.Student, + Status = MembershipStatus.Active + }); + using var client = factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions + { + HandleCookies = false + }); + client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); + client.DefaultRequestHeaders.Add("Origin", "http://localhost"); + + var response = await client.PostAsJsonAsync("/api/browser-auth/login/password", new PasswordLoginDto + { + Realm = AuthRealm.Tenant, + TenantCode = tenantId.ToString("N"), + Identifier = phone, + Password = PasswordTestUserExtensions.TestPassword + }); + var body = await response.Content.ReadAsStringAsync(); + using var json = JsonDocument.Parse(body); + var cookies = response.Headers.GetValues("Set-Cookie").ToArray(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.DoesNotContain("accessToken", body, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("refreshToken", body, StringComparison.OrdinalIgnoreCase); + Assert.Equal("Authenticated", json.RootElement.GetProperty("status").GetString()); + Assert.Contains(cookies, value => value.StartsWith(BrowserAuthOptions.AccessCookie) && + value.Contains("secure", StringComparison.OrdinalIgnoreCase) && + value.Contains("httponly", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(cookies, value => value.StartsWith(BrowserAuthOptions.RefreshCookie) && + value.Contains("samesite=strict", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(cookies, value => value.StartsWith(BrowserAuthOptions.CsrfCookie)); + } + + [Fact] + public async Task Browser_refresh_requires_same_origin_and_matching_csrf_token() + { + await using var factory = new ApiTestFactory(); + var tenantId = Guid.NewGuid(); + const string phone = "13890000001"; + var user = new User { Id = Guid.NewGuid(), Phone = phone, Name = "CSRF User" }.WithTestPassword(); + await factory.SeedAsync( + new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "CSRF Tenant" }, + user, + new TenantMembership + { + TenantId = tenantId, + UserId = user.Id, + Role = TenantRole.Student, + Status = MembershipStatus.Active + }); + using var client = factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions + { + HandleCookies = false + }); + client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); + using var login = new HttpRequestMessage(HttpMethod.Post, "/api/browser-auth/login/password") + { + Content = JsonContent.Create(new PasswordLoginDto + { + Realm = AuthRealm.Tenant, + TenantCode = tenantId.ToString("N"), + Identifier = phone, + Password = PasswordTestUserExtensions.TestPassword + }) + }; + login.Headers.Add("Origin", "http://localhost"); + var loginResponse = await client.SendAsync(login); + var setCookies = loginResponse.Headers.GetValues("Set-Cookie").ToArray(); + var refresh = ReadCookie(setCookies, BrowserAuthOptions.RefreshCookie); + var csrf = ReadCookie(setCookies, BrowserAuthOptions.CsrfCookie); + var cookieHeader = $"{BrowserAuthOptions.RefreshCookie}={refresh}; {BrowserAuthOptions.CsrfCookie}={csrf}"; + + var missingCsrf = await SendRefreshAsync(client, cookieHeader, "http://localhost", null); + var wrongOrigin = await SendRefreshAsync(client, cookieHeader, "https://attacker.example", csrf); + var accepted = await SendRefreshAsync(client, cookieHeader, "http://localhost", csrf); + + Assert.Equal(HttpStatusCode.Forbidden, missingCsrf.StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, wrongOrigin.StatusCode); + Assert.Equal(HttpStatusCode.OK, accepted.StatusCode); + Assert.Contains(accepted.Headers.GetValues("Set-Cookie"), value => + value.StartsWith(BrowserAuthOptions.RefreshCookie, StringComparison.Ordinal)); + } + + [Fact] + public async Task Access_cookie_is_ignored_without_same_origin_evidence() + { + await using var factory = new ApiTestFactory(); + var tenantId = Guid.NewGuid(); + const string phone = "13890000002"; + var user = new User { Id = Guid.NewGuid(), Phone = phone, Name = "Cookie User" }.WithTestPassword(); + await factory.SeedAsync( + new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Cookie Tenant" }, + user, + new TenantMembership + { + TenantId = tenantId, + UserId = user.Id, + Role = TenantRole.Student, + Status = MembershipStatus.Active + }); + using var client = factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions + { + HandleCookies = false + }); + client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); + using var login = new HttpRequestMessage(HttpMethod.Post, "/api/browser-auth/login/password") + { + Content = JsonContent.Create(new PasswordLoginDto + { + Realm = AuthRealm.Tenant, + TenantCode = tenantId.ToString("N"), + Identifier = phone, + Password = PasswordTestUserExtensions.TestPassword + }) + }; + login.Headers.Add("Origin", "http://localhost"); + var loginResponse = await client.SendAsync(login); + var access = ReadCookie(loginResponse.Headers.GetValues("Set-Cookie").ToArray(), BrowserAuthOptions.AccessCookie); + + using var crossSite = new HttpRequestMessage(HttpMethod.Get, "/api/me"); + crossSite.Headers.Add("Cookie", $"{BrowserAuthOptions.AccessCookie}={access}"); + crossSite.Headers.Add("Sec-Fetch-Site", "cross-site"); + var rejected = await client.SendAsync(crossSite); + using var sameOrigin = new HttpRequestMessage(HttpMethod.Get, "/api/me"); + sameOrigin.Headers.Add("Cookie", $"{BrowserAuthOptions.AccessCookie}={access}"); + sameOrigin.Headers.Add("Origin", "http://localhost"); + var accepted = await client.SendAsync(sameOrigin); + + Assert.Equal(HttpStatusCode.Unauthorized, rejected.StatusCode); + Assert.Equal(HttpStatusCode.OK, accepted.StatusCode); + } + + private static async Task SendRefreshAsync( + HttpClient client, + string cookieHeader, + string origin, + string? csrf) + { + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/browser-auth/refresh"); + request.Headers.Add("Cookie", cookieHeader); + request.Headers.Add("Origin", origin); + if (csrf is not null) + { + request.Headers.Add(BrowserAuthOptions.CsrfHeader, csrf); + } + return await client.SendAsync(request); + } + + private static string ReadCookie(IEnumerable setCookies, string name) + { + var prefix = name + "="; + var header = setCookies.Single(value => value.StartsWith(prefix, StringComparison.Ordinal)); + return header[prefix.Length..header.IndexOf(';')]; + } +} diff --git a/Tiku.IntegrationTests/Api/CapabilityAuthorizationTests.cs b/Tiku.IntegrationTests/Api/CapabilityAuthorizationTests.cs new file mode 100644 index 0000000..7e9239a --- /dev/null +++ b/Tiku.IntegrationTests/Api/CapabilityAuthorizationTests.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.DependencyInjection; +using Tiku.Application.Security; +using Tiku.Domain.Commerce; +using Tiku.Domain.Platform; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.IntegrationTests.Api; + +public sealed class CapabilityAuthorizationTests +{ + [Fact] + public async Task Entitlement_is_database_backed_and_past_due_is_read_only() + { + await using var factory = new ApiTestFactory(); + var tenantId = Guid.NewGuid(); + await factory.SeedAsync( + new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Capability Tenant" }, + new PlatformSaasPlan { Code = "capability-test", Name = "Capability Test" }, + new ProductModule { Code = "content", Name = "Content" }, + new TenantSubscription + { + TenantId = tenantId, + PlanCode = "capability-test", + Status = TenantSubscriptionStatus.Active, + StartsAt = DateTimeOffset.UtcNow.AddDays(-1), + ExpiresAt = DateTimeOffset.UtcNow.AddDays(30) + }); + + using var scope = factory.CreateSystemScope("Verify capability authorization"); + var evaluator = scope.ServiceProvider.GetRequiredService(); + Assert.False(await evaluator.IsAllowedAsync(tenantId, "content", CapabilityOperation.Read)); + + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.PlanModuleEntitlements.Add(new PlanModuleEntitlement + { + PlanCode = "capability-test", + ModuleCode = "content" + }); + await dbContext.SaveChangesAsync(); + Assert.True(await evaluator.IsAllowedAsync(tenantId, "content", CapabilityOperation.Write)); + + var subscription = dbContext.TenantSubscriptions.Single(item => item.TenantId == tenantId); + subscription.Status = TenantSubscriptionStatus.PastDue; + await dbContext.SaveChangesAsync(); + Assert.True(await evaluator.IsAllowedAsync(tenantId, "content", CapabilityOperation.Read)); + Assert.False(await evaluator.IsAllowedAsync(tenantId, "content", CapabilityOperation.Write)); + } +} diff --git a/Tiku.IntegrationTests/Api/ProductionConfigurationTests.cs b/Tiku.IntegrationTests/Api/ProductionConfigurationTests.cs index a885430..50c8028 100644 --- a/Tiku.IntegrationTests/Api/ProductionConfigurationTests.cs +++ b/Tiku.IntegrationTests/Api/ProductionConfigurationTests.cs @@ -85,4 +85,24 @@ public sealed class ProductionConfigurationTests MasterKey = Convert.ToBase64String(new byte[32]) })); } + + [Fact] + public void Production_network_configuration_requires_formal_hosts_allowed_hosts_and_proxy() + { + var developmentDefaults = new TenantResolutionOptions(); + var wildcard = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["AllowedHosts"] = "*" }) + .Build(); + Assert.False(OptionsValidation.BeValidTenantResolutionOptions(developmentDefaults, wildcard, true)); + + var production = new TenantResolutionOptions + { + PlatformHosts = ["admin.example.com"], + TrustedProxyAddresses = ["10.0.0.10"] + }; + var explicitHosts = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["AllowedHosts"] = "admin.example.com;api.example.com" }) + .Build(); + Assert.True(OptionsValidation.BeValidTenantResolutionOptions(production, explicitHosts, true)); + } } diff --git a/Tiku.IntegrationTests/MassTransitOutboxTests.cs b/Tiku.IntegrationTests/MassTransitOutboxTests.cs new file mode 100644 index 0000000..5039738 --- /dev/null +++ b/Tiku.IntegrationTests/MassTransitOutboxTests.cs @@ -0,0 +1,217 @@ +using MassTransit; +using MassTransit.EntityFrameworkCoreIntegration; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using StackExchange.Redis; +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using Tiku.Application; +using Tiku.Contracts; +using Tiku.Infrastructure; +using Tiku.Infrastructure.Messaging; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.IntegrationTests; + +public sealed class MassTransitOutboxTests +{ + [Fact] + public async Task Worker_consumer_uses_inbox_and_updates_non_authoritative_redis_version() + { + var rabbitMqHost = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ"); + var redisConnection = Environment.GetEnvironmentVariable("TIKU_TEST_REDIS"); + if (string.IsNullOrWhiteSpace(rabbitMqHost) || string.IsNullOrWhiteSpace(redisConnection)) + { + return; + } + + await using var factory = CreateRabbitFactory(rabbitMqHost); + using var client = factory.CreateClient(); + Assert.True(await WaitForReadyAsync(client), "API dependencies did not become ready within 10 seconds."); + + var redisEnvironment = $"consumer-{Guid.NewGuid():N}"; + var workerBuilder = Host.CreateApplicationBuilder(); + workerBuilder.Services.AddApplication(); + workerBuilder.Services.AddInfrastructure(factory.DatabaseConnectionString); + workerBuilder.Services.AddRedisSecurity(redisConnection, redisEnvironment); + workerBuilder.Services.AddReliableMessaging(CreateRabbitOptions(rabbitMqHost, configureConsumers: true)); + using var worker = workerBuilder.Build(); + await worker.StartAsync(); + + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var messageId = Guid.NewGuid(); + const long version = 123456789; + using (var scope = factory.CreateSystemScope("Publish duplicate inbox test message")) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + var publishEndpoint = scope.ServiceProvider.GetRequiredService(); + await using var transaction = await dbContext.Database.BeginTransactionAsync(); + var message = new AuthorizationStateChangedV1( + Guid.NewGuid(), tenantId, userId, "consumer_test", version, + DateTimeOffset.UtcNow, Guid.NewGuid().ToString("N")); + await publishEndpoint.Publish(message, context => context.MessageId = messageId); + await publishEndpoint.Publish(message, context => context.MessageId = messageId); + await dbContext.SaveChangesAsync(); + await transaction.CommitAsync(); + } + + var redisKey = $"tiku:{redisEnvironment}:auth-inv:authorization:{tenantId:N}:{userId:N}"; + var consumed = false; + for (var attempt = 0; attempt < 60; attempt++) + { + var multiplexer = worker.Services.GetRequiredService(); + if (await multiplexer.GetDatabase().StringGetAsync(redisKey) == version) + { + consumed = true; + break; + } + await Task.Delay(250); + } + Assert.True(consumed, "Worker did not consume the security event within 15 seconds."); + + using (var verification = factory.CreateSystemScope("Verify duplicate consumer inbox")) + { + var dbContext = verification.ServiceProvider.GetRequiredService(); + Assert.Single(await dbContext.Set() + .Where(item => item.MessageId == messageId) + .ToArrayAsync()); + } + + var managementEndpoint = ResolveRabbitManagementEndpoint(rabbitMqHost); + if (managementEndpoint is not null) + { + Assert.Equal(0, await GetQueueMessageCountAsync( + managementEndpoint, "security-state-changed_error")); + } + + var redis = worker.Services.GetRequiredService(); + await redis.GetDatabase().KeyDeleteAsync(redisKey); + await worker.StopAsync(); + } + + [Fact] + public async Task RabbitMq_health_and_bus_outbox_follow_database_transaction() + { + var rabbitMqHost = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ"); + if (string.IsNullOrWhiteSpace(rabbitMqHost)) + { + return; + } + + await using var factory = CreateRabbitFactory(rabbitMqHost); + using var client = factory.CreateClient(); + Assert.True(await WaitForReadyAsync(client), "API dependencies did not become ready within 10 seconds."); + var ready = await client.GetAsync("/api/health/ready"); + using var readyJson = JsonDocument.Parse(await ready.Content.ReadAsStringAsync()); + + Assert.Equal(HttpStatusCode.OK, ready.StatusCode); + Assert.True(readyJson.RootElement.GetProperty("rabbitMq").GetProperty("configured").GetBoolean()); + Assert.True(readyJson.RootElement.GetProperty("rabbitMq").GetProperty("ready").GetBoolean()); + + using (var rollbackScope = factory.CreateSystemScope("Verify rolled back bus outbox")) + { + var dbContext = rollbackScope.ServiceProvider.GetRequiredService(); + var publisher = rollbackScope.ServiceProvider.GetRequiredService(); + await using var transaction = await dbContext.Database.BeginTransactionAsync(); + await publisher.AuthorizationChangedAsync( + null, null, "rollback_test", 1, Guid.NewGuid().ToString("N")); + await dbContext.SaveChangesAsync(); + await transaction.RollbackAsync(); + } + + using (var verification = factory.CreateSystemScope("Verify rolled back outbox is empty")) + { + var dbContext = verification.ServiceProvider.GetRequiredService(); + Assert.Empty(await dbContext.Set().ToArrayAsync()); + } + + using (var commitScope = factory.CreateSystemScope("Verify committed bus outbox")) + { + var dbContext = commitScope.ServiceProvider.GetRequiredService(); + var publisher = commitScope.ServiceProvider.GetRequiredService(); + await using var transaction = await dbContext.Database.BeginTransactionAsync(); + await publisher.AuthorizationChangedAsync( + null, null, "commit_test", 2, Guid.NewGuid().ToString("N")); + await dbContext.SaveChangesAsync(); + await transaction.CommitAsync(); + } + + var drained = false; + for (var attempt = 0; attempt < 40; attempt++) + { + using var verification = factory.CreateSystemScope("Wait for committed outbox delivery"); + var dbContext = verification.ServiceProvider.GetRequiredService(); + if (!await dbContext.Set().AnyAsync()) + { + drained = true; + break; + } + await Task.Delay(250); + } + if (!drained) + { + using var diagnostics = factory.CreateSystemScope("Inspect undelivered outbox"); + var dbContext = diagnostics.ServiceProvider.GetRequiredService(); + var messages = await dbContext.Set().CountAsync(); + var states = await dbContext.Set().CountAsync(); + Assert.Fail($"Committed MassTransit outbox was not delivered within 10 seconds. messages={messages}, states={states}"); + } + } + + private static Api.ApiTestFactory CreateRabbitFactory(string rabbitMqHost) => + new(configurationOverrides: new Dictionary + { + ["RabbitMq:Host"] = rabbitMqHost, + ["RabbitMq:Username"] = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_USERNAME") ?? "guest", + ["RabbitMq:Password"] = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_PASSWORD") ?? "guest" + }); + + private static MessagingOptions CreateRabbitOptions(string rabbitMqHost, bool configureConsumers) => new() + { + Host = rabbitMqHost, + Username = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_USERNAME") ?? "guest", + Password = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_PASSWORD") ?? "guest", + ConfigureConsumers = configureConsumers + }; + + private static async Task WaitForReadyAsync(HttpClient client) + { + for (var attempt = 0; attempt < 40; attempt++) + { + if ((await client.GetAsync("/api/health/ready")).StatusCode == HttpStatusCode.OK) + { + return true; + } + await Task.Delay(250); + } + return false; + } + + private static Uri? ResolveRabbitManagementEndpoint(string rabbitMqHost) + { + var configured = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_MANAGEMENT"); + if (!string.IsNullOrWhiteSpace(configured)) + { + return new Uri(configured); + } + + var broker = new Uri(rabbitMqHost); + return broker.IsLoopback ? new Uri($"http://{broker.Host}:15672") : null; + } + + private static async Task GetQueueMessageCountAsync(Uri managementEndpoint, string queueName) + { + using var client = new HttpClient { BaseAddress = managementEndpoint }; + var username = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_USERNAME") ?? "guest"; + var password = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_PASSWORD") ?? "guest"; + var credentials = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"{username}:{password}")); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials); + using var response = await client.GetAsync($"/api/queues/%2F/{Uri.EscapeDataString(queueName)}"); + response.EnsureSuccessStatusCode(); + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return document.RootElement.GetProperty("messages").GetInt32(); + } +} diff --git a/Tiku.IntegrationTests/RedisSecurityStoreTests.cs b/Tiku.IntegrationTests/RedisSecurityStoreTests.cs new file mode 100644 index 0000000..e213219 --- /dev/null +++ b/Tiku.IntegrationTests/RedisSecurityStoreTests.cs @@ -0,0 +1,81 @@ +using Microsoft.Extensions.DependencyInjection; +using StackExchange.Redis; +using System.Security.Cryptography; +using System.Text; +using Tiku.Application.Security; +using Tiku.Infrastructure; +using Tiku.Infrastructure.Security; + +namespace Tiku.IntegrationTests; + +public sealed class RedisSecurityStoreTests +{ + [Fact] + public async Task Unavailable_redis_fails_closed_for_security_operations() + { + await using var provider = BuildProvider( + "localhost:6399,connectTimeout=200,syncTimeout=200,asyncTimeout=200,abortConnect=false", + $"integration-unavailable-{Guid.NewGuid():N}"); + var store = provider.GetRequiredService(); + + await Assert.ThrowsAsync(() => store.ConsumeAsync( + [ + new DistributedRateLimitBucket("password:ip:test", 1, TimeSpan.FromSeconds(1)) + ])); + Assert.False(await store.PingAsync()); + } + + [Fact] + public async Task Two_instances_share_atomic_limit_and_keys_contain_no_plaintext_identifier() + { + var connectionString = Environment.GetEnvironmentVariable("TIKU_TEST_REDIS"); + if (string.IsNullOrWhiteSpace(connectionString)) + { + return; + } + + var environment = $"integration-{Guid.NewGuid():N}"; + await using var first = BuildProvider(connectionString, environment); + await using var second = BuildProvider(connectionString, environment); + var store1 = first.GetRequiredService(); + var store2 = second.GetRequiredService(); + const string phone = "13812345678"; + var phoneHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(phone))).ToLowerInvariant(); + var bucket = new DistributedRateLimitBucket( + $"sms-verify:tenant-id:login:{phoneHash}", 1, TimeSpan.FromMinutes(1)); + + try + { + var attempts = await Task.WhenAll(Enumerable.Range(0, 12).Select(index => + (index & 1) == 0 + ? store1.ConsumeAsync([bucket]) + : store2.ConsumeAsync([bucket]))); + + Assert.Single(attempts, result => result.Allowed); + Assert.All(attempts.Where(result => !result.Allowed), result => Assert.NotNull(result.RetryAfter)); + var multiplexer = first.GetRequiredService(); + var server = multiplexer.GetServer(multiplexer.GetEndPoints().Single()); + var keys = server.Keys(pattern: $"tiku:{environment}:*").Select(key => key.ToString()).ToArray(); + Assert.NotEmpty(keys); + Assert.DoesNotContain(keys, key => key.Contains(phone, StringComparison.Ordinal)); + } + finally + { + var multiplexer = first.GetRequiredService(); + var server = multiplexer.GetServer(multiplexer.GetEndPoints().Single()); + var keys = server.Keys(pattern: $"tiku:{environment}:*").ToArray(); + if (keys.Length > 0) + { + await multiplexer.GetDatabase().KeyDeleteAsync(keys); + } + } + } + + private static ServiceProvider BuildProvider(string connectionString, string environment) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddRedisSecurity(connectionString, environment); + return services.BuildServiceProvider(); + } +} diff --git a/Tiku.IntegrationTests/SystemScopeAuditTests.cs b/Tiku.IntegrationTests/SystemScopeAuditTests.cs new file mode 100644 index 0000000..0872773 --- /dev/null +++ b/Tiku.IntegrationTests/SystemScopeAuditTests.cs @@ -0,0 +1,75 @@ +using Microsoft.Extensions.DependencyInjection; +using Tiku.Application.Security; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.IntegrationTests; + +public sealed class SystemScopeAuditTests +{ + [Fact] + public async Task Descriptor_is_required_and_success_is_audited() + { + await using var factory = new Api.ApiTestFactory(); + using var outer = factory.CreateSystemScope("Resolve execution scope"); + var executionScope = outer.ServiceProvider.GetRequiredService(); + await Assert.ThrowsAsync(() => executionScope.ExecuteAsync( + new SystemScopeRequest(null, SystemScopeCallerType.Worker, "worker", "", "job-1"), + (_, _) => Task.CompletedTask)); + + var correlationId = Guid.NewGuid().ToString("N"); + await executionScope.ExecuteAsync( + new SystemScopeRequest(null, SystemScopeCallerType.Worker, "background-worker", "test audit", correlationId), + (_, _) => Task.CompletedTask); + + using var verification = factory.CreateSystemScope("Verify execution scope audit"); + var dbContext = verification.ServiceProvider.GetRequiredService(); + Assert.Contains(dbContext.AuditLogs, item => item.Action == "system_scope.entered" && item.TargetId == correlationId); + Assert.Contains(dbContext.AuditLogs, item => item.Action == "system_scope.completed" && item.TargetId == correlationId); + } + + [Fact] + public async Task Failed_system_scope_rolls_back_business_write_and_persists_failure_audit() + { + await using var factory = new Api.ApiTestFactory(); + using var outer = factory.CreateSystemScope("Resolve execution scope"); + var executionScope = outer.ServiceProvider.GetRequiredService(); + var correlationId = Guid.NewGuid().ToString("N"); + var tenantId = Guid.NewGuid(); + await factory.SeedAsync(new Tenant + { + Id = tenantId, + Slug = tenantId.ToString("N"), + Name = "Existing Tenant" + }); + + await Assert.ThrowsAsync(() => executionScope.ExecuteAsync( + new SystemScopeRequest( + tenantId, + SystemScopeCallerType.Worker, + "background-worker", + "verify transactional rollback", + correlationId), + async (provider, cancellationToken) => + { + var dbContext = provider.GetRequiredService(); + dbContext.TenantBrandings.Add(new TenantBranding + { + TenantId = tenantId, + BrandName = "Must Roll Back" + }); + await dbContext.SaveChangesAsync(cancellationToken); + throw new InvalidOperationException("expected failure"); + })); + + using var verification = factory.CreateSystemScope("Verify failed execution scope audit"); + var verificationDb = verification.ServiceProvider.GetRequiredService(); + Assert.DoesNotContain(verificationDb.TenantBrandings, item => item.TenantId == tenantId); + Assert.Contains(verificationDb.AuditLogs, item => + item.Action == "system_scope.entered" && item.TargetId == correlationId); + Assert.Contains(verificationDb.AuditLogs, item => + item.Action == "system_scope.failed" && item.TargetId == correlationId); + Assert.DoesNotContain(verificationDb.AuditLogs, item => + item.Action == "system_scope.completed" && item.TargetId == correlationId); + } +} diff --git a/Tiku.Worker/Program.cs b/Tiku.Worker/Program.cs index 6af43d2..fc1e9cc 100644 --- a/Tiku.Worker/Program.cs +++ b/Tiku.Worker/Program.cs @@ -2,6 +2,8 @@ using Tiku.Application; using Tiku.Application.Tenancy; using Tiku.Infrastructure; using Tiku.Worker; +using Tiku.Infrastructure.Messaging; +using Tiku.Infrastructure.Security; var builder = Host.CreateApplicationBuilder(args); var connectionString = builder.Configuration.GetConnectionString("Database") @@ -10,6 +12,41 @@ var connectionString = builder.Configuration.GetConnectionString("Database") "Database connection is required. Configure ConnectionStrings:Database or DATABASE_URL."); builder.Services.AddApplication(); builder.Services.AddInfrastructure(connectionString); +var redisConnectionString = builder.Configuration.GetConnectionString("Redis") ?? builder.Configuration["REDIS_URL"]; +builder.Services.AddOptions() + .Configure(options => options.ConnectionString = redisConnectionString ?? string.Empty) + .Validate( + options => !builder.Environment.IsProduction() || !string.IsNullOrWhiteSpace(options.ConnectionString), + "Redis is required in Production.") + .ValidateOnStart(); +if (!string.IsNullOrWhiteSpace(redisConnectionString)) +{ + builder.Services.AddRedisSecurity(redisConnectionString, builder.Environment.EnvironmentName); +} +else if (builder.Environment.IsProduction()) +{ + throw new InvalidOperationException( + "Redis is required in Production. Configure ConnectionStrings:Redis or REDIS_URL."); +} +var messaging = builder.Configuration.GetSection("RabbitMq").Get() ?? new MessagingOptions(); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection("RabbitMq")) + .Validate( + options => !builder.Environment.IsProduction() || + (options.IsConfigured && + !string.IsNullOrWhiteSpace(options.Username) && + !string.IsNullOrWhiteSpace(options.Password)), + "Production RabbitMQ requires a valid Host, Username and Password.") + .ValidateOnStart(); +if (messaging.IsConfigured) +{ + messaging.ConfigureConsumers = true; + builder.Services.AddReliableMessaging(messaging); +} +else if (builder.Environment.IsProduction()) +{ + throw new InvalidOperationException("RabbitMQ is required in Production. Configure RabbitMq:Host."); +} builder.Services.AddOptions() .Bind(builder.Configuration.GetSection("TenantDomains")); builder.Services.AddHostedService(); diff --git a/Tiku.Worker/appsettings.json b/Tiku.Worker/appsettings.json index 1cbdfef..035d2a3 100644 --- a/Tiku.Worker/appsettings.json +++ b/Tiku.Worker/appsettings.json @@ -1,4 +1,10 @@ { + "RabbitMq": { + "Host": "", + "VirtualHost": "/", + "Username": "", + "Password": "" + }, "TenantDomains": { "Enabled": true, "PollSeconds": 60, diff --git a/docs/architecture/authentication-authorization-hardening-plan.md b/docs/architecture/authentication-authorization-hardening-plan.md index 457f6a3..e5c9698 100644 --- a/docs/architecture/authentication-authorization-hardening-plan.md +++ b/docs/architecture/authentication-authorization-hardening-plan.md @@ -1,5 +1,7 @@ # 认证与授权待补强清单 +> 2026-07-29 实施状态:可信代理启动校验、外部登录成员生命周期、Redis 跨实例频控与故障关闭、数据库 Capability、事务化 System Scope 审计、MassTransit EF Bus/Consumer Outbox、浏览器 Cookie/CSRF 主链路及 endpoint manifest 已落地。生产网关 ACL、RabbitMQ 4.x 重启/积压演练和按 job type 迁移旧轮询 Worker 仍属于部署验收项。 + 当前生效规则见 [认证、授权与 Host 安全策略](authentication-authorization-security.md)。本文只记录尚需补强的安全事项,不重复描述已实现体系。 ## P0:可信代理与 Host fail-closed @@ -10,6 +12,8 @@ - 受信代理只接受一跳转发,网关必须覆盖客户端伪造的 Forwarded Headers。 - API 公网入口必须只能由受信网关访问。 +实现说明:应用已强制 `ForwardLimit=1`,Production 缺少正式 Host、显式 `AllowedHosts` 或可信代理地址时启动失败;公网 ACL 和网关覆盖转发头由部署层落实。 + 验收: - Host A + Tenant B token 返回 403。 @@ -23,6 +27,8 @@ - 首次外部登录是否允许创建学生成员,必须由租户自注册策略控制。 - 成员恢复只能由管理员显式操作并写审计。 +实现说明:`TenantAuthPolicy.AllowExternalStudentSelfRegistration` 控制首次外部登录;Disabled/Invited 不会被登录激活,管理员成员变更会同步撤销 Session、写审计并发布生命周期事件。 + 验收: - Disabled 成员旧 access/refresh 立即失效。 @@ -43,6 +49,8 @@ Tenant Active + DataScope / Resource Scope ``` +实现说明:`ProductModule`、`PlanModuleEntitlement`、`TenantModuleOverride` 与 `ICapabilityAccessEvaluator` 已进入数据库授权 Handler。模块目录采用渐进启用:只有进入固定目录的模块才强制套餐校验,避免迁移时误封未建档模块。 + 验收: - 有 permission 但套餐不含模块,返回 403。 @@ -59,6 +67,8 @@ Tenant Active - All-only 资源必须显式声明。 - 新增 Controller action 未进入 manifest 时测试失败。 +实现说明:`AuthorizationManifestTests` 对全部 Controller HTTP Action 的 method、route、匿名标记和 policy 生成稳定摘要;MVC convention 同时为全部非匿名 Controller endpoint 生成 realm、module、permission、operation、All-only 与 audit action 运行时元数据,变更会触发测试失败并要求安全评审。 + 验收: - 列表、详情、创建、更新、删除、批量、导出和 Worker job 使用一致 DataScope。 @@ -70,6 +80,8 @@ Tenant Active - 平台操作、Worker、迁移验证和受审计公共题库服务才允许使用 System Scope。 - 跨租户写操作必须落 `AuditLog`。 +实现说明:`SystemScopeRequest` 强制 caller、reason、target tenant 和 correlation ID;旧参数签名已移除。成功路径的 entered 审计、跨租户业务写入和 completed 审计处于同一 PostgreSQL 事务,异常路径回滚业务并持久化 entered/failed 审计。 + 验收: - 未声明 reason 的 System Scope 创建失败。 @@ -82,3 +94,5 @@ Tenant Active - Access token 继续短期有效,不把角色和权限写入 JWT。 - 登录审计和错误响应避免泄露手机号、openId、邮箱完整值。 - 出现第三方生态登录、开放 API 或多客户端授权需求时,再评估 OpenIddict / OIDC,不继续扩展私有协议。 + +实现说明:浏览器使用 `/api/browser-auth` + Secure/HttpOnly Cookie + Origin/CSRF 校验;原 `/api/auth` Bearer 契约继续供小程序、原生和服务调用。 diff --git a/docs/architecture/authentication-authorization-security.md b/docs/architecture/authentication-authorization-security.md index 2038793..3b26302 100644 --- a/docs/architecture/authentication-authorization-security.md +++ b/docs/architecture/authentication-authorization-security.md @@ -12,6 +12,7 @@ - Host、JWT scope、tenant claim、数据库 Session 和请求租户上下文必须一致。 - JWT 只证明已认证会话,不承载可直接授权的角色或权限。 - 后台权限每次从数据库角色绑定解析;菜单只控制 UI 展示。 +- 租户后台能力同时要求 Active tenant、有效订阅、模块权益和 operation permission;Capability 仍以 PostgreSQL 为准。 - 数据权限必须进入 SQL;无法可靠映射 owner、region 或 class 的资源采用 All-only fail-closed。 - 用户、成员、租户、后台角色、权限、SecurityStamp 或 Session 任一失效,旧 token 不能继续取得能力。 - Controller 默认要求认证;公开接口必须显式 `[AllowAnonymous]`。 @@ -54,6 +55,8 @@ v2.{t|p}.{tenantId|-}.{sessionId}.{64-byte-random-secret} - 已轮换 token 被复用时视为重放,撤销整个 token family 并写审计。 - logout 撤销当前 refresh token family;logout-all 更新 SecurityStamp 并撤销用户全部 Session。 +浏览器入口使用 `/api/browser-auth/*`:access/refresh token 仅写入 Secure、HttpOnly Cookie,响应体不返回 token;不安全方法必须通过同源 Origin 与双提交 CSRF 校验。`/api/auth/*` Bearer 契约继续供小程序、原生客户端和服务调用。 + ## Host 与 tenant 解析 Host 是认证上下文,不是普通参数。`TenantResolutionMiddleware` 在 Authentication 前执行。 @@ -83,6 +86,8 @@ Host 是认证上下文,不是普通参数。`TenantResolutionMiddleware` 在 - 平台角色不带租户键,不能自动读取租户业务数据。 - 菜单只决定 UI bootstrap 展示,不作为 API 授权依据。 - 后台 API 必须声明明确 permission;高风险写操作必须记录审计。 +- UI bootstrap 只返回“有效 permission 推导菜单”与有效 Capability 的交集;租户不能绑定当前无权使用的模块权限。 +- Trial/Active 且在有效期内可写;PastDue/Cancelled/Expired 仅允许已有权益模块的历史读取。 DataScope: @@ -97,6 +102,15 @@ DataScope: - `ISmsProvider` 只负责发送。 - 发送失败必须记录失败状态,不能留下可验证验证码。 - 登录、绑定、找回密码等场景使用独立 purpose 和频控键。 +- Redis Lua 同时执行跨实例 IP、账号、租户、手机号和 purpose 窗口计数;key 只使用 GUID 或不可逆哈希。 +- Redis 不可用时密码尝试、短信发送和短信校验失败关闭;普通授权请求仍直接查询 PostgreSQL。 + +## 可靠安全事件 + +- `Tiku.Contracts` 只包含版本化 DTO,不引用 EF、HTTP 或 Provider SDK。 +- API 使用 MassTransit EF Bus Outbox,Worker consumer 使用 EF inbox/outbox;业务变更、审计和消息由同一 DbContext 提交。 +- RabbitMQ 消息只负责非权威失效版本、菜单刷新和下游通知;成员、租户、Session 或套餐失效不等待 consumer。 +- System Scope 只能通过完整 `SystemScopeRequest` 创建;成功路径将 entered 审计、跨租户业务写入和 completed 审计放入同一 PostgreSQL 事务。 ## 审计与错误 @@ -121,6 +135,9 @@ DataScope: - JWT issuer、audience、当前 `KeyId`、RSA 私钥和旧公钥集合。 - Data Protection 证书。 - CORS 明确 Origin。 +- Redis 7.2+ 连接串;Production 缺失时拒绝启动。 +- RabbitMQ 4.x Host、virtual host 与凭据;Production 缺失时拒绝启动。 +- 公网只暴露覆盖 Forwarded Headers 的可信网关,API ACL 只允许该网关访问。 - Secret encryption key。 - 短信、对象存储、支付、通知和 AI provider 只通过租户 Provider 配置读取密钥。 diff --git a/docs/architecture/endpoint-authorization-manifest.md b/docs/architecture/endpoint-authorization-manifest.md new file mode 100644 index 0000000..31b85c4 --- /dev/null +++ b/docs/architecture/endpoint-authorization-manifest.md @@ -0,0 +1,9 @@ +# Endpoint authorization manifest + +Controller 授权面由 `AuthorizationManifestTests` 按 HTTP method、route、controller/action、匿名标记和 policy 生成稳定摘要。 +`EndpointAuthorizationMetadataConvention` 为全部非匿名 Controller endpoint 生成 realm、module、permission、CapabilityOperation、All-only DataScope 与 audit action 元数据,测试从运行时 `EndpointDataSource` 验证覆盖。新增、删除或修改 Action 时摘要测试必须失败,评审者确认元数据后才能更新 count/hash。 + +该清单是防止接口绕过评审的变更门禁;实际授权事实仍来自 PostgreSQL permission、Capability 和 DataScope,不能用摘要替代运行时校验。 + +- Action 数量:330 +- SHA-256:`ad09167662cb9dc25111f40902c5f16a6633465f0ca7e7da0e50cdc10cfb8bb5` diff --git a/docs/quickstart.md b/docs/quickstart.md index f58a299..9b08332 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -1,5 +1,18 @@ # 本地开发快速开始 +## 可选分布式依赖 + +本地单实例开发可以不配置 Redis/RabbitMQ,认证频控仍保留 PostgreSQL/进程内防线;Production 两者均为启动必填项。 + +```bash +export ConnectionStrings__Redis='localhost:6379,abortConnect=false' +export RabbitMq__Host='rabbitmq://localhost' +export RabbitMq__Username='guest' +export RabbitMq__Password='guest' +``` + +RabbitMQ 使用 MassTransit 8.5.10 和 PostgreSQL EF Bus/Consumer Outbox。`GET /api/health` 是 liveness,`GET /api/health/ready` 检查 PostgreSQL、已配置的 Redis、RabbitMQ bus health 和 outbox backlog;服务健康不等于认证授权验收完成。 + 这份文档用于从全新开发环境启动 TIKU Backend、初始化 PostgreSQL,并完成平台管理员的首次登录。 ## 1. 准备环境