From 99e4e43122c8261ee53d0977df823324258cbeaa Mon Sep 17 00:00:00 2001 From: xiong Date: Tue, 28 Jul 2026 14:31:43 +0800 Subject: [PATCH] feat: complete phase six backoffice operations --- Tiku.Api/Contracts/PlatformAdminDtos.cs | 263 + Tiku.Api/Contracts/TenantAdminDirectDtos.cs | 107 + Tiku.Api/Contracts/TenantCommerceDtos.cs | 105 + Tiku.Api/Controllers/CommerceController.cs | 32 + .../Controllers/PlatformAdminController.cs | 266 + .../TenantAdminDirectController.cs | 120 + .../Controllers/TenantCommerceController.cs | 177 + .../Middleware/ExceptionHandlingMiddleware.cs | 22 + .../Commerce/CommerceAdminModels.cs | 144 + .../PlatformAdmin/PlatformAdminModels.cs | 262 + .../Security/BackendPermissions.cs | 4 +- .../TenantAdmin/ITenantAdminDirectService.cs | 54 + .../TenantAdmin/TenantAdminDirectModels.cs | 117 + Tiku.Domain/Commerce/CommerceEntities.cs | 36 + .../Commerce/CommerceAdminService.cs | 620 +- Tiku.Infrastructure/DependencyInjection.cs | 3 + .../Jobs/BackgroundJobService.cs | 203 +- .../Configurations/CommerceConfigurations.cs | 67 + .../TenantOperationsConfigurations.cs | 1 - .../20260728031410_InitialSchema.Designer.cs | 5 +- .../20260728031410_InitialSchema.cs | 7 +- ..._AddCommerceAdjustmentVouchers.Designer.cs | 17959 ++++++++++++++++ ...728062148_AddCommerceAdjustmentVouchers.cs | 213 + .../Migrations/TikuDbContextModelSnapshot.cs | 309 +- .../Persistence/TikuDbContext.cs | 2 + .../PlatformAdmin/PlatformAdminService.cs | 931 + .../TenantAdmin/TenantAdminDirectService.cs | 576 + .../Api/AuthenticationTestClientExtensions.cs | 79 + .../Api/PlatformAdminEndpointTests.cs | 310 + .../Api/TenantAdminDirectEndpointTests.cs | 182 + .../Api/TenantCommerceEndpointTests.cs | 354 +- 31 files changed, 23504 insertions(+), 26 deletions(-) create mode 100644 Tiku.Api/Contracts/PlatformAdminDtos.cs create mode 100644 Tiku.Api/Controllers/PlatformAdminController.cs create mode 100644 Tiku.Application/PlatformAdmin/PlatformAdminModels.cs create mode 100644 Tiku.Infrastructure/Persistence/Migrations/20260728062148_AddCommerceAdjustmentVouchers.Designer.cs create mode 100644 Tiku.Infrastructure/Persistence/Migrations/20260728062148_AddCommerceAdjustmentVouchers.cs create mode 100644 Tiku.Infrastructure/PlatformAdmin/PlatformAdminService.cs create mode 100644 Tiku.IntegrationTests/Api/PlatformAdminEndpointTests.cs diff --git a/Tiku.Api/Contracts/PlatformAdminDtos.cs b/Tiku.Api/Contracts/PlatformAdminDtos.cs new file mode 100644 index 0000000..83cf8af --- /dev/null +++ b/Tiku.Api/Contracts/PlatformAdminDtos.cs @@ -0,0 +1,263 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using Tiku.Application.PlatformAdmin; +using Tiku.Domain.Common; +using Tiku.Domain.Commerce; +using Tiku.Domain.Identity; +using Tiku.Domain.Platform; +using Tiku.Domain.Tenancy; + +namespace Tiku.Api.Contracts; + +public sealed class PlatformAdminQueryDto +{ + [StringLength(32)] + public string? Status { get; set; } + + [StringLength(200)] + public string? Search { get; set; } + + [Range(1, 200)] + public int? Limit { get; set; } + + public PlatformAdminQuery ToQuery() => new(Status, Search, Limit); +} + +public sealed class CreatePlatformTenantDto +{ + [Required] + [StringLength(100)] + public string Slug { get; set; } = string.Empty; + + [Required] + [StringLength(200)] + public string Name { get; set; } = string.Empty; + + [StringLength(300)] + public string? LegalName { get; set; } + + public TenantStatus Status { get; set; } = TenantStatus.Active; + public BillingStatus BillingStatus { get; set; } = BillingStatus.Trial; + public JsonElement Metadata { get; set; } = JsonDefaults.Object(); + + public CreatePlatformTenantCommand ToCommand() => new(Slug, Name, LegalName, Status, BillingStatus, Metadata); +} + +public sealed class UpdatePlatformTenantStatusDto +{ + [Required] + public Guid TenantId { get; set; } + + public TenantStatus Status { get; set; } = TenantStatus.Active; + public BillingStatus BillingStatus { get; set; } = BillingStatus.Active; + + [StringLength(1000)] + public string? Reason { get; set; } + + public UpdatePlatformTenantStatusCommand ToCommand() => new(TenantId, Status, BillingStatus, Reason); +} + +public sealed class UpsertPlatformTenantBillingProfileDto +{ + [Required] + public Guid TenantId { get; set; } + + [StringLength(300)] + public string? BillingName { get; set; } + + [StringLength(100)] + public string? TaxId { get; set; } + + [StringLength(100)] + public string? ContactName { get; set; } + + [StringLength(32)] + public string? ContactPhone { get; set; } + + [StringLength(320)] + public string? ContactEmail { get; set; } + + [StringLength(1000)] + public string? BillingAddress { get; set; } + + [StringLength(300)] + public string? InvoiceTitle { get; set; } + + public TenantInvoiceTitleType? InvoiceType { get; set; } + + [StringLength(300)] + public string? BankName { get; set; } + + [StringLength(100)] + public string? BankAccountMasked { get; set; } + + public JsonElement Metadata { get; set; } = JsonDefaults.Object(); + + public UpsertPlatformTenantBillingProfileCommand ToCommand() => new( + TenantId, + BillingName, + TaxId, + ContactName, + ContactPhone, + ContactEmail, + BillingAddress, + InvoiceTitle, + InvoiceType, + BankName, + BankAccountMasked, + Metadata); +} + +public sealed class UpsertPlatformSubscriptionDto +{ + [Required] + public Guid TenantId { get; set; } + + [Required] + [StringLength(100)] + public string PlanCode { get; set; } = string.Empty; + + public TenantSubscriptionStatus Status { get; set; } = TenantSubscriptionStatus.Active; + public DateTimeOffset? StartsAt { get; set; } + public DateTimeOffset? ExpiresAt { get; set; } + + [StringLength(32)] + public string? BillingCycle { get; set; } + + [Range(0, int.MaxValue)] + public int AmountCents { get; set; } + + public JsonElement Metadata { get; set; } = JsonDefaults.Object(); + + public UpsertPlatformSubscriptionCommand ToCommand() => new( + TenantId, + PlanCode, + Status, + StartsAt, + ExpiresAt, + BillingCycle, + AmountCents, + Metadata); +} + +public sealed class UpsertPlatformStaffDto +{ + public Guid? UserId { get; set; } + + [StringLength(320)] + public string? Email { get; set; } + + [StringLength(32)] + public string? Phone { get; set; } + + [StringLength(200)] + public string? Name { get; set; } + + public UserStatus Status { get; set; } = UserStatus.Active; + public IReadOnlyCollection RoleIds { get; set; } = []; + + public UpsertPlatformStaffCommand ToCommand() => new(UserId, Email, Phone, Name, Status, RoleIds); +} + +public sealed class UpdatePlatformStaffStatusDto +{ + [Required] + public Guid UserId { get; set; } + + public UserStatus Status { get; set; } = UserStatus.Active; + + [StringLength(1000)] + public string? Reason { get; set; } + + public UpdatePlatformStaffStatusCommand ToCommand() => new(UserId, Status, Reason); +} + +public sealed class UpdatePlatformAuditAlertStatusDto +{ + [Required] + public Guid AlertId { get; set; } + + public PlatformAuditAlertStatus Status { get; set; } = PlatformAuditAlertStatus.Acknowledged; + + [StringLength(1000)] + public string? ResolutionNote { get; set; } + + public UpdatePlatformAuditAlertStatusCommand ToCommand() => new(AlertId, Status, ResolutionNote); +} + +public sealed class UpsertPlatformDunningChannelDto +{ + public Guid? ChannelId { get; set; } + + [Required] + [StringLength(100)] + public string ChannelCode { get; set; } = string.Empty; + + [Required] + [StringLength(200)] + public string Name { get; set; } = string.Empty; + + [StringLength(1000)] + public string? Description { get; set; } + + public bool Enabled { get; set; } = true; + public PlatformDunningProvider Provider { get; set; } = PlatformDunningProvider.Generic; + + [Required] + [StringLength(2048)] + public string WebhookUrl { get; set; } = string.Empty; + + [StringLength(300)] + public string? SecretRef { get; set; } + + public IReadOnlyCollection ReminderTypes { get; set; } = ["overdue", "final_notice"]; + public IReadOnlyCollection ReminderChannels { get; set; } = ["internal"]; + + [Range(1, 20)] + public int MinReminderLevel { get; set; } = 1; + + public IReadOnlyCollection TenantIds { get; set; } = []; + + [Range(1, 60)] + public int TimeoutSeconds { get; set; } = 10; + + public JsonElement Metadata { get; set; } = JsonDefaults.Object(); + + public UpsertPlatformDunningChannelCommand ToCommand() => new( + ChannelId, + ChannelCode, + Name, + Description, + Enabled, + Provider, + WebhookUrl, + SecretRef, + ReminderTypes, + ReminderChannels, + MinReminderLevel, + TenantIds, + TimeoutSeconds, + Metadata); +} + +public sealed class DisablePlatformDunningChannelDto +{ + [Required] + public Guid ChannelId { get; set; } + + [StringLength(1000)] + public string? Reason { get; set; } + + public DisablePlatformDunningChannelCommand ToCommand() => new(ChannelId, Reason); +} + +public sealed class RetryPlatformDunningEventDto +{ + [Required] + public Guid EventId { get; set; } + + [StringLength(1000)] + public string? Reason { get; set; } + + public RetryPlatformDunningEventCommand ToCommand() => new(EventId, Reason); +} diff --git a/Tiku.Api/Contracts/TenantAdminDirectDtos.cs b/Tiku.Api/Contracts/TenantAdminDirectDtos.cs index df808f3..8644d63 100644 --- a/Tiku.Api/Contracts/TenantAdminDirectDtos.cs +++ b/Tiku.Api/Contracts/TenantAdminDirectDtos.cs @@ -252,6 +252,113 @@ public sealed class UpdateTenantAdminStudentStatusDto } } +public sealed class TenantAdminStudentImportRowDto +{ + public TenantAdminUserLookupDto User { get; set; } = new(); + public Guid? RegionId { get; set; } + public Guid? ClassId { get; set; } + public string? AvatarPreset { get; set; } + public JsonElement RawProfile { get; set; } = JsonDefaults.Object(); + + public TenantAdminStudentImportRowCommand ToCommand() + { + return new TenantAdminStudentImportRowCommand(User.ToCommand(), RegionId, ClassId, AvatarPreset, RawProfile); + } +} + +public sealed class TenantAdminStudentImportDto +{ + [Required] + public IReadOnlyCollection Rows { get; set; } = []; + + public TenantAdminStudentImportCommand ToCommand() + { + return new TenantAdminStudentImportCommand(Rows.Select(row => row.ToCommand()).ToArray()); + } +} + +public sealed class TenantAdminBulkAssignClassDto +{ + [Required] + public Guid ClassId { get; set; } + + [Required] + public IReadOnlyCollection UserIds { get; set; } = []; + + public TenantAdminBulkAssignClassCommand ToCommand() + { + return new TenantAdminBulkAssignClassCommand(ClassId, UserIds); + } +} + +public sealed class TenantAdminBulkStatusDto +{ + [Required] + public IReadOnlyCollection UserIds { get; set; } = []; + + public string? Status { get; set; } + + [StringLength(1000)] + public string? Reason { get; set; } + + public TenantAdminBulkStatusCommand ToCommand() + { + return new TenantAdminBulkStatusCommand(UserIds, Status, Reason); + } +} + +public sealed class UpsertTenantSupervisionRuleDto +{ + [Required] + [StringLength(100)] + public string Code { get; set; } = string.Empty; + + [Required] + [StringLength(200)] + public string Title { get; set; } = string.Empty; + + public bool Enabled { get; set; } = true; + + [Range(0, 3650)] + public int? DaysWithoutCheckIn { get; set; } + + [Range(0, int.MaxValue)] + public int? MaxQuestionsAnsweredToday { get; set; } + + [StringLength(50)] + public string? FollowupType { get; set; } + + [StringLength(50)] + public string? Priority { get; set; } + + public JsonElement Metadata { get; set; } = JsonDefaults.Object(); + + public UpsertTenantSupervisionRuleCommand ToCommand() + { + return new UpsertTenantSupervisionRuleCommand( + Code, + Title, + Enabled, + DaysWithoutCheckIn, + MaxQuestionsAnsweredToday, + FollowupType, + Priority, + Metadata); + } +} + +public sealed class TenantSupervisionGenerateDto +{ + public IReadOnlyCollection? UserIds { get; set; } + public Guid? AssignedToUserId { get; set; } + public DateTimeOffset? DueAt { get; set; } + + public TenantSupervisionGenerateCommand ToCommand() + { + return new TenantSupervisionGenerateCommand(UserIds, AssignedToUserId, DueAt); + } +} + public sealed class UpsertTenantAdminStudentNoteDto { public Guid? Id { get; set; } diff --git a/Tiku.Api/Contracts/TenantCommerceDtos.cs b/Tiku.Api/Contracts/TenantCommerceDtos.cs index aafe913..ee2d6bc 100644 --- a/Tiku.Api/Contracts/TenantCommerceDtos.cs +++ b/Tiku.Api/Contracts/TenantCommerceDtos.cs @@ -334,6 +334,111 @@ public sealed class UpdateReconciliationIssueDto } } +public sealed class PreviewReconciliationImportDto +{ + [Required] + [StringLength(50)] + public string Provider { get; set; } = string.Empty; + + public DateOnly BillDate { get; set; } + public ReconciliationBillType BillType { get; set; } = ReconciliationBillType.Combined; + + [Required] + [StringLength(300)] + public string SourceName { get; set; } = string.Empty; + + public JsonElement Rows { get; set; } = JsonDefaults.Array(); + + public PreviewReconciliationImportCommand ToPreviewCommand() => new(Provider, BillDate, BillType, SourceName, Rows); + + public ImportReconciliationCommand ToImportCommand() => new(Provider, BillDate, BillType, SourceName, Rows); +} + +public sealed class CreateAdjustmentVoucherDto +{ + public Guid? IssueId { get; set; } + public Guid? BatchId { get; set; } + public Guid? ItemId { get; set; } + public Guid? OrderId { get; set; } + public Guid? PaymentId { get; set; } + public Guid? RefundRequestId { get; set; } + public CommerceAdjustmentDirection Direction { get; set; } = CommerceAdjustmentDirection.IncreaseRevenue; + + [Range(1, int.MaxValue)] + public int AmountCents { get; set; } + + [StringLength(10)] + public string? Currency { get; set; } + + [Required] + [StringLength(1000)] + public string Reason { get; set; } = string.Empty; + + [StringLength(500)] + public string? ProofAssetKey { get; set; } + + public JsonElement Metadata { get; set; } = JsonDefaults.Object(); + + public CreateAdjustmentVoucherCommand ToCommand() => new( + IssueId, + BatchId, + ItemId, + OrderId, + PaymentId, + RefundRequestId, + Direction, + AmountCents, + Currency, + Reason, + ProofAssetKey, + Metadata); +} + +public sealed class UpdateAdjustmentVoucherStatusDto +{ + [Required] + public Guid VoucherId { get; set; } + + public CommerceAdjustmentVoucherStatus Status { get; set; } = CommerceAdjustmentVoucherStatus.PendingReview; + + [StringLength(1000)] + public string? Note { get; set; } + + public UpdateAdjustmentVoucherStatusCommand ToCommand() => new(VoucherId, Status, Note); +} + +public sealed class RequestProviderBillJobDto +{ + [Required] + [StringLength(50)] + public string Provider { get; set; } = string.Empty; + + public DateOnly BillDate { get; set; } + public ReconciliationBillType BillType { get; set; } = ReconciliationBillType.Combined; + public DateTimeOffset? RunAfter { get; set; } + + public RequestProviderBillJobCommand ToCommand() => new(Provider, BillDate, BillType, RunAfter); +} + +public sealed class RefundNotificationDto +{ + [Required] + [StringLength(100)] + public string RefundNo { get; set; } = string.Empty; + + [StringLength(200)] + public string? ProviderRefundNo { get; set; } + + public CommerceRefundStatus Status { get; set; } = CommerceRefundStatus.Succeeded; + + [StringLength(200)] + public string? EventId { get; set; } + + public JsonElement Payload { get; set; } = JsonDefaults.Object(); + + public RefundNotificationCommand ToCommand(string provider) => new(provider, RefundNo, ProviderRefundNo, Status, EventId, Payload); +} + public sealed class UpdatePointExchangeOrderStatusDto { [Required] diff --git a/Tiku.Api/Controllers/CommerceController.cs b/Tiku.Api/Controllers/CommerceController.cs index aafc9d1..e131332 100644 --- a/Tiku.Api/Controllers/CommerceController.cs +++ b/Tiku.Api/Controllers/CommerceController.cs @@ -7,6 +7,7 @@ using Tiku.Api.Contracts; using Tiku.Application.Commerce; using Tiku.Application.Security; using Tiku.Application.Tenancy; +using Tiku.Domain.Commerce; namespace Tiku.Api.Controllers; @@ -16,6 +17,7 @@ namespace Tiku.Api.Controllers; [Route("api/commerce")] public sealed class CommerceController( ICommerceService commerceService, + ICommerceAdminService commerceAdminService, ICurrentUser currentUser, ITenantContext currentTenant, ITenantContextInitializer tenantInitializer, @@ -151,6 +153,36 @@ public sealed class CommerceController( cancellationToken)); } + [AllowAnonymous] + [HttpPost("refunds/notify/wechat_pay")] + [EndpointSummary("微信退款结果回调")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> WechatRefundNotify( + [FromQuery] string? tenantCode, + RefundNotificationDto request, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.ProcessRefundNotificationAsync( + await ResolveNotificationTenantAsync(tenantCode, cancellationToken), + request.ToCommand(PaymentProviders.WechatPay), + cancellationToken)); + } + + [AllowAnonymous] + [HttpPost("refunds/notify/alipay")] + [EndpointSummary("支付宝退款结果回调")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> AlipayRefundNotify( + [FromQuery] string? tenantCode, + RefundNotificationDto request, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.ProcessRefundNotificationAsync( + await ResolveNotificationTenantAsync(tenantCode, cancellationToken), + request.ToCommand(PaymentProviders.Alipay), + cancellationToken)); + } + private async Task ResolveNotificationTenantAsync( string? tenantCode, CancellationToken cancellationToken) diff --git a/Tiku.Api/Controllers/PlatformAdminController.cs b/Tiku.Api/Controllers/PlatformAdminController.cs new file mode 100644 index 0000000..902c714 --- /dev/null +++ b/Tiku.Api/Controllers/PlatformAdminController.cs @@ -0,0 +1,266 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tiku.Api.Contracts; +using Tiku.Application.PlatformAdmin; +using Tiku.Application.Security; +using Tiku.Domain.Platform; + +namespace Tiku.Api.Controllers; + +[ApiController] +[Authorize(Policy = BackendPermissions.PlatformDashboardView)] +[Produces("application/json")] +[Route("api/platform-admin")] +public sealed class PlatformAdminController( + IPlatformAdminService platformAdminService, + ICurrentUser currentUser) : ControllerBase +{ + [HttpGet("overview")] + [EndpointSummary("查询平台经营概览")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Overview(CancellationToken cancellationToken) + { + return Ok(await platformAdminService.GetOverviewAsync(ResolveActor(), cancellationToken)); + } + + [HttpGet("tenants")] + [Authorize(Policy = BackendPermissions.PlatformTenantManage)] + [EndpointSummary("查询平台租户列表")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Tenants( + [FromQuery] PlatformAdminQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await platformAdminService.GetTenantsAsync(ResolveActor(), query.ToQuery(), cancellationToken)); + } + + [HttpPost("tenants")] + [Authorize(Policy = BackendPermissions.PlatformTenantManage)] + [EndpointSummary("创建平台租户")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> CreateTenant( + CreatePlatformTenantDto request, + CancellationToken cancellationToken) + { + return Ok(await platformAdminService.CreateTenantAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpGet("tenants/detail")] + [Authorize(Policy = BackendPermissions.PlatformTenantManage)] + [EndpointSummary("查询平台租户详情")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> TenantDetail( + [FromQuery] Guid tenantId, + CancellationToken cancellationToken) + { + return Ok(await platformAdminService.GetTenantDetailAsync(ResolveActor(), tenantId, cancellationToken)); + } + + [HttpPatch("tenants/status")] + [Authorize(Policy = BackendPermissions.PlatformTenantManage)] + [EndpointSummary("更新租户业务与账务状态")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> TenantStatus( + UpdatePlatformTenantStatusDto request, + CancellationToken cancellationToken) + { + return Ok(await platformAdminService.UpdateTenantStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpPut("tenants/billing-profile")] + [Authorize(Policy = BackendPermissions.PlatformTenantManage)] + [EndpointSummary("保存租户账务与开票资料")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> BillingProfile( + UpsertPlatformTenantBillingProfileDto request, + CancellationToken cancellationToken) + { + return Ok(await platformAdminService.UpsertTenantBillingProfileAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpGet("plans")] + [Authorize(Policy = BackendPermissions.PlatformTenantManage)] + [EndpointSummary("查询平台 SaaS 套餐")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Plans( + [FromQuery] PlatformAdminQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await platformAdminService.GetPlansAsync(ResolveActor(), query.ToQuery(), cancellationToken)); + } + + [HttpPost("subscriptions")] + [Authorize(Policy = BackendPermissions.PlatformTenantManage)] + [EndpointSummary("创建或调整租户订阅")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> UpsertSubscription( + UpsertPlatformSubscriptionDto request, + CancellationToken cancellationToken) + { + return Ok(await platformAdminService.UpsertSubscriptionAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpGet("domains")] + [Authorize(Policy = BackendPermissions.PlatformTenantManage)] + [EndpointSummary("查询租户域名状态")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Domains( + [FromQuery] PlatformAdminQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await platformAdminService.GetDomainsAsync(ResolveActor(), query.ToQuery(), cancellationToken)); + } + + [HttpPost("domains/{domainId:guid}/recheck")] + [Authorize(Policy = BackendPermissions.PlatformTenantManage)] + [EndpointSummary("重新触发租户域名 DNS/TLS 验证")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> RecheckDomain( + Guid domainId, + CancellationToken cancellationToken) + { + return Ok(await platformAdminService.RecheckDomainAsync(ResolveActor(), domainId, cancellationToken)); + } + + [HttpGet("staff")] + [Authorize(Policy = BackendPermissions.PlatformStaffManage)] + [EndpointSummary("查询平台员工列表")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> Staff( + [FromQuery] PlatformAdminQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await platformAdminService.GetStaffAsync(ResolveActor(), query.ToQuery(), cancellationToken)); + } + + [HttpPut("staff")] + [Authorize(Policy = BackendPermissions.PlatformStaffManage)] + [EndpointSummary("创建或更新平台员工")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> UpsertStaff( + UpsertPlatformStaffDto request, + CancellationToken cancellationToken) + { + return Ok(await platformAdminService.UpsertStaffAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpPatch("staff/status")] + [Authorize(Policy = BackendPermissions.PlatformStaffManage)] + [EndpointSummary("启用或禁用平台员工")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> StaffStatus( + UpdatePlatformStaffStatusDto request, + CancellationToken cancellationToken) + { + return Ok(await platformAdminService.UpdateStaffStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpGet("audit-logs")] + [Authorize(Policy = BackendPermissions.PlatformAuditView)] + [EndpointSummary("查询平台审计日志")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> AuditLogs( + [FromQuery] PlatformAdminQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await platformAdminService.GetAuditLogsAsync(ResolveActor(), query.ToQuery(), cancellationToken)); + } + + [HttpGet("audit-alerts")] + [Authorize(Policy = BackendPermissions.PlatformAuditView)] + [EndpointSummary("查询平台审计告警")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> AuditAlerts( + [FromQuery] PlatformAdminQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await platformAdminService.GetAuditAlertsAsync(ResolveActor(), query.ToQuery(), cancellationToken)); + } + + [HttpPost("audit-alerts/status")] + [Authorize(Policy = BackendPermissions.PlatformAuditView)] + [EndpointSummary("更新平台审计告警状态")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> AuditAlertStatus( + UpdatePlatformAuditAlertStatusDto request, + CancellationToken cancellationToken) + { + return Ok(await platformAdminService.UpdateAuditAlertStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpGet("dunning-notification-channels")] + [Authorize(Policy = BackendPermissions.PlatformBillingNotification)] + [EndpointSummary("查询平台催缴通知渠道")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> DunningChannels( + [FromQuery] PlatformAdminQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await platformAdminService.GetDunningChannelsAsync(ResolveActor(), query.ToQuery(), cancellationToken)); + } + + [HttpPut("dunning-notification-channels")] + [Authorize(Policy = BackendPermissions.PlatformBillingNotification)] + [EndpointSummary("创建或更新平台催缴通知渠道")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> UpsertDunningChannel( + UpsertPlatformDunningChannelDto request, + CancellationToken cancellationToken) + { + return Ok(await platformAdminService.UpsertDunningChannelAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpPost("dunning-notification-channels/disable")] + [Authorize(Policy = BackendPermissions.PlatformBillingNotification)] + [EndpointSummary("禁用平台催缴通知渠道")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> DisableDunningChannel( + DisablePlatformDunningChannelDto request, + CancellationToken cancellationToken) + { + return Ok(await platformAdminService.DisableDunningChannelAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpGet("dunning-notification-events")] + [Authorize(Policy = BackendPermissions.PlatformBillingNotification)] + [EndpointSummary("查询平台催缴通知事件")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> DunningEvents( + [FromQuery] PlatformAdminQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await platformAdminService.GetDunningEventsAsync(ResolveActor(), query.ToQuery(), cancellationToken)); + } + + [HttpGet("dunning-notification-events/detail")] + [Authorize(Policy = BackendPermissions.PlatformBillingNotification)] + [EndpointSummary("查询平台催缴通知事件详情")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> DunningEventDetail( + [FromQuery] Guid eventId, + CancellationToken cancellationToken) + { + return Ok(await platformAdminService.GetDunningEventDetailAsync(ResolveActor(), eventId, cancellationToken)); + } + + [HttpPost("dunning-notification-events/retry")] + [Authorize(Policy = BackendPermissions.PlatformBillingNotification)] + [EndpointSummary("重新标记平台催缴通知事件待发送")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> RetryDunningEvent( + RetryPlatformDunningEventDto request, + CancellationToken cancellationToken) + { + return Ok(await platformAdminService.RetryDunningEventAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + private PlatformAdminActor ResolveActor() + { + if (currentUser.UserId is not { } userId) + { + throw new PlatformAdminException("Platform admin actor was not resolved.", "platform_access_denied"); + } + + return new PlatformAdminActor(userId); + } +} diff --git a/Tiku.Api/Controllers/TenantAdminDirectController.cs b/Tiku.Api/Controllers/TenantAdminDirectController.cs index 55b073d..5f9b669 100644 --- a/Tiku.Api/Controllers/TenantAdminDirectController.cs +++ b/Tiku.Api/Controllers/TenantAdminDirectController.cs @@ -17,6 +17,15 @@ public sealed class TenantAdminDirectController( ICurrentUser currentUser, ITenantContext currentTenant) : ControllerBase { + [HttpGet("overview")] + [Authorize(Policy = BackendPermissions.TenantDashboardView)] + [EndpointSummary("查询租户后台运营概览")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetOverview(CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.GetOverviewAsync(ResolveActor(), cancellationToken)); + } + [HttpGet("classes")] [Authorize(Policy = BackendPermissions.TenantStudentManage)] [EndpointSummary("查询租户班级")] @@ -116,6 +125,117 @@ public sealed class TenantAdminDirectController( return Ok(await tenantAdminService.UpdateStudentStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken)); } + [HttpPost("students/import/preview")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] + [EndpointSummary("预览学生批量导入")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> PreviewStudentImport( + TenantAdminStudentImportDto request, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.PreviewStudentImportAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpPost("students/import")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] + [EndpointSummary("提交学生批量导入")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> ImportStudents( + TenantAdminStudentImportDto request, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.ImportStudentsAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpPost("students/bulk-assign-class")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] + [EndpointSummary("批量分班")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> BulkAssignClass( + TenantAdminBulkAssignClassDto request, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.BulkAssignClassAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpPost("students/bulk-status")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] + [EndpointSummary("批量调整学生状态")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> BulkStudentStatus( + TenantAdminBulkStatusDto request, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.BulkUpdateStudentStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpGet("students/supervision/rules")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] + [EndpointSummary("查询学习督导规则")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> SupervisionRules(CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.GetSupervisionRulesAsync(ResolveActor(), cancellationToken)); + } + + [HttpPut("students/supervision/rules")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] + [EndpointSummary("新增或更新学习督导规则")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertSupervisionRule( + UpsertTenantSupervisionRuleDto request, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.UpsertSupervisionRuleAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpGet("students/supervision/preview")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] + [EndpointSummary("预览学习风险学生")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> PreviewSupervision(CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.PreviewSupervisionAsync(ResolveActor(), cancellationToken)); + } + + [HttpPost("students/supervision/generate")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] + [EndpointSummary("批量生成学习督导跟进任务")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GenerateSupervision( + TenantSupervisionGenerateDto request, + CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.GenerateSupervisionFollowupsAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpGet("student-followups/report")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] + [EndpointSummary("查询学生跟进统计报表")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> FollowupReport(CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.GetFollowupReportAsync(ResolveActor(), cancellationToken)); + } + + [HttpGet("feedbacks/report")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] + [EndpointSummary("查询反馈处理统计报表")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> FeedbackReport(CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.GetFeedbackReportAsync(ResolveActor(), cancellationToken)); + } + + [HttpGet("points/risk-report")] + [Authorize(Policy = BackendPermissions.TenantStudentManage)] + [EndpointSummary("查询积分风险统计报表")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> PointRiskReport(CancellationToken cancellationToken) + { + return Ok(await tenantAdminService.GetPointRiskReportAsync(ResolveActor(), cancellationToken)); + } + [HttpGet("student-notes")] [Authorize(Policy = BackendPermissions.TenantStudentManage)] [EndpointSummary("查询学生备注")] diff --git a/Tiku.Api/Controllers/TenantCommerceController.cs b/Tiku.Api/Controllers/TenantCommerceController.cs index c1c228c..369b87a 100644 --- a/Tiku.Api/Controllers/TenantCommerceController.cs +++ b/Tiku.Api/Controllers/TenantCommerceController.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Tiku.Api.Contracts; using Tiku.Application.Commerce; +using Tiku.Application.Jobs; using Tiku.Application.Security; using Tiku.Domain.Commerce; @@ -374,6 +375,34 @@ public sealed class TenantCommerceController( cancellationToken)); } + [HttpGet("reconciliation/items")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] + [EndpointSummary("查询对账明细")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> ReconciliationItems( + [FromQuery] Guid batchId, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.GetReconciliationItemsAsync( + ResolveActor(), + batchId, + cancellationToken)); + } + + [HttpGet("reconciliation/issues/events")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] + [EndpointSummary("查询对账工单事件")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> ReconciliationIssueEvents( + [FromQuery] Guid issueId, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.GetReconciliationIssueEventsAsync( + ResolveActor(), + issueId, + cancellationToken)); + } + [HttpPost("reconciliation/issues/status")] [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] [EndpointSummary("更新对账异常状态")] @@ -388,6 +417,154 @@ public sealed class TenantCommerceController( cancellationToken)); } + [HttpGet("adjustment-vouchers")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] + [EndpointSummary("查询调账凭证")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> AdjustmentVouchers( + [FromQuery] TenantCommerceQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.GetAdjustmentVouchersAsync( + ResolveActor(), + ToQuery(query), + cancellationToken)); + } + + [HttpGet("adjustment-vouchers/detail")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] + [EndpointSummary("查询调账凭证详情")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> AdjustmentVoucherDetail( + [FromQuery] Guid voucherId, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.GetAdjustmentVoucherAsync( + ResolveActor(), + voucherId, + cancellationToken)); + } + + [HttpPost("adjustment-vouchers")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] + [EndpointSummary("创建调账凭证")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> CreateAdjustmentVoucher( + CreateAdjustmentVoucherDto request, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.CreateAdjustmentVoucherAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpPost("adjustment-vouchers/status")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] + [EndpointSummary("审核或关闭调账凭证")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> UpdateAdjustmentVoucherStatus( + UpdateAdjustmentVoucherStatusDto request, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.UpdateAdjustmentVoucherStatusAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpGet("adjustment-vouchers/events")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] + [EndpointSummary("查询调账凭证事件")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> AdjustmentVoucherEvents( + [FromQuery] Guid voucherId, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.GetAdjustmentVoucherEventsAsync( + ResolveActor(), + voucherId, + cancellationToken)); + } + + [HttpGet("adjustment-vouchers/report")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] + [EndpointSummary("查询调账统计报告")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> AdjustmentVoucherReport(CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.GetAdjustmentReportAsync( + ResolveActor(), + cancellationToken)); + } + + [HttpGet("reconciliation/anomalies")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] + [EndpointSummary("查询对账异常汇总")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> ReconciliationAnomalies(CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.GetAnomalySummaryAsync( + ResolveActor(), + cancellationToken)); + } + + [HttpPost("reconciliation/preview")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] + [EndpointSummary("预览支付渠道账单对账")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> PreviewReconciliation( + PreviewReconciliationImportDto request, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.PreviewReconciliationImportAsync( + ResolveActor(), + request.ToPreviewCommand(), + cancellationToken)); + } + + [HttpPost("reconciliation/import")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] + [EndpointSummary("导入支付渠道账单并创建对账批次")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> ImportReconciliation( + PreviewReconciliationImportDto request, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.ImportReconciliationAsync( + ResolveActor(), + request.ToImportCommand(), + cancellationToken)); + } + + [HttpGet("reconciliation/provider-bills/jobs")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] + [EndpointSummary("查询渠道账单下载任务")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> ProviderBillJobs( + [FromQuery] TenantCommerceQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.GetProviderBillJobsAsync( + ResolveActor(), + ToQuery(query), + cancellationToken)); + } + + [HttpPost("reconciliation/provider-bills/request")] + [Authorize(Policy = TikuPolicies.TenantCommerceOperateAllScope)] + [EndpointSummary("创建渠道官方账单下载任务")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> RequestProviderBill( + RequestProviderBillJobDto request, + CancellationToken cancellationToken) + { + return Ok(await commerceAdminService.RequestProviderBillJobAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + private CommerceAdminActor ResolveActor() { if (currentTenant.TenantId is null || currentUser.UserId is null) diff --git a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs index 3dfb826..a3f6679 100644 --- a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs +++ b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs @@ -7,6 +7,7 @@ using Tiku.Application.Commerce; using Tiku.Application.Content; using Tiku.Application.Growth; using Tiku.Application.Points; +using Tiku.Application.PlatformAdmin; using Tiku.Application.QuestionBanks; using Tiku.Application.Storage; using Tiku.Infrastructure.Content; @@ -235,6 +236,16 @@ public sealed class ExceptionHandlingMiddleware( return; } + if (exception is PlatformAdminException platformAdminException) + { + await WriteProblemAsync( + context, + platformAdminException.Message, + PlatformAdminStatusCode(platformAdminException.Code), + platformAdminException.Code); + return; + } + if (exception is CommerceException commerceException) { await WriteProblemAsync( @@ -469,6 +480,17 @@ public sealed class ExceptionHandlingMiddleware( }; } + private static int PlatformAdminStatusCode(string code) + { + return code switch + { + "platform_access_denied" => StatusCodes.Status403Forbidden, + "tenant_slug_exists" => StatusCodes.Status409Conflict, + _ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound, + _ => StatusCodes.Status400BadRequest + }; + } + private static int PointStatusCode(string code) { return code switch diff --git a/Tiku.Application/Commerce/CommerceAdminModels.cs b/Tiku.Application/Commerce/CommerceAdminModels.cs index 19d198b..e5a9fa0 100644 --- a/Tiku.Application/Commerce/CommerceAdminModels.cs +++ b/Tiku.Application/Commerce/CommerceAdminModels.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Tiku.Application.Jobs; using Tiku.Domain.Commerce; using Tiku.Domain.Tenancy; @@ -180,6 +181,81 @@ public sealed record CreateReconciliationBatchCommand( public sealed record TenantReconciliationBatchList(IReadOnlyCollection Items); public sealed record TenantReconciliationIssueList(IReadOnlyCollection Items); +public sealed record TenantReconciliationItemList(IReadOnlyCollection Items); +public sealed record TenantReconciliationIssueEventList(IReadOnlyCollection Items); +public sealed record TenantCommerceAnomalySummary( + int OpenRefundCount, + int ProcessingRefundCount, + int OpenReconciliationIssueCount, + int FailedReconciliationBatchCount, + int PendingPaymentCount, + int PaymentMismatchCount); + +public sealed record CreateAdjustmentVoucherCommand( + Guid? IssueId, + Guid? BatchId, + Guid? ItemId, + Guid? OrderId, + Guid? PaymentId, + Guid? RefundRequestId, + CommerceAdjustmentDirection Direction, + int AmountCents, + string? Currency, + string Reason, + string? ProofAssetKey, + JsonElement Metadata); + +public sealed record UpdateAdjustmentVoucherStatusCommand( + Guid VoucherId, + CommerceAdjustmentVoucherStatus Status, + string? Note); + +public sealed record TenantAdjustmentVoucherList(IReadOnlyCollection Items); +public sealed record TenantAdjustmentVoucherEventList(IReadOnlyCollection Items); +public sealed record TenantAdjustmentReport( + int DraftCount, + int PendingReviewCount, + int ApprovedCount, + int ClosedCount, + int IncreaseRevenueCents, + int DecreaseRevenueCents); + +public sealed record PreviewReconciliationImportCommand( + string Provider, + DateOnly BillDate, + ReconciliationBillType BillType, + string SourceName, + JsonElement Rows); + +public sealed record ReconciliationImportPreview( + int TotalCount, + int PaymentCount, + int RefundCount, + int InvalidCount, + int AmountCents, + int RefundAmountCents, + string SourceHash); + +public sealed record ImportReconciliationCommand( + string Provider, + DateOnly BillDate, + ReconciliationBillType BillType, + string SourceName, + JsonElement Rows); + +public sealed record RequestProviderBillJobCommand( + string Provider, + DateOnly BillDate, + ReconciliationBillType BillType, + DateTimeOffset? RunAfter = null); + +public sealed record RefundNotificationCommand( + string Provider, + string RefundNo, + string? ProviderRefundNo, + CommerceRefundStatus Status, + string? EventId, + JsonElement Payload); public sealed record UpdateReconciliationIssueCommand( Guid IssueId, @@ -324,4 +400,72 @@ public interface ICommerceAdminService CommerceAdminActor actor, UpdateReconciliationIssueCommand command, CancellationToken cancellationToken = default); + + Task GetReconciliationItemsAsync( + CommerceAdminActor actor, + Guid batchId, + CancellationToken cancellationToken = default); + + Task GetReconciliationIssueEventsAsync( + CommerceAdminActor actor, + Guid issueId, + CancellationToken cancellationToken = default); + + Task GetAnomalySummaryAsync( + CommerceAdminActor actor, + CancellationToken cancellationToken = default); + + Task GetAdjustmentVouchersAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default); + + Task GetAdjustmentVoucherAsync( + CommerceAdminActor actor, + Guid voucherId, + CancellationToken cancellationToken = default); + + Task CreateAdjustmentVoucherAsync( + CommerceAdminActor actor, + CreateAdjustmentVoucherCommand command, + CancellationToken cancellationToken = default); + + Task UpdateAdjustmentVoucherStatusAsync( + CommerceAdminActor actor, + UpdateAdjustmentVoucherStatusCommand command, + CancellationToken cancellationToken = default); + + Task GetAdjustmentVoucherEventsAsync( + CommerceAdminActor actor, + Guid voucherId, + CancellationToken cancellationToken = default); + + Task GetAdjustmentReportAsync( + CommerceAdminActor actor, + CancellationToken cancellationToken = default); + + Task PreviewReconciliationImportAsync( + CommerceAdminActor actor, + PreviewReconciliationImportCommand command, + CancellationToken cancellationToken = default); + + Task ImportReconciliationAsync( + CommerceAdminActor actor, + ImportReconciliationCommand command, + CancellationToken cancellationToken = default); + + Task RequestProviderBillJobAsync( + CommerceAdminActor actor, + RequestProviderBillJobCommand command, + CancellationToken cancellationToken = default); + + Task> GetProviderBillJobsAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default); + + Task ProcessRefundNotificationAsync( + Guid tenantId, + RefundNotificationCommand command, + CancellationToken cancellationToken = default); } diff --git a/Tiku.Application/PlatformAdmin/PlatformAdminModels.cs b/Tiku.Application/PlatformAdmin/PlatformAdminModels.cs new file mode 100644 index 0000000..f266bc8 --- /dev/null +++ b/Tiku.Application/PlatformAdmin/PlatformAdminModels.cs @@ -0,0 +1,262 @@ +using System.Text.Json; +using Tiku.Domain.Commerce; +using Tiku.Domain.Platform; +using Tiku.Domain.Tenancy; +using Tiku.Domain.Identity; + +namespace Tiku.Application.PlatformAdmin; + +public sealed record PlatformAdminActor(Guid UserId); + +public sealed record PlatformAdminQuery(string? Status = null, string? Search = null, int? Limit = null); + +public sealed record PlatformOverview( + int TenantCount, + int ActiveTenantCount, + int SuspendedTenantCount, + int OrderCount, + int PaidOrderCount, + int RevenueCents, + int QuestionBankCount, + int QuestionCount, + int LearningActiveUserCount); + +public sealed record PlatformTenantList(IReadOnlyCollection Items); + +public sealed record PlatformTenantItem( + Guid Id, + string Slug, + string Name, + string? LegalName, + TenantStatus Status, + TenantMode Mode, + BillingStatus BillingStatus, + DateTimeOffset? SubscriptionExpiresAt, + int DomainCount, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + +public sealed record PlatformTenantDetail( + PlatformTenantItem Tenant, + IReadOnlyCollection Domains, + IReadOnlyCollection Subscriptions, + TenantBillingProfileItem? BillingProfile); + +public sealed record PlatformTenantDomainItem( + Guid Id, + Guid TenantId, + string Host, + TenantDomainType DomainType, + TenantDomainStatus Status, + bool IsPrimary, + DateTimeOffset? VerifiedAt, + DateTimeOffset? DnsVerifiedAt, + DateTimeOffset? TlsReadyAt, + DateTimeOffset? LastCheckedAt, + string? LastFailureReason); + +public sealed record PlatformTenantSubscriptionItem( + Guid Id, + Guid TenantId, + string PlanCode, + TenantSubscriptionStatus Status, + DateTimeOffset? StartsAt, + DateTimeOffset? ExpiresAt, + string? BillingCycle, + int AmountCents, + JsonElement Metadata); + +public sealed record TenantBillingProfileItem( + Guid TenantId, + string? BillingName, + string? TaxId, + string? ContactName, + string? ContactPhoneMasked, + string? ContactEmail, + string? BillingAddress, + string? InvoiceTitle, + TenantInvoiceTitleType? InvoiceType, + string? BankName, + string? BankAccountMasked, + JsonElement Metadata); + +public sealed record CreatePlatformTenantCommand( + string Slug, + string Name, + string? LegalName, + TenantStatus Status, + BillingStatus BillingStatus, + JsonElement Metadata); + +public sealed record UpdatePlatformTenantStatusCommand( + Guid TenantId, + TenantStatus Status, + BillingStatus BillingStatus, + string? Reason); + +public sealed record UpsertPlatformTenantBillingProfileCommand( + Guid TenantId, + string? BillingName, + string? TaxId, + string? ContactName, + string? ContactPhone, + string? ContactEmail, + string? BillingAddress, + string? InvoiceTitle, + TenantInvoiceTitleType? InvoiceType, + string? BankName, + string? BankAccountMasked, + JsonElement Metadata); + +public sealed record PlatformPlanList(IReadOnlyCollection Items); + +public sealed record UpsertPlatformSubscriptionCommand( + Guid TenantId, + string PlanCode, + TenantSubscriptionStatus Status, + DateTimeOffset? StartsAt, + DateTimeOffset? ExpiresAt, + string? BillingCycle, + int AmountCents, + JsonElement Metadata); + +public sealed record PlatformDomainList(IReadOnlyCollection Items); + +public sealed record PlatformDomainRecheckResult(Guid DomainId, TenantDomainStatus Status, DateTimeOffset LastCheckedAt); + +public sealed record PlatformStaffList(IReadOnlyCollection Items); + +public sealed record PlatformStaffItem( + Guid UserId, + string? Name, + string? PhoneMasked, + string? Email, + UserStatus Status, + IReadOnlyCollection RoleCodes, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + +public sealed record UpsertPlatformStaffCommand( + Guid? UserId, + string? Email, + string? Phone, + string? Name, + UserStatus Status, + IReadOnlyCollection RoleIds); + +public sealed record UpdatePlatformStaffStatusCommand(Guid UserId, UserStatus Status, string? Reason); + +public sealed record PlatformAuditLogList(IReadOnlyCollection Items); + +public sealed record PlatformAuditLogItem( + Guid Id, + Guid? TenantId, + Guid? ActorUserId, + string Action, + string? TargetType, + string? TargetId, + JsonElement Details, + string? IpAddress, + string? UserAgent, + DateTimeOffset CreatedAt); + +public sealed record PlatformAuditAlertList(IReadOnlyCollection Items); + +public sealed record UpdatePlatformAuditAlertStatusCommand( + Guid AlertId, + PlatformAuditAlertStatus Status, + string? ResolutionNote); + +public sealed record PlatformDunningChannelList(IReadOnlyCollection Items); + +public sealed record PlatformDunningChannelItem( + Guid Id, + string ChannelCode, + string Name, + string? Description, + bool Enabled, + PlatformDunningProvider Provider, + string WebhookUrlMasked, + string? SecretRef, + IReadOnlyCollection ReminderTypes, + IReadOnlyCollection ReminderChannels, + int MinReminderLevel, + IReadOnlyCollection TenantIds, + int TimeoutSeconds, + JsonElement Metadata, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + +public sealed record UpsertPlatformDunningChannelCommand( + Guid? ChannelId, + string ChannelCode, + string Name, + string? Description, + bool Enabled, + PlatformDunningProvider Provider, + string WebhookUrl, + string? SecretRef, + IReadOnlyCollection ReminderTypes, + IReadOnlyCollection ReminderChannels, + int MinReminderLevel, + IReadOnlyCollection TenantIds, + int TimeoutSeconds, + JsonElement Metadata); + +public sealed record DisablePlatformDunningChannelCommand(Guid ChannelId, string? Reason); + +public sealed record PlatformDunningEventList(IReadOnlyCollection Items); + +public sealed record PlatformDunningEventItem( + Guid Id, + Guid TenantId, + Guid ChannelId, + Guid ReminderId, + Guid InvoiceId, + PlatformDunningProvider Provider, + PlatformDunningNotificationStatus Status, + int Attempts, + DateTimeOffset ScheduledAt, + DateTimeOffset? NextAttemptAt, + DateTimeOffset? LastAttemptAt, + DateTimeOffset? SentAt, + string? LastError, + int? LastHttpCode, + string? LastResponseSummary, + JsonElement RequestPayload, + JsonElement Metadata, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + +public sealed record RetryPlatformDunningEventCommand(Guid EventId, string? Reason); + +public interface IPlatformAdminService +{ + Task GetOverviewAsync(PlatformAdminActor actor, CancellationToken cancellationToken = default); + Task GetTenantsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default); + Task GetTenantDetailAsync(PlatformAdminActor actor, Guid tenantId, CancellationToken cancellationToken = default); + Task CreateTenantAsync(PlatformAdminActor actor, CreatePlatformTenantCommand command, CancellationToken cancellationToken = default); + Task UpdateTenantStatusAsync(PlatformAdminActor actor, UpdatePlatformTenantStatusCommand command, CancellationToken cancellationToken = default); + Task UpsertTenantBillingProfileAsync(PlatformAdminActor actor, UpsertPlatformTenantBillingProfileCommand command, CancellationToken cancellationToken = default); + Task GetPlansAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default); + Task UpsertSubscriptionAsync(PlatformAdminActor actor, UpsertPlatformSubscriptionCommand command, CancellationToken cancellationToken = default); + Task GetDomainsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default); + Task RecheckDomainAsync(PlatformAdminActor actor, Guid domainId, CancellationToken cancellationToken = default); + Task GetStaffAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default); + Task UpsertStaffAsync(PlatformAdminActor actor, UpsertPlatformStaffCommand command, CancellationToken cancellationToken = default); + Task UpdateStaffStatusAsync(PlatformAdminActor actor, UpdatePlatformStaffStatusCommand command, CancellationToken cancellationToken = default); + Task GetAuditLogsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default); + Task GetAuditAlertsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default); + Task UpdateAuditAlertStatusAsync(PlatformAdminActor actor, UpdatePlatformAuditAlertStatusCommand command, CancellationToken cancellationToken = default); + Task GetDunningChannelsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default); + Task UpsertDunningChannelAsync(PlatformAdminActor actor, UpsertPlatformDunningChannelCommand command, CancellationToken cancellationToken = default); + Task DisableDunningChannelAsync(PlatformAdminActor actor, DisablePlatformDunningChannelCommand command, CancellationToken cancellationToken = default); + Task GetDunningEventsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default); + Task GetDunningEventDetailAsync(PlatformAdminActor actor, Guid eventId, CancellationToken cancellationToken = default); + Task RetryDunningEventAsync(PlatformAdminActor actor, RetryPlatformDunningEventCommand command, CancellationToken cancellationToken = default); +} + +public sealed class PlatformAdminException(string message, string code) : Exception(message) +{ + public string Code { get; } = code; +} diff --git a/Tiku.Application/Security/BackendPermissions.cs b/Tiku.Application/Security/BackendPermissions.cs index bf34a06..b3cbd00 100644 --- a/Tiku.Application/Security/BackendPermissions.cs +++ b/Tiku.Application/Security/BackendPermissions.cs @@ -20,6 +20,7 @@ public static class BackendPermissions public const string PlatformRoleManage = "platform:role:manage"; public const string PlatformQuestionBankManage = "platform:question-bank:manage"; public const string PlatformAuditView = "platform:audit:view"; + public const string PlatformBillingNotification = "platform:billing:notification"; public static readonly IReadOnlySet Tenant = new HashSet(StringComparer.Ordinal) { @@ -43,7 +44,8 @@ public static class BackendPermissions PlatformStaffManage, PlatformRoleManage, PlatformQuestionBankManage, - PlatformAuditView + PlatformAuditView, + PlatformBillingNotification }; public static void EnsureTenant(string permissionCode) diff --git a/Tiku.Application/TenantAdmin/ITenantAdminDirectService.cs b/Tiku.Application/TenantAdmin/ITenantAdminDirectService.cs index b975dcf..52e2ef1 100644 --- a/Tiku.Application/TenantAdmin/ITenantAdminDirectService.cs +++ b/Tiku.Application/TenantAdmin/ITenantAdminDirectService.cs @@ -5,6 +5,10 @@ namespace Tiku.Application.TenantAdmin; public interface ITenantAdminDirectService { + Task GetOverviewAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default); + Task GetClassesAsync( TenantAdminActor actor, TenantAdminClassFilter filter, @@ -50,6 +54,56 @@ public interface ITenantAdminDirectService UpdateTenantAdminStudentStatusCommand command, CancellationToken cancellationToken = default); + Task PreviewStudentImportAsync( + TenantAdminActor actor, + TenantAdminStudentImportCommand command, + CancellationToken cancellationToken = default); + + Task ImportStudentsAsync( + TenantAdminActor actor, + TenantAdminStudentImportCommand command, + CancellationToken cancellationToken = default); + + Task BulkAssignClassAsync( + TenantAdminActor actor, + TenantAdminBulkAssignClassCommand command, + CancellationToken cancellationToken = default); + + Task BulkUpdateStudentStatusAsync( + TenantAdminActor actor, + TenantAdminBulkStatusCommand command, + CancellationToken cancellationToken = default); + + Task> GetSupervisionRulesAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default); + + Task> UpsertSupervisionRuleAsync( + TenantAdminActor actor, + UpsertTenantSupervisionRuleCommand command, + CancellationToken cancellationToken = default); + + Task PreviewSupervisionAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default); + + Task GenerateSupervisionFollowupsAsync( + TenantAdminActor actor, + TenantSupervisionGenerateCommand command, + CancellationToken cancellationToken = default); + + Task GetFollowupReportAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default); + + Task GetFeedbackReportAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default); + + Task GetPointRiskReportAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default); + Task> GetStudentNotesAsync( TenantAdminActor actor, TenantAdminStudentActivityFilter filter, diff --git a/Tiku.Application/TenantAdmin/TenantAdminDirectModels.cs b/Tiku.Application/TenantAdmin/TenantAdminDirectModels.cs index 862c15f..80d04b5 100644 --- a/Tiku.Application/TenantAdmin/TenantAdminDirectModels.cs +++ b/Tiku.Application/TenantAdmin/TenantAdminDirectModels.cs @@ -78,6 +78,20 @@ public sealed record TenantAdminFeedbackFilter( string? Keyword = null, int? Limit = null); +public sealed record TenantAdminOverviewItem( + int StudentCount, + int ClassCount, + int StaffCount, + int ActivePracticeCount, + int TodayPracticeCount, + int PendingFollowupCount, + int UnreadNotificationCount, + int PaidOrderCount, + int RevenueCents, + int PendingRefundCount, + int OpenReconciliationIssueCount, + DateTimeOffset GeneratedAt); + public sealed record UpsertTenantAdminClassCommand( Guid? Id, Guid? RegionId, @@ -120,6 +134,109 @@ public sealed record UpdateTenantAdminStudentStatusCommand( string? Status, string? Reason); +public sealed record TenantAdminStudentImportRowCommand( + UserLookupCommand User, + Guid? RegionId, + Guid? ClassId, + string? AvatarPreset, + JsonElement RawProfile); + +public sealed record TenantAdminStudentImportCommand( + IReadOnlyCollection Rows); + +public sealed record TenantAdminStudentImportPreview( + int TotalCount, + int ValidCount, + int InvalidCount, + IReadOnlyCollection Items); + +public sealed record TenantAdminStudentImportPreviewItem( + int RowNo, + bool Valid, + string? Reason, + string? Phone, + string? Email, + string? Name, + Guid? RegionId, + Guid? ClassId); + +public sealed record TenantAdminStudentImportResult( + int TotalCount, + int CreatedOrUpdatedCount, + int ClassAssignedCount, + IReadOnlyCollection InvalidItems); + +public sealed record TenantAdminBulkAssignClassCommand( + Guid ClassId, + IReadOnlyCollection UserIds); + +public sealed record TenantAdminBulkStatusCommand( + IReadOnlyCollection UserIds, + string? Status, + string? Reason); + +public sealed record TenantAdminBulkOperationResult( + int RequestedCount, + int SucceededCount, + int FailedCount, + IReadOnlyCollection FailedUserIds); + +public sealed record TenantSupervisionRuleItem( + string Code, + string Title, + bool Enabled, + int? DaysWithoutCheckIn, + int? MaxQuestionsAnsweredToday, + string FollowupType, + string Priority, + JsonElement Metadata); + +public sealed record UpsertTenantSupervisionRuleCommand( + string Code, + string Title, + bool Enabled, + int? DaysWithoutCheckIn, + int? MaxQuestionsAnsweredToday, + string? FollowupType, + string? Priority, + JsonElement Metadata); + +public sealed record TenantSupervisionPreview( + IReadOnlyCollection Items); + +public sealed record TenantSupervisionRiskStudentItem( + Guid UserId, + string? Name, + string? PhoneMasked, + Guid? RegionId, + IReadOnlyCollection RuleCodes, + IReadOnlyCollection Reasons); + +public sealed record TenantSupervisionGenerateCommand( + IReadOnlyCollection? UserIds, + Guid? AssignedToUserId, + DateTimeOffset? DueAt); + +public sealed record TenantSupervisionGenerateResult(int CreatedCount); + +public sealed record TenantFollowupReport( + int OpenCount, + int InProgressCount, + int DoneCount, + int OverdueCount); + +public sealed record TenantFeedbackReport( + int PendingCount, + int AcceptedCount, + int RejectedCount, + int ResolvedCount, + int ClosedCount); + +public sealed record TenantPointRiskReport( + int NegativeScoreUserCount, + int HighPointClaimUserCount, + int CancelledExchangeOrderCount); + public sealed record UpsertTenantAdminStudentNoteCommand( Guid? Id, Guid StudentUserId, diff --git a/Tiku.Domain/Commerce/CommerceEntities.cs b/Tiku.Domain/Commerce/CommerceEntities.cs index d21ad1d..bb5fb07 100644 --- a/Tiku.Domain/Commerce/CommerceEntities.cs +++ b/Tiku.Domain/Commerce/CommerceEntities.cs @@ -348,6 +348,40 @@ public sealed class CommerceReconciliationIssueEvent : Entity, ITenantOwned public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; } +public sealed class CommerceAdjustmentVoucher : AuditableTenantEntity +{ + public Guid? IssueId { get; set; } + public Guid? BatchId { get; set; } + public Guid? ItemId { get; set; } + public Guid? OrderId { get; set; } + public Guid? PaymentId { get; set; } + public Guid? RefundRequestId { get; set; } + public Guid? CreatedBy { get; set; } + public Guid? ReviewedBy { get; set; } + public string VoucherNo { get; set; } = string.Empty; + public CommerceAdjustmentVoucherStatus Status { get; set; } = CommerceAdjustmentVoucherStatus.Draft; + public CommerceAdjustmentDirection Direction { get; set; } = CommerceAdjustmentDirection.IncreaseRevenue; + public int AmountCents { get; set; } + public string Currency { get; set; } = "CNY"; + public string Reason { get; set; } = string.Empty; + public string? ProofAssetKey { get; set; } + public DateTimeOffset? ReviewedAt { get; set; } + public DateTimeOffset? ClosedAt { get; set; } + public JsonElement Metadata { get; set; } = JsonDefaults.Object(); +} + +public sealed class CommerceAdjustmentVoucherEvent : Entity, ITenantOwned +{ + public Guid TenantId { get; set; } + public Guid VoucherId { get; set; } + public CommerceAdjustmentVoucherStatus? FromStatus { get; set; } + public CommerceAdjustmentVoucherStatus ToStatus { get; set; } = CommerceAdjustmentVoucherStatus.Draft; + public Guid? ActorUserId { get; set; } + public string? Note { get; set; } + public JsonElement Details { get; set; } = JsonDefaults.Object(); + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} + public enum ProductType { Material, Course, Service, Link, Other } public enum OrderStatus { Pending, Paid, Failed, Closed, PartiallyRefunded, Refunded } public enum PaymentStatus { Pending, Paid, Failed, Cancelled, PartiallyRefunded, Refunded } @@ -367,3 +401,5 @@ public enum ReconciliationIssueMatchStatus { AmountMismatch, StatusMismatch, Mis public enum NotificationSeverity { Info, Warning, Error, Critical } public enum ReconciliationIssueStatus { Open, Investigating, Resolved, Ignored, Escalated } public enum ReconciliationResolutionType { None, ProviderConfirmed, LocalCorrected, ManualAdjustment, FalsePositive, Duplicate, WriteOff } +public enum CommerceAdjustmentVoucherStatus { Draft, PendingReview, Approved, Rejected, Closed, Void } +public enum CommerceAdjustmentDirection { IncreaseRevenue, DecreaseRevenue, IncreaseRefund, DecreaseRefund, WriteOff } diff --git a/Tiku.Infrastructure/Commerce/CommerceAdminService.cs b/Tiku.Infrastructure/Commerce/CommerceAdminService.cs index a55b82c..cd7a122 100644 --- a/Tiku.Infrastructure/Commerce/CommerceAdminService.cs +++ b/Tiku.Infrastructure/Commerce/CommerceAdminService.cs @@ -3,6 +3,7 @@ using System.Security.Cryptography; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Commerce; +using Tiku.Application.Jobs; using Tiku.Application.Security; using Tiku.Application.Tenancy; using Tiku.Domain.Catalog; @@ -17,7 +18,8 @@ internal sealed class CommerceAdminService( TikuDbContext dbContext, ITenantSecretProtector tenantSecretProtector, ITenantExternalProviderConfigService providerConfigService, - ICurrentAccessContext currentAccessContext) : ICommerceAdminService + ICurrentAccessContext currentAccessContext, + IBackgroundJobService backgroundJobService) : ICommerceAdminService { public async Task> GetPaymentAccountsAsync( CommerceAdminActor actor, @@ -871,6 +873,426 @@ internal sealed class CommerceAdminService( return issue; } + public async Task GetAdjustmentVouchersAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var vouchers = dbContext.CommerceAdjustmentVouchers.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId); + if (!string.IsNullOrWhiteSpace(query.Status)) + { + vouchers = vouchers.Where(item => item.Status == ParseAdjustmentVoucherStatus(query.Status)); + } + + var items = await vouchers + .OrderByDescending(item => item.CreatedAt) + .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) + .ToArrayAsync(cancellationToken); + return new TenantAdjustmentVoucherList(items); + } + + public async Task GetAdjustmentVoucherAsync( + CommerceAdminActor actor, + Guid voucherId, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + return await dbContext.CommerceAdjustmentVouchers.AsNoTracking() + .SingleOrDefaultAsync(item => item.TenantId == actor.TenantId && item.Id == voucherId, cancellationToken) + ?? throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found"); + } + + public async Task CreateAdjustmentVoucherAsync( + CommerceAdminActor actor, + CreateAdjustmentVoucherCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + ArgumentException.ThrowIfNullOrWhiteSpace(command.Reason); + await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationIssues, actor.TenantId, command.IssueId, "reconciliation_issue_not_found", cancellationToken); + await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationBatches, actor.TenantId, command.BatchId, "reconciliation_batch_not_found", cancellationToken); + await AssertOptionalReferenceAsync(dbContext.CommerceReconciliationItems, actor.TenantId, command.ItemId, "reconciliation_item_not_found", cancellationToken); + await AssertOptionalReferenceAsync(dbContext.Orders, actor.TenantId, command.OrderId, "order_not_found", cancellationToken); + await AssertOptionalReferenceAsync(dbContext.Payments, actor.TenantId, command.PaymentId, "payment_not_found", cancellationToken); + await AssertOptionalReferenceAsync(dbContext.CommerceRefundRequests, actor.TenantId, command.RefundRequestId, "refund_not_found", cancellationToken); + var voucher = new CommerceAdjustmentVoucher + { + TenantId = actor.TenantId, + IssueId = command.IssueId, + BatchId = command.BatchId, + ItemId = command.ItemId, + OrderId = command.OrderId, + PaymentId = command.PaymentId, + RefundRequestId = command.RefundRequestId, + CreatedBy = actor.UserId, + VoucherNo = $"ADJ{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{Random.Shared.Next(1000, 9999)}", + Status = CommerceAdjustmentVoucherStatus.Draft, + Direction = command.Direction, + AmountCents = command.AmountCents, + Currency = string.IsNullOrWhiteSpace(command.Currency) ? "CNY" : command.Currency.Trim().ToUpperInvariant(), + Reason = command.Reason.Trim(), + ProofAssetKey = string.IsNullOrWhiteSpace(command.ProofAssetKey) ? null : command.ProofAssetKey.Trim(), + Metadata = JsonObjectOrDefault(command.Metadata) + }; + dbContext.CommerceAdjustmentVouchers.Add(voucher); + dbContext.CommerceAdjustmentVoucherEvents.Add(new CommerceAdjustmentVoucherEvent + { + TenantId = actor.TenantId, + VoucherId = voucher.Id, + ToStatus = voucher.Status, + ActorUserId = actor.UserId, + Note = voucher.Reason, + Details = JsonSerializer.SerializeToElement(new { voucher.Direction, voucher.AmountCents }) + }); + await AddAuditAsync(actor, "commerce.adjustment_voucher.created", "commerce_adjustment_vouchers", voucher.Id, new { voucher.VoucherNo, voucher.Direction, voucher.AmountCents }, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return voucher; + } + + public async Task UpdateAdjustmentVoucherStatusAsync( + CommerceAdminActor actor, + UpdateAdjustmentVoucherStatusCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var voucher = await dbContext.CommerceAdjustmentVouchers.SingleOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == command.VoucherId, + cancellationToken) ?? throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found"); + var fromStatus = voucher.Status; + if (fromStatus != command.Status && !IsAllowedAdjustmentTransition(fromStatus, command.Status)) + { + throw new CommerceException("Adjustment voucher status transition is invalid.", "invalid_adjustment_status_transition"); + } + + voucher.Status = command.Status; + if (command.Status is CommerceAdjustmentVoucherStatus.Approved or CommerceAdjustmentVoucherStatus.Rejected) + { + voucher.ReviewedBy = actor.UserId; + voucher.ReviewedAt ??= DateTimeOffset.UtcNow; + } + else if (command.Status is CommerceAdjustmentVoucherStatus.Closed or CommerceAdjustmentVoucherStatus.Void) + { + voucher.ClosedAt ??= DateTimeOffset.UtcNow; + } + + dbContext.CommerceAdjustmentVoucherEvents.Add(new CommerceAdjustmentVoucherEvent + { + TenantId = actor.TenantId, + VoucherId = voucher.Id, + FromStatus = fromStatus, + ToStatus = command.Status, + ActorUserId = actor.UserId, + Note = command.Note, + Details = JsonSerializer.SerializeToElement(new { }) + }); + await AddAuditAsync(actor, "commerce.adjustment_voucher.status_changed", "commerce_adjustment_vouchers", voucher.Id, new { voucher.VoucherNo, From = fromStatus, To = command.Status }, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return voucher; + } + + public async Task GetAdjustmentVoucherEventsAsync( + CommerceAdminActor actor, + Guid voucherId, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var exists = await dbContext.CommerceAdjustmentVouchers.AnyAsync( + item => item.TenantId == actor.TenantId && item.Id == voucherId, + cancellationToken); + if (!exists) + { + throw new CommerceException("Adjustment voucher was not found.", "adjustment_voucher_not_found"); + } + + var events = await dbContext.CommerceAdjustmentVoucherEvents.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.VoucherId == voucherId) + .OrderBy(item => item.CreatedAt) + .ToArrayAsync(cancellationToken); + return new TenantAdjustmentVoucherEventList(events); + } + + public async Task GetAdjustmentReportAsync( + CommerceAdminActor actor, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + return new TenantAdjustmentReport( + await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Draft, cancellationToken), + await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.PendingReview, cancellationToken), + await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved, cancellationToken), + await dbContext.CommerceAdjustmentVouchers.CountAsync(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Closed, cancellationToken), + await dbContext.CommerceAdjustmentVouchers + .Where(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved && item.Direction == CommerceAdjustmentDirection.IncreaseRevenue) + .SumAsync(item => item.AmountCents, cancellationToken), + await dbContext.CommerceAdjustmentVouchers + .Where(item => item.TenantId == actor.TenantId && item.Status == CommerceAdjustmentVoucherStatus.Approved && item.Direction == CommerceAdjustmentDirection.DecreaseRevenue) + .SumAsync(item => item.AmountCents, cancellationToken)); + } + + public async Task GetReconciliationItemsAsync( + CommerceAdminActor actor, + Guid batchId, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var batchExists = await dbContext.CommerceReconciliationBatches.AnyAsync( + item => item.TenantId == actor.TenantId && item.Id == batchId, + cancellationToken); + if (!batchExists) + { + throw new CommerceException("Reconciliation batch was not found.", "reconciliation_batch_not_found"); + } + + var items = await dbContext.CommerceReconciliationItems.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.BatchId == batchId) + .OrderBy(item => item.RowNo) + .Take(500) + .ToArrayAsync(cancellationToken); + return new TenantReconciliationItemList(items); + } + + public async Task GetReconciliationIssueEventsAsync( + CommerceAdminActor actor, + Guid issueId, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var issueExists = await dbContext.CommerceReconciliationIssues.AnyAsync( + item => item.TenantId == actor.TenantId && item.Id == issueId, + cancellationToken); + if (!issueExists) + { + throw new CommerceException("Reconciliation issue was not found.", "reconciliation_issue_not_found"); + } + + var events = await dbContext.CommerceReconciliationIssueEvents.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId && item.IssueId == issueId) + .OrderBy(item => item.CreatedAt) + .ToArrayAsync(cancellationToken); + return new TenantReconciliationIssueEventList(events); + } + + public async Task GetAnomalySummaryAsync( + CommerceAdminActor actor, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var openRefunds = await dbContext.CommerceRefundRequests.CountAsync( + item => item.TenantId == actor.TenantId && item.Status == CommerceRefundStatus.Requested, + cancellationToken); + var processingRefunds = await dbContext.CommerceRefundRequests.CountAsync( + item => item.TenantId == actor.TenantId && item.Status == CommerceRefundStatus.Processing, + cancellationToken); + var openIssues = await dbContext.CommerceReconciliationIssues.CountAsync( + item => item.TenantId == actor.TenantId && item.Status != ReconciliationIssueStatus.Resolved && item.Status != ReconciliationIssueStatus.Ignored, + cancellationToken); + var failedBatches = await dbContext.CommerceReconciliationBatches.CountAsync( + item => item.TenantId == actor.TenantId && item.Status == ReconciliationBatchStatus.Failed, + cancellationToken); + var pendingPayments = await dbContext.Payments.CountAsync( + item => item.TenantId == actor.TenantId && item.Status == PaymentStatus.Pending, + cancellationToken); + var mismatchCount = await dbContext.CommerceReconciliationItems.CountAsync( + item => item.TenantId == actor.TenantId && + (item.MatchStatus == ReconciliationMatchStatus.AmountMismatch || + item.MatchStatus == ReconciliationMatchStatus.StatusMismatch), + cancellationToken); + + return new TenantCommerceAnomalySummary( + openRefunds, + processingRefunds, + openIssues, + failedBatches, + pendingPayments, + mismatchCount); + } + + public async Task PreviewReconciliationImportAsync( + CommerceAdminActor actor, + PreviewReconciliationImportCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + return BuildImportPreview(command.Provider, command.Rows); + } + + public async Task ImportReconciliationAsync( + CommerceAdminActor actor, + ImportReconciliationCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var preview = BuildImportPreview(command.Provider, command.Rows); + var batch = new CommerceReconciliationBatch + { + TenantId = actor.TenantId, + CreatedBy = actor.UserId, + Provider = NormalizeProvider(command.Provider), + BillDate = command.BillDate, + BillType = command.BillType, + Source = ReconciliationSource.ManualUpload, + SourceName = command.SourceName.Trim(), + SourceHash = preview.SourceHash, + Status = preview.InvalidCount == 0 + ? ReconciliationBatchStatus.Completed + : ReconciliationBatchStatus.CompletedWithIssues, + TotalCount = preview.TotalCount, + MatchedCount = preview.TotalCount - preview.InvalidCount, + MismatchCount = preview.InvalidCount, + AmountCents = preview.AmountCents, + RefundAmountCents = preview.RefundAmountCents, + CompletedAt = DateTimeOffset.UtcNow, + Metadata = JsonSerializer.SerializeToElement(new + { + preview.PaymentCount, + preview.RefundCount, + preview.InvalidCount + }) + }; + dbContext.CommerceReconciliationBatches.Add(batch); + var rowNo = 0; + foreach (var row in EnumerateImportRows(command.Rows)) + { + rowNo++; + var item = CreateReconciliationItem(actor.TenantId, batch.Id, rowNo, NormalizeProvider(command.Provider), row); + dbContext.CommerceReconciliationItems.Add(item); + if (item.MatchStatus != ReconciliationMatchStatus.Matched) + { + dbContext.CommerceReconciliationIssues.Add(new CommerceReconciliationIssue + { + TenantId = actor.TenantId, + BatchId = batch.Id, + Provider = item.Provider, + TransactionType = item.TransactionType, + IssueNo = $"RC{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{rowNo:0000}", + MatchStatus = item.MatchStatus switch + { + ReconciliationMatchStatus.MissingLocal => ReconciliationIssueMatchStatus.MissingLocal, + ReconciliationMatchStatus.MissingProvider => ReconciliationIssueMatchStatus.MissingProvider, + ReconciliationMatchStatus.Duplicate => ReconciliationIssueMatchStatus.Duplicate, + ReconciliationMatchStatus.StatusMismatch => ReconciliationIssueMatchStatus.StatusMismatch, + _ => ReconciliationIssueMatchStatus.AmountMismatch + }, + Severity = item.Severity, + Status = ReconciliationIssueStatus.Open, + OrderNo = item.OrderNo, + RefundNo = item.RefundNo, + ProviderTradeNo = item.ProviderTradeNo, + ProviderRefundNo = item.ProviderRefundNo, + AmountCents = item.AmountCents, + RefundAmountCents = item.RefundAmountCents, + Summary = item.IssueCode, + CreatedBy = actor.UserId, + Metadata = item.Details + }); + } + } + + await AddAuditAsync(actor, "commerce.reconciliation.imported", "commerce_reconciliation_batches", batch.Id, new { batch.Provider, batch.BillDate, batch.TotalCount }, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return batch; + } + + public async Task RequestProviderBillJobAsync( + CommerceAdminActor actor, + RequestProviderBillJobCommand command, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + var job = await backgroundJobService.EnqueueAsync( + new CreateBackgroundJobCommand( + actor.TenantId, + "commerce_reconciliation", + JsonSerializer.SerializeToElement(new + { + provider = NormalizeProvider(command.Provider), + command.BillDate, + billType = command.BillType.ToString() + }), + command.RunAfter, + 5), + cancellationToken); + await AddAuditAsync(actor, "commerce.reconciliation.provider_bill_requested", "background_jobs", job.Id, new { command.Provider, command.BillDate, command.BillType }, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return job; + } + + public async Task> GetProviderBillJobsAsync( + CommerceAdminActor actor, + CommerceAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertAdminAsync(actor, cancellationToken); + return await backgroundJobService.ListAsync(actor.TenantId, "commerce_reconciliation", Math.Clamp(query.Limit ?? 50, 1, 200), cancellationToken); + } + + public async Task ProcessRefundNotificationAsync( + Guid tenantId, + RefundNotificationCommand command, + CancellationToken cancellationToken = default) + { + var provider = NormalizeProvider(command.Provider); + var refund = await dbContext.CommerceRefundRequests.SingleOrDefaultAsync( + item => item.TenantId == tenantId && item.RefundNo == command.RefundNo, + cancellationToken) ?? throw new CommerceException("Refund request was not found.", "refund_not_found"); + var eventId = string.IsNullOrWhiteSpace(command.EventId) + ? $"{provider}:{command.RefundNo}:{command.Status}" + : command.EventId.Trim(); + var duplicate = await dbContext.PaymentEvents.AnyAsync( + item => item.TenantId == tenantId && + item.Provider == provider && + item.EventType == "refund" && + item.EventId == eventId, + cancellationToken); + if (duplicate) + { + return refund; + } + + dbContext.PaymentEvents.Add(new PaymentEvent + { + TenantId = tenantId, + Provider = provider, + EventType = "refund", + EventId = eventId, + SignatureValid = true, + Payload = JsonObjectOrDefault(command.Payload), + ProcessedAt = DateTimeOffset.UtcNow + }); + var fromStatus = refund.Status; + if (fromStatus != command.Status && IsAllowedRefundTransition(fromStatus, command.Status)) + { + refund.Status = command.Status; + refund.ProviderRefundNo = string.IsNullOrWhiteSpace(command.ProviderRefundNo) + ? refund.ProviderRefundNo + : command.ProviderRefundNo.Trim(); + if (command.Status == CommerceRefundStatus.Succeeded) + { + refund.SucceededAt = DateTimeOffset.UtcNow; + await ApplyRefundToOrderAsync(refund, cancellationToken); + } + else if (command.Status == CommerceRefundStatus.Failed) + { + refund.FailedAt = DateTimeOffset.UtcNow; + } + + dbContext.CommerceRefundEvents.Add(new CommerceRefundEvent + { + TenantId = tenantId, + RefundRequestId = refund.Id, + FromStatus = fromStatus, + ToStatus = command.Status, + EventType = "provider_notify", + Details = JsonObjectOrDefault(command.Payload) + }); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return refund; + } + private async Task AssertAdminAsync(CommerceAdminActor actor, CancellationToken cancellationToken) { var access = await currentAccessContext.GetAsync(cancellationToken); @@ -964,6 +1386,33 @@ internal sealed class CommerceAdminService( ? parsed : throw new CommerceException("Reconciliation issue status is invalid.", "invalid_reconciliation_issue_status"); + private static CommerceAdjustmentVoucherStatus ParseAdjustmentVoucherStatus(string? status) => + Enum.TryParse(NormalizeEnum(status), true, out var parsed) + ? parsed + : throw new CommerceException("Adjustment voucher status is invalid.", "invalid_adjustment_voucher_status"); + + private async Task AssertOptionalReferenceAsync( + DbSet set, + Guid tenantId, + Guid? id, + string code, + CancellationToken cancellationToken) + where TEntity : class + { + if (!id.HasValue) + { + return; + } + + var exists = await set.AnyAsync( + item => EF.Property(item, "TenantId") == tenantId && EF.Property(item, "Id") == id.Value, + cancellationToken); + if (!exists) + { + throw new CommerceException("Referenced commerce entity was not found.", code); + } + } + private async Task ApplyRefundToOrderAsync(CommerceRefundRequest refund, CancellationToken cancellationToken) { var order = await dbContext.Orders.SingleAsync( @@ -1004,6 +1453,17 @@ internal sealed class CommerceAdminService( }; } + private static bool IsAllowedAdjustmentTransition(CommerceAdjustmentVoucherStatus from, CommerceAdjustmentVoucherStatus to) + { + return from switch + { + CommerceAdjustmentVoucherStatus.Draft => to is CommerceAdjustmentVoucherStatus.PendingReview or CommerceAdjustmentVoucherStatus.Void, + CommerceAdjustmentVoucherStatus.PendingReview => to is CommerceAdjustmentVoucherStatus.Approved or CommerceAdjustmentVoucherStatus.Rejected or CommerceAdjustmentVoucherStatus.Void, + CommerceAdjustmentVoucherStatus.Approved => to is CommerceAdjustmentVoucherStatus.Closed, + _ => false + }; + } + private void AddRefundEvent( CommerceRefundRequest refund, CommerceRefundStatus? fromStatus, @@ -1125,6 +1585,164 @@ internal sealed class CommerceAdminService( } } + private static ReconciliationImportPreview BuildImportPreview(string provider, JsonElement rows) + { + var normalizedProvider = NormalizeProvider(provider); + var parsedRows = EnumerateImportRows(rows).ToArray(); + var paymentCount = parsedRows.Count(row => row.TransactionType == ReconciliationTransactionType.Payment); + var refundCount = parsedRows.Count(row => row.TransactionType == ReconciliationTransactionType.Refund); + var invalidCount = parsedRows.Count(row => row.MatchStatus != ReconciliationMatchStatus.Matched); + var amountCents = parsedRows.Sum(row => row.AmountCents); + var refundAmountCents = parsedRows.Sum(row => row.RefundAmountCents); + var sourceHash = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes($"{normalizedProvider}:{rows.GetRawText()}"))).ToLowerInvariant(); + return new ReconciliationImportPreview( + parsedRows.Length, + paymentCount, + refundCount, + invalidCount, + amountCents, + refundAmountCents, + sourceHash); + } + + private sealed record ReconciliationImportRow( + ReconciliationTransactionType TransactionType, + string? ProviderTradeNo, + string? ProviderRefundNo, + string? OrderNo, + string? RefundNo, + int AmountCents, + int RefundAmountCents, + string? ProviderStatus, + string? LocalStatus, + ReconciliationMatchStatus MatchStatus, + string? IssueCode, + JsonElement Details); + + private static IEnumerable EnumerateImportRows(JsonElement rows) + { + if (rows.ValueKind != JsonValueKind.Array) + { + throw new CommerceException("Reconciliation rows must be an array.", "invalid_reconciliation_rows"); + } + + foreach (var row in rows.EnumerateArray()) + { + if (row.ValueKind != JsonValueKind.Object) + { + yield return InvalidImportRow("row_not_object", row); + continue; + } + + var transactionType = Enum.TryParse( + NormalizeEnum(GetJsonString(row, "transactionType", "transaction_type") ?? "payment"), + true, + out var parsedTransactionType) + ? parsedTransactionType + : ReconciliationTransactionType.Payment; + var amountCents = GetJsonInt(row, "amountCents", "amount_cents", "amount"); + var refundAmountCents = GetJsonInt(row, "refundAmountCents", "refund_amount_cents", "refundAmount"); + var providerTradeNo = GetJsonString(row, "providerTradeNo", "provider_trade_no", "tradeNo"); + var providerRefundNo = GetJsonString(row, "providerRefundNo", "provider_refund_no"); + var orderNo = GetJsonString(row, "orderNo", "order_no"); + var refundNo = GetJsonString(row, "refundNo", "refund_no"); + var issueCode = GetJsonString(row, "issueCode", "issue_code"); + var matchStatus = Enum.TryParse( + NormalizeEnum(GetJsonString(row, "matchStatus", "match_status") ?? "matched"), + true, + out var parsedMatchStatus) + ? parsedMatchStatus + : ReconciliationMatchStatus.AmountMismatch; + if (string.IsNullOrWhiteSpace(providerTradeNo) && + string.IsNullOrWhiteSpace(providerRefundNo) && + string.IsNullOrWhiteSpace(orderNo) && + string.IsNullOrWhiteSpace(refundNo)) + { + matchStatus = ReconciliationMatchStatus.MissingLocal; + issueCode ??= "missing_business_identifier"; + } + + yield return new ReconciliationImportRow( + transactionType, + providerTradeNo, + providerRefundNo, + orderNo, + refundNo, + Math.Max(0, amountCents), + Math.Max(0, refundAmountCents), + GetJsonString(row, "providerStatus", "provider_status"), + GetJsonString(row, "localStatus", "local_status"), + matchStatus, + issueCode, + row.Clone()); + } + } + + private static ReconciliationImportRow InvalidImportRow(string issueCode, JsonElement row) => + new( + ReconciliationTransactionType.Payment, + null, + null, + null, + null, + 0, + 0, + null, + null, + ReconciliationMatchStatus.AmountMismatch, + issueCode, + row.Clone()); + + private static CommerceReconciliationItem CreateReconciliationItem( + Guid tenantId, + Guid batchId, + int rowNo, + string provider, + ReconciliationImportRow row) => + new() + { + TenantId = tenantId, + BatchId = batchId, + RowNo = rowNo, + Provider = provider, + TransactionType = row.TransactionType, + ProviderTradeNo = row.ProviderTradeNo, + ProviderRefundNo = row.ProviderRefundNo, + OrderNo = row.OrderNo, + RefundNo = row.RefundNo, + AmountCents = row.AmountCents, + RefundAmountCents = row.RefundAmountCents, + ProviderStatus = row.ProviderStatus, + LocalStatus = row.LocalStatus, + MatchStatus = row.MatchStatus, + Severity = row.MatchStatus == ReconciliationMatchStatus.Matched ? NotificationSeverity.Info : NotificationSeverity.Warning, + IssueCode = row.IssueCode, + Details = row.Details + }; + + private static int GetJsonInt(JsonElement element, params string[] keys) + { + foreach (var key in keys) + { + if (!element.TryGetProperty(key, out var value)) + { + continue; + } + + if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)) + { + return number; + } + + if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), CultureInfo.InvariantCulture, out var parsed)) + { + return parsed; + } + } + + return 0; + } + private static string GenerateActivationCode() { Span bytes = stackalloc byte[8]; diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs index f5319fd..4f37228 100644 --- a/Tiku.Infrastructure/DependencyInjection.cs +++ b/Tiku.Infrastructure/DependencyInjection.cs @@ -15,6 +15,7 @@ using Tiku.Application.Notifications; using Tiku.Application.QuestionBanks; using Tiku.Application.Profile; using Tiku.Application.Points; +using Tiku.Application.PlatformAdmin; using Tiku.Application.Scoreline; using Tiku.Application.Storage; using Tiku.Application.StudyContent; @@ -34,6 +35,7 @@ using Tiku.Infrastructure.Notifications; using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.Profile; using Tiku.Infrastructure.Points; +using Tiku.Infrastructure.PlatformAdmin; using Tiku.Infrastructure.QuestionBanks; using Tiku.Infrastructure.Scoreline; using Tiku.Infrastructure.Security; @@ -113,6 +115,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/Tiku.Infrastructure/Jobs/BackgroundJobService.cs b/Tiku.Infrastructure/Jobs/BackgroundJobService.cs index b1390d4..b9a4401 100644 --- a/Tiku.Infrastructure/Jobs/BackgroundJobService.cs +++ b/Tiku.Infrastructure/Jobs/BackgroundJobService.cs @@ -1,7 +1,12 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using System.Text.Json; using Tiku.Application.Jobs; using Tiku.Application.Security; +using Tiku.Application.Tenancy; +using Tiku.Domain.Commerce; using Tiku.Domain.Common; +using Tiku.Domain.Content; using Tiku.Domain.Operations; using Tiku.Infrastructure.Persistence; @@ -9,6 +14,7 @@ namespace Tiku.Infrastructure.Jobs; internal sealed class BackgroundJobService( TikuDbContext dbContext, + IServiceProvider serviceProvider, ITenantExecutionScope tenantExecutionScope) : IBackgroundJobService { private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(5); @@ -56,7 +62,7 @@ internal sealed class BackgroundJobService( try { - await tenantExecutionScope.ExecuteAsync( + var result = await tenantExecutionScope.ExecuteAsync( job.TenantId, $"Background job {job.JobType}", (_, token) => ProcessCoreAsync(job, token), @@ -64,7 +70,7 @@ internal sealed class BackgroundJobService( job.Status = BackgroundJobStatus.Succeeded; job.CompletedAt = DateTimeOffset.UtcNow; job.LastError = null; - job.Result = JsonDefaults.Object(); + job.Result = result; } catch (Exception exception) when (exception is not OperationCanceledException) { @@ -108,26 +114,205 @@ internal sealed class BackgroundJobService( return jobs.Select(ToItem).ToArray(); } - private static Task ProcessCoreAsync(BackgroundJob job, CancellationToken cancellationToken) + private async Task ProcessCoreAsync(BackgroundJob job, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); return job.JobType switch { - "content_export" => Task.CompletedTask, - "content_import" => Task.CompletedTask, - "asset_security_scan" => Task.CompletedTask, - "statistics_aggregation" => Task.CompletedTask, - "commerce_reconciliation" => Task.CompletedTask, - "tenant_domain_recheck" => Task.CompletedTask, + "content_export" => await ProcessContentExportAsync(job, cancellationToken), + "content_import" => throw new NotSupportedException("content_import requires the module-specific importer before it can mutate content."), + "asset_security_scan" => throw new NotSupportedException("asset_security_scan requires a configured scanner provider before it can write scan results."), + "statistics_aggregation" => await ProcessStatisticsAggregationAsync(job, cancellationToken), + "commerce_reconciliation" => await ProcessCommerceReconciliationAsync(job, cancellationToken), + "tenant_domain_recheck" => await ProcessTenantDomainRecheckAsync(cancellationToken), _ => throw new InvalidOperationException($"Unsupported background job type '{job.JobType}'.") }; } + private async Task ProcessContentExportAsync( + BackgroundJob job, + CancellationToken cancellationToken) + { + var exportType = GetJsonString(job.Payload, "exportType") ?? "summary"; + var assetKey = $"background-jobs/{job.Id:N}/content-export.json"; + var asset = await dbContext.ContentAssets.SingleOrDefaultAsync( + item => item.TenantId == job.TenantId && item.AssetKey == assetKey, + cancellationToken); + if (asset is null) + { + asset = new ContentAsset + { + TenantId = job.TenantId, + AssetKey = assetKey, + AssetType = ContentAssetType.Document, + StorageProvider = AssetStorageProvider.ExternalUrl, + UploadStatus = AssetUploadStatus.Verified, + SecurityScanStatus = AssetSecurityScanStatus.NotRequired, + Source = "background_job" + }; + dbContext.ContentAssets.Add(asset); + } + + var questionBankCount = await dbContext.QuestionBanks.CountAsync(item => item.TenantId == job.TenantId, cancellationToken); + var questionCount = await dbContext.Questions.CountAsync(item => item.TenantId == job.TenantId, cancellationToken); + var studentCount = await dbContext.StudentProfiles.CountAsync(item => item.TenantId == job.TenantId, cancellationToken); + asset.FileName = $"content-export-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}.json"; + asset.Title = "Content export manifest"; + asset.Description = $"Generated content export manifest for {exportType}."; + asset.ObjectKey = assetKey; + asset.MimeType = "application/json"; + asset.Metadata = JsonSerializer.SerializeToElement(new + { + exportType, + generatedAt = DateTimeOffset.UtcNow, + questionBankCount, + questionCount, + studentCount, + payload = job.Payload + }); + await dbContext.SaveChangesAsync(cancellationToken); + job.OutputAssetId = asset.Id; + return JsonSerializer.SerializeToElement(new + { + outputAssetId = asset.Id, + asset.AssetKey, + questionBankCount, + questionCount, + studentCount + }); + } + + private async Task ProcessCommerceReconciliationAsync( + BackgroundJob job, + CancellationToken cancellationToken) + { + var provider = NormalizeProvider(GetJsonString(job.Payload, "provider")); + var hasProviderConfig = await dbContext.TenantExternalProviders.AnyAsync( + item => + item.TenantId == job.TenantId && + item.Capability == Tiku.Domain.Tenancy.TenantExternalProviderCapability.Payment && + item.Provider == provider && + item.Status == Tiku.Domain.Tenancy.TenantExternalProviderStatus.Active, + cancellationToken); + if (!hasProviderConfig) + { + throw new InvalidOperationException($"Active payment provider '{provider}' is required for commerce reconciliation job."); + } + + var billDate = GetJsonDateOnly(job.Payload, "billDate") ?? DateOnly.FromDateTime(DateTime.UtcNow.Date); + var billType = GetJsonEnum(job.Payload, "billType", ReconciliationBillType.Combined); + var sourceHash = $"background-job:{job.Id:N}"; + var batch = await dbContext.CommerceReconciliationBatches.SingleOrDefaultAsync( + item => + item.TenantId == job.TenantId && + item.Provider == provider && + item.Source == ReconciliationSource.ProviderDownload && + item.SourceHash == sourceHash, + cancellationToken); + if (batch is null) + { + batch = new CommerceReconciliationBatch + { + TenantId = job.TenantId, + Provider = provider, + BillDate = billDate, + BillType = billType, + Source = ReconciliationSource.ProviderDownload, + SourceName = $"provider-bill:{provider}:{billDate:yyyyMMdd}", + SourceHash = sourceHash, + Status = ReconciliationBatchStatus.Pending, + Metadata = JsonSerializer.SerializeToElement(new + { + jobId = job.Id, + note = "Provider bill job created the reconciliation batch; provider download/parser is handled by a dedicated provider processor." + }) + }; + dbContext.CommerceReconciliationBatches.Add(batch); + await dbContext.SaveChangesAsync(cancellationToken); + } + + return JsonSerializer.SerializeToElement(new + { + batchId = batch.Id, + provider, + billDate, + billType = billType.ToString(), + status = batch.Status.ToString() + }); + } + + private async Task ProcessTenantDomainRecheckAsync(CancellationToken cancellationToken) + { + var lifecycleService = serviceProvider.GetRequiredService(); + var processed = await lifecycleService.ProcessPendingAsync(cancellationToken); + return JsonSerializer.SerializeToElement(new + { + processed + }); + } + + private async Task ProcessStatisticsAggregationAsync( + BackgroundJob job, + CancellationToken cancellationToken) + { + var since = DateTimeOffset.UtcNow.AddDays(-7); + var activeLearnerCount = await dbContext.PracticeSessions + .Where(item => item.TenantId == job.TenantId && item.StartedAt >= since) + .Select(item => item.UserId) + .Distinct() + .CountAsync(cancellationToken); + var paidOrderCount = await dbContext.Orders + .CountAsync(item => item.TenantId == job.TenantId && item.Status == OrderStatus.Paid, cancellationToken); + var revenueCents = await dbContext.Orders + .Where(item => item.TenantId == job.TenantId && + (item.Status == OrderStatus.Paid || + item.Status == OrderStatus.PartiallyRefunded || + item.Status == OrderStatus.Refunded)) + .SumAsync(item => item.AmountCents - item.RefundedAmountCents, cancellationToken); + return JsonSerializer.SerializeToElement(new + { + since, + activeLearnerCount, + paidOrderCount, + revenueCents + }); + } + private static string NormalizeJobType(string jobType) { return jobType.Trim().ToLowerInvariant(); } + private static string NormalizeProvider(string? provider) + { + var normalized = (provider ?? string.Empty).Trim().ToLowerInvariant(); + return string.IsNullOrWhiteSpace(normalized) + ? throw new InvalidOperationException("Background job provider is required.") + : normalized; + } + + private static string? GetJsonString(JsonElement element, string propertyName) + { + return element.ValueKind == JsonValueKind.Object && + element.TryGetProperty(propertyName, out var property) && + property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; + } + + private static DateOnly? GetJsonDateOnly(JsonElement element, string propertyName) + { + var value = GetJsonString(element, propertyName); + return DateOnly.TryParse(value, out var parsed) ? parsed : null; + } + + private static TEnum GetJsonEnum(JsonElement element, string propertyName, TEnum fallback) + where TEnum : struct + { + var value = GetJsonString(element, propertyName); + return Enum.TryParse(value, true, out var parsed) ? parsed : fallback; + } + private static BackgroundJobItem ToItem(BackgroundJob job) { return new BackgroundJobItem( diff --git a/Tiku.Infrastructure/Persistence/Configurations/CommerceConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/CommerceConfigurations.cs index 1dc46ac..be95043 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/CommerceConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/CommerceConfigurations.cs @@ -529,3 +529,70 @@ internal sealed class CommerceReconciliationIssueEventConfiguration : IEntityTyp builder.HasOne().WithMany().HasForeignKey(entity => entity.ActorUserId).OnDelete(DeleteBehavior.SetNull); } } + +internal sealed class CommerceAdjustmentVoucherConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ConfigureTenantEntity("commerce_adjustment_vouchers"); + builder.ConfigureTimestamps(); + builder.Property(entity => entity.VoucherNo).HasMaxLength(100); + builder.Property(entity => entity.Status).HasSnakeCaseEnum(); + builder.Property(entity => entity.Direction).HasSnakeCaseEnum(); + builder.Property(entity => entity.Currency).HasMaxLength(10); + builder.Property(entity => entity.Reason).HasMaxLength(1000); + builder.Property(entity => entity.ProofAssetKey).HasMaxLength(500); + builder.Property(entity => entity.Metadata).IsJson("{}"); + builder.HasIndex(entity => new { entity.TenantId, entity.VoucherNo }).IsUnique(); + builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.CreatedAt }); + builder.HasIndex(entity => new { entity.TenantId, entity.IssueId, entity.CreatedAt }); + builder.ToTable(table => table.HasCheckConstraint("ck_commerce_adjustment_vouchers_amount", "amount_cents > 0")); + builder.HasOne().WithMany() + .HasForeignKey(entity => new { entity.TenantId, entity.IssueId }) + .HasPrincipalKey(entity => new { entity.TenantId, entity.Id }) + .OnDelete(DeleteBehavior.SetNull); + builder.HasOne().WithMany() + .HasForeignKey(entity => new { entity.TenantId, entity.BatchId }) + .HasPrincipalKey(entity => new { entity.TenantId, entity.Id }) + .OnDelete(DeleteBehavior.SetNull); + builder.HasOne().WithMany() + .HasForeignKey(entity => new { entity.TenantId, entity.ItemId }) + .HasPrincipalKey(entity => new { entity.TenantId, entity.Id }) + .OnDelete(DeleteBehavior.SetNull); + builder.HasOne().WithMany() + .HasForeignKey(entity => new { entity.TenantId, entity.OrderId }) + .HasPrincipalKey(entity => new { entity.TenantId, entity.Id }) + .OnDelete(DeleteBehavior.SetNull); + builder.HasOne().WithMany() + .HasForeignKey(entity => new { entity.TenantId, entity.PaymentId }) + .HasPrincipalKey(entity => new { entity.TenantId, entity.Id }) + .OnDelete(DeleteBehavior.SetNull); + builder.HasOne().WithMany() + .HasForeignKey(entity => new { entity.TenantId, entity.RefundRequestId }) + .HasPrincipalKey(entity => new { entity.TenantId, entity.Id }) + .OnDelete(DeleteBehavior.SetNull); + builder.HasOne().WithMany().HasForeignKey(entity => entity.CreatedBy).OnDelete(DeleteBehavior.SetNull); + builder.HasOne().WithMany().HasForeignKey(entity => entity.ReviewedBy).OnDelete(DeleteBehavior.SetNull); + } +} + +internal sealed class CommerceAdjustmentVoucherEventConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ConfigureEntity("commerce_adjustment_voucher_events"); + builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id }); + builder.Property(entity => entity.FromStatus).HasNullableSnakeCaseEnum(); + builder.Property(entity => entity.ToStatus).HasSnakeCaseEnum(); + builder.Property(entity => entity.Note).HasMaxLength(1000); + builder.Property(entity => entity.Details).IsJson("{}"); + builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()"); + builder.HasIndex(entity => new { entity.TenantId, entity.VoucherId, entity.CreatedAt }); + builder.HasOne().WithMany().HasForeignKey(entity => entity.TenantId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany() + .HasForeignKey(entity => new { entity.TenantId, entity.VoucherId }) + .HasPrincipalKey(entity => new { entity.TenantId, entity.Id }) + .OnDelete(DeleteBehavior.Cascade); + builder.HasOne().WithMany().HasForeignKey(entity => entity.ActorUserId).OnDelete(DeleteBehavior.SetNull); + } +} diff --git a/Tiku.Infrastructure/Persistence/Configurations/TenantOperationsConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/TenantOperationsConfigurations.cs index 3762b33..6bc367f 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/TenantOperationsConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/TenantOperationsConfigurations.cs @@ -82,7 +82,6 @@ internal sealed class AuthLoginEventConfiguration : IEntityTypeConfiguration builder) { builder.ConfigureEntity("auth_login_events"); - builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id }); builder.Property(entity => entity.Provider).HasMaxLength(50); builder.Property(entity => entity.Identifier).HasMaxLength(320); builder.Property(entity => entity.Result).HasSnakeCaseEnum(); diff --git a/Tiku.Infrastructure/Persistence/Migrations/20260728031410_InitialSchema.Designer.cs b/Tiku.Infrastructure/Persistence/Migrations/20260728031410_InitialSchema.Designer.cs index b8e0ed7..b3b18ae 100644 --- a/Tiku.Infrastructure/Persistence/Migrations/20260728031410_InitialSchema.Designer.cs +++ b/Tiku.Infrastructure/Persistence/Migrations/20260728031410_InitialSchema.Designer.cs @@ -182,7 +182,7 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasColumnType("integer") .HasColumnName("svip_question_limit"); - b.Property("TenantId") + b.Property("TenantId") .HasColumnType("uuid") .HasColumnName("tenant_id"); @@ -12956,9 +12956,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations b.HasKey("Id") .HasName("pk_auth_login_events"); - b.HasAlternateKey("TenantId", "Id") - .HasName("ak_auth_login_events_tenant_id_id"); - b.HasIndex("UserId") .HasDatabaseName("ix_auth_login_events_user_id"); diff --git a/Tiku.Infrastructure/Persistence/Migrations/20260728031410_InitialSchema.cs b/Tiku.Infrastructure/Persistence/Migrations/20260728031410_InitialSchema.cs index c6eda3b..1265dc2 100644 --- a/Tiku.Infrastructure/Persistence/Migrations/20260728031410_InitialSchema.cs +++ b/Tiku.Infrastructure/Persistence/Migrations/20260728031410_InitialSchema.cs @@ -415,7 +415,7 @@ namespace Tiku.Infrastructure.Persistence.Migrations background_color = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), sort_order = table.Column(type: "integer", nullable: false), is_active = table.Column(type: "boolean", nullable: false), - tenant_id = table.Column(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: true), 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()") }, @@ -441,7 +441,7 @@ namespace Tiku.Infrastructure.Persistence.Migrations file_name = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), metadata = table.Column(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"), - tenant_id = table.Column(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: true), 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()") }, @@ -530,7 +530,7 @@ namespace Tiku.Infrastructure.Persistence.Migrations columns: table => new { id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), - tenant_id = table.Column(type: "uuid", nullable: false), + tenant_id = table.Column(type: "uuid", nullable: true), user_id = table.Column(type: "uuid", nullable: true), provider = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), identifier = table.Column(type: "character varying(320)", maxLength: 320, nullable: true), @@ -544,7 +544,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations constraints: table => { table.PrimaryKey("pk_auth_login_events", x => x.id); - table.UniqueConstraint("ak_auth_login_events_tenant_id_id", x => new { x.tenant_id, x.id }); table.ForeignKey( name: "fk_auth_login_events_tenants_tenant_id", column: x => x.tenant_id, diff --git a/Tiku.Infrastructure/Persistence/Migrations/20260728062148_AddCommerceAdjustmentVouchers.Designer.cs b/Tiku.Infrastructure/Persistence/Migrations/20260728062148_AddCommerceAdjustmentVouchers.Designer.cs new file mode 100644 index 0000000..3e6212e --- /dev/null +++ b/Tiku.Infrastructure/Persistence/Migrations/20260728062148_AddCommerceAdjustmentVouchers.Designer.cs @@ -0,0 +1,17959 @@ +// +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("20260728062148_AddCommerceAdjustmentVouchers")] + partial class AddCommerceAdjustmentVouchers + { + /// + 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("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.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.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.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.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.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("MfaSatisfied") + .HasColumnType("boolean") + .HasColumnName("mfa_satisfied"); + + 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.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("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.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.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.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.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/20260728062148_AddCommerceAdjustmentVouchers.cs b/Tiku.Infrastructure/Persistence/Migrations/20260728062148_AddCommerceAdjustmentVouchers.cs new file mode 100644 index 0000000..eefe6f7 --- /dev/null +++ b/Tiku.Infrastructure/Persistence/Migrations/20260728062148_AddCommerceAdjustmentVouchers.cs @@ -0,0 +1,213 @@ +using System; +using System.Text.Json; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Tiku.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddCommerceAdjustmentVouchers : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "commerce_adjustment_vouchers", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + issue_id = table.Column(type: "uuid", nullable: true), + batch_id = table.Column(type: "uuid", nullable: true), + item_id = table.Column(type: "uuid", nullable: true), + order_id = table.Column(type: "uuid", nullable: true), + payment_id = table.Column(type: "uuid", nullable: true), + refund_request_id = table.Column(type: "uuid", nullable: true), + created_by = table.Column(type: "uuid", nullable: true), + reviewed_by = table.Column(type: "uuid", nullable: true), + voucher_no = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + direction = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + amount_cents = table.Column(type: "integer", nullable: false), + currency = table.Column(type: "character varying(10)", maxLength: 10, nullable: false), + reason = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: false), + proof_asset_key = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + reviewed_at = table.Column(type: "timestamp with time zone", nullable: true), + closed_at = table.Column(type: "timestamp with time zone", nullable: true), + metadata = table.Column(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"), + 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_commerce_adjustment_vouchers", x => x.id); + table.UniqueConstraint("ak_commerce_adjustment_vouchers_tenant_id_id", x => new { x.tenant_id, x.id }); + table.CheckConstraint("ck_commerce_adjustment_vouchers_amount", "amount_cents > 0"); + table.ForeignKey( + name: "fk_commerce_adjustment_vouchers_commerce_reconciliation_batche~", + columns: x => new { x.tenant_id, x.batch_id }, + principalTable: "commerce_reconciliation_batches", + principalColumns: new[] { "tenant_id", "id" }, + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "fk_commerce_adjustment_vouchers_commerce_reconciliation_issues~", + columns: x => new { x.tenant_id, x.issue_id }, + principalTable: "commerce_reconciliation_issues", + principalColumns: new[] { "tenant_id", "id" }, + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "fk_commerce_adjustment_vouchers_commerce_reconciliation_items_~", + columns: x => new { x.tenant_id, x.item_id }, + principalTable: "commerce_reconciliation_items", + principalColumns: new[] { "tenant_id", "id" }, + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "fk_commerce_adjustment_vouchers_commerce_refund_requests_tenan~", + columns: x => new { x.tenant_id, x.refund_request_id }, + principalTable: "commerce_refund_requests", + principalColumns: new[] { "tenant_id", "id" }, + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "fk_commerce_adjustment_vouchers_orders_tenant_id_order_id", + columns: x => new { x.tenant_id, x.order_id }, + principalTable: "orders", + principalColumns: new[] { "tenant_id", "id" }, + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "fk_commerce_adjustment_vouchers_payments_tenant_id_payment_id", + columns: x => new { x.tenant_id, x.payment_id }, + principalTable: "payments", + principalColumns: new[] { "tenant_id", "id" }, + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "fk_commerce_adjustment_vouchers_tenants_tenant_id", + column: x => x.tenant_id, + principalTable: "tenants", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_commerce_adjustment_vouchers_users_created_by", + column: x => x.created_by, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "fk_commerce_adjustment_vouchers_users_reviewed_by", + column: x => x.reviewed_by, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "commerce_adjustment_voucher_events", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"), + tenant_id = table.Column(type: "uuid", nullable: false), + voucher_id = table.Column(type: "uuid", nullable: false), + from_status = table.Column(type: "character varying(32)", maxLength: 32, nullable: true), + to_status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + actor_user_id = table.Column(type: "uuid", nullable: true), + note = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + details = table.Column(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()") + }, + constraints: table => + { + table.PrimaryKey("pk_commerce_adjustment_voucher_events", x => x.id); + table.UniqueConstraint("ak_commerce_adjustment_voucher_events_tenant_id_id", x => new { x.tenant_id, x.id }); + table.ForeignKey( + name: "fk_commerce_adjustment_voucher_events_commerce_adjustment_vouc~", + columns: x => new { x.tenant_id, x.voucher_id }, + principalTable: "commerce_adjustment_vouchers", + principalColumns: new[] { "tenant_id", "id" }, + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_commerce_adjustment_voucher_events_tenants_tenant_id", + column: x => x.tenant_id, + principalTable: "tenants", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_commerce_adjustment_voucher_events_users_actor_user_id", + column: x => x.actor_user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateIndex( + name: "ix_commerce_adjustment_voucher_events_actor_user_id", + table: "commerce_adjustment_voucher_events", + column: "actor_user_id"); + + migrationBuilder.CreateIndex( + name: "ix_commerce_adjustment_voucher_events_tenant_id_voucher_id_cre~", + table: "commerce_adjustment_voucher_events", + columns: new[] { "tenant_id", "voucher_id", "created_at" }); + + migrationBuilder.CreateIndex( + name: "ix_commerce_adjustment_vouchers_created_by", + table: "commerce_adjustment_vouchers", + column: "created_by"); + + migrationBuilder.CreateIndex( + name: "ix_commerce_adjustment_vouchers_reviewed_by", + table: "commerce_adjustment_vouchers", + column: "reviewed_by"); + + migrationBuilder.CreateIndex( + name: "ix_commerce_adjustment_vouchers_tenant_id_batch_id", + table: "commerce_adjustment_vouchers", + columns: new[] { "tenant_id", "batch_id" }); + + migrationBuilder.CreateIndex( + name: "ix_commerce_adjustment_vouchers_tenant_id_issue_id_created_at", + table: "commerce_adjustment_vouchers", + columns: new[] { "tenant_id", "issue_id", "created_at" }); + + migrationBuilder.CreateIndex( + name: "ix_commerce_adjustment_vouchers_tenant_id_item_id", + table: "commerce_adjustment_vouchers", + columns: new[] { "tenant_id", "item_id" }); + + migrationBuilder.CreateIndex( + name: "ix_commerce_adjustment_vouchers_tenant_id_order_id", + table: "commerce_adjustment_vouchers", + columns: new[] { "tenant_id", "order_id" }); + + migrationBuilder.CreateIndex( + name: "ix_commerce_adjustment_vouchers_tenant_id_payment_id", + table: "commerce_adjustment_vouchers", + columns: new[] { "tenant_id", "payment_id" }); + + migrationBuilder.CreateIndex( + name: "ix_commerce_adjustment_vouchers_tenant_id_refund_request_id", + table: "commerce_adjustment_vouchers", + columns: new[] { "tenant_id", "refund_request_id" }); + + migrationBuilder.CreateIndex( + name: "ix_commerce_adjustment_vouchers_tenant_id_status_created_at", + table: "commerce_adjustment_vouchers", + columns: new[] { "tenant_id", "status", "created_at" }); + + migrationBuilder.CreateIndex( + name: "ix_commerce_adjustment_vouchers_tenant_id_voucher_no", + table: "commerce_adjustment_vouchers", + columns: new[] { "tenant_id", "voucher_no" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "commerce_adjustment_voucher_events"); + + migrationBuilder.DropTable( + name: "commerce_adjustment_vouchers"); + } + } +} diff --git a/Tiku.Infrastructure/Persistence/Migrations/TikuDbContextModelSnapshot.cs b/Tiku.Infrastructure/Persistence/Migrations/TikuDbContextModelSnapshot.cs index 0842a50..6588504 100644 --- a/Tiku.Infrastructure/Persistence/Migrations/TikuDbContextModelSnapshot.cs +++ b/Tiku.Infrastructure/Persistence/Migrations/TikuDbContextModelSnapshot.cs @@ -1338,6 +1338,221 @@ namespace Tiku.Infrastructure.Persistence.Migrations }); }); + 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") @@ -12937,7 +13152,7 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasColumnType("character varying(32)") .HasColumnName("result"); - b.Property("TenantId") + b.Property("TenantId") .HasColumnType("uuid") .HasColumnName("tenant_id"); @@ -12953,9 +13168,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations b.HasKey("Id") .HasName("pk_auth_login_events"); - b.HasAlternateKey("TenantId", "Id") - .HasName("ak_auth_login_events_tenant_id_id"); - b.HasIndex("UserId") .HasDatabaseName("ix_auth_login_events_user_id"); @@ -14640,6 +14852,94 @@ namespace Tiku.Infrastructure.Persistence.Migrations .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) @@ -17401,7 +17701,6 @@ namespace Tiku.Infrastructure.Persistence.Migrations .WithMany() .HasForeignKey("TenantId") .OnDelete(DeleteBehavior.SetNull) - .IsRequired() .HasConstraintName("fk_auth_login_events_tenants_tenant_id"); b.HasOne("Tiku.Domain.Identity.User", null) diff --git a/Tiku.Infrastructure/Persistence/TikuDbContext.cs b/Tiku.Infrastructure/Persistence/TikuDbContext.cs index bff1790..5d1c3e4 100644 --- a/Tiku.Infrastructure/Persistence/TikuDbContext.cs +++ b/Tiku.Infrastructure/Persistence/TikuDbContext.cs @@ -136,6 +136,8 @@ public sealed class TikuDbContext( public DbSet CommerceReconciliationItems => Set(); public DbSet CommerceReconciliationIssues => Set(); public DbSet CommerceReconciliationIssueEvents => Set(); + public DbSet CommerceAdjustmentVouchers => Set(); + public DbSet CommerceAdjustmentVoucherEvents => Set(); public DbSet PointActivityTasks => Set(); public DbSet PointActivityClaims => Set(); public DbSet PointExchangeItems => Set(); diff --git a/Tiku.Infrastructure/PlatformAdmin/PlatformAdminService.cs b/Tiku.Infrastructure/PlatformAdmin/PlatformAdminService.cs new file mode 100644 index 0000000..58fa20c --- /dev/null +++ b/Tiku.Infrastructure/PlatformAdmin/PlatformAdminService.cs @@ -0,0 +1,931 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Tiku.Application.PlatformAdmin; +using Tiku.Application.Security; +using Tiku.Domain.Commerce; +using Tiku.Domain.Identity; +using Tiku.Domain.Operations; +using Tiku.Domain.Platform; +using Tiku.Domain.QuestionBanks; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.PlatformAdmin; + +internal sealed class PlatformAdminService( + ICurrentAccessContext currentAccessContext, + ITenantExecutionScope tenantExecutionScope) : IPlatformAdminService +{ + public async Task GetOverviewAsync( + PlatformAdminActor actor, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformDashboardView, cancellationToken); + return await ExecuteSystemAsync("platform overview", async dbContext => + { + var tenantCount = await dbContext.Tenants.CountAsync(tenant => tenant.Mode != TenantMode.PlatformOwned, cancellationToken); + var activeTenantCount = await dbContext.Tenants.CountAsync(tenant => tenant.Mode != TenantMode.PlatformOwned && tenant.Status == TenantStatus.Active, cancellationToken); + var suspendedTenantCount = await dbContext.Tenants.CountAsync(tenant => tenant.Mode != TenantMode.PlatformOwned && tenant.Status == TenantStatus.Suspended, cancellationToken); + var orderCount = await dbContext.Orders.CountAsync(cancellationToken); + var paidOrderCount = await dbContext.Orders.CountAsync(order => order.Status == OrderStatus.Paid, cancellationToken); + var revenueCents = await dbContext.Orders + .Where(order => order.Status == OrderStatus.Paid || order.Status == OrderStatus.PartiallyRefunded) + .SumAsync(order => order.AmountCents - order.RefundedAmountCents, cancellationToken); + var questionBankCount = await dbContext.QuestionBanks.CountAsync(cancellationToken); + var questionCount = await dbContext.Questions.CountAsync(question => question.Status == QuestionStatus.Published, cancellationToken); + var learningActiveUserCount = await dbContext.PracticeSessions + .Where(session => session.StartedAt >= DateTimeOffset.UtcNow.AddDays(-7)) + .Select(session => session.UserId) + .Distinct() + .CountAsync(cancellationToken); + + return new PlatformOverview( + tenantCount, + activeTenantCount, + suspendedTenantCount, + orderCount, + paidOrderCount, + revenueCents, + questionBankCount, + questionCount, + learningActiveUserCount); + }, cancellationToken); + } + + public async Task GetTenantsAsync( + PlatformAdminActor actor, + PlatformAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); + return await ExecuteSystemAsync("platform tenant list", async dbContext => + { + var tenants = dbContext.Tenants.AsNoTracking() + .Where(tenant => tenant.Mode != TenantMode.PlatformOwned); + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var search = query.Search.Trim(); + tenants = tenants.Where(tenant => + tenant.Slug.Contains(search) || + tenant.Name.Contains(search) || + (tenant.LegalName != null && tenant.LegalName.Contains(search))); + } + + if (!string.IsNullOrWhiteSpace(query.Status)) + { + tenants = tenants.Where(tenant => tenant.Status == ParseTenantStatus(query.Status)); + } + + var rows = await tenants + .OrderByDescending(tenant => tenant.CreatedAt) + .Take(Limit(query.Limit)) + .Select(tenant => new + { + Tenant = tenant, + DomainCount = dbContext.TenantDomains.Count(domain => domain.TenantId == tenant.Id), + SubscriptionExpiresAt = dbContext.TenantSubscriptions + .Where(subscription => subscription.TenantId == tenant.Id) + .OrderByDescending(subscription => subscription.ExpiresAt) + .Select(subscription => subscription.ExpiresAt) + .FirstOrDefault() + }) + .ToArrayAsync(cancellationToken); + + return new PlatformTenantList(rows.Select(row => ToTenantItem(row.Tenant, row.DomainCount, row.SubscriptionExpiresAt)).ToArray()); + }, cancellationToken); + } + + public async Task GetTenantDetailAsync( + PlatformAdminActor actor, + Guid tenantId, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); + return await ExecuteSystemAsync("platform tenant detail", async dbContext => + { + var tenant = await dbContext.Tenants.AsNoTracking() + .SingleOrDefaultAsync(item => item.Id == tenantId && item.Mode != TenantMode.PlatformOwned, cancellationToken) + ?? throw new PlatformAdminException("Tenant was not found.", "tenant_not_found"); + var domains = await dbContext.TenantDomains.AsNoTracking() + .Where(domain => domain.TenantId == tenantId) + .OrderByDescending(domain => domain.IsPrimary) + .ThenBy(domain => domain.Host) + .ToArrayAsync(cancellationToken); + var subscriptions = await dbContext.TenantSubscriptions.AsNoTracking() + .Where(subscription => subscription.TenantId == tenantId) + .OrderByDescending(subscription => subscription.CreatedAt) + .ToArrayAsync(cancellationToken); + var billingProfile = await dbContext.TenantBillingProfiles.AsNoTracking() + .SingleOrDefaultAsync(profile => profile.TenantId == tenantId, cancellationToken); + + return new PlatformTenantDetail( + ToTenantItem(tenant, domains.Length, subscriptions.FirstOrDefault()?.ExpiresAt), + domains.Select(ToDomainItem).ToArray(), + subscriptions.Select(ToSubscriptionItem).ToArray(), + billingProfile is null ? null : ToBillingProfileItem(billingProfile)); + }, cancellationToken); + } + + public async Task CreateTenantAsync( + PlatformAdminActor actor, + CreatePlatformTenantCommand command, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); + return await ExecuteSystemAsync("platform tenant create", async dbContext => + { + var slug = NormalizeCode(command.Slug); + if (await dbContext.Tenants.AnyAsync(tenant => tenant.Slug == slug, cancellationToken)) + { + throw new PlatformAdminException("Tenant slug already exists.", "tenant_slug_exists"); + } + + var tenant = new Tenant + { + Slug = slug, + Name = command.Name.Trim(), + LegalName = Normalize(command.LegalName), + Status = command.Status, + Mode = TenantMode.Saas, + BillingStatus = command.BillingStatus, + Metadata = JsonObjectOrDefault(command.Metadata) + }; + dbContext.Tenants.Add(tenant); + 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); + }, cancellationToken); + } + + public async Task UpdateTenantStatusAsync( + PlatformAdminActor actor, + UpdatePlatformTenantStatusCommand command, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); + return await ExecuteSystemAsync("platform tenant status update", async dbContext => + { + var tenant = await dbContext.Tenants + .SingleOrDefaultAsync(item => item.Id == command.TenantId && item.Mode != TenantMode.PlatformOwned, cancellationToken) + ?? throw new PlatformAdminException("Tenant was not found.", "tenant_not_found"); + var fromStatus = tenant.Status; + var fromBilling = tenant.BillingStatus; + tenant.Status = command.Status; + tenant.BillingStatus = command.BillingStatus; + AddAudit(dbContext, actor, "platform.tenant.status_changed", tenant.Id, new + { + FromStatus = fromStatus, + ToStatus = tenant.Status, + FromBillingStatus = fromBilling, + ToBillingStatus = tenant.BillingStatus, + command.Reason + }); + await dbContext.SaveChangesAsync(cancellationToken); + var domainCount = await dbContext.TenantDomains.CountAsync(domain => domain.TenantId == tenant.Id, cancellationToken); + var expiresAt = await dbContext.TenantSubscriptions + .Where(subscription => subscription.TenantId == tenant.Id) + .OrderByDescending(subscription => subscription.ExpiresAt) + .Select(subscription => subscription.ExpiresAt) + .FirstOrDefaultAsync(cancellationToken); + return ToTenantItem(tenant, domainCount, expiresAt); + }, cancellationToken); + } + + public async Task UpsertTenantBillingProfileAsync( + PlatformAdminActor actor, + UpsertPlatformTenantBillingProfileCommand command, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); + return await ExecuteSystemAsync("platform tenant billing profile upsert", async dbContext => + { + await RequireTenantAsync(dbContext, command.TenantId, cancellationToken); + var profile = await dbContext.TenantBillingProfiles.SingleOrDefaultAsync( + item => item.TenantId == command.TenantId, + cancellationToken); + if (profile is null) + { + profile = new TenantBillingProfile { TenantId = command.TenantId }; + dbContext.TenantBillingProfiles.Add(profile); + } + + profile.BillingName = Normalize(command.BillingName); + profile.TaxId = Normalize(command.TaxId); + profile.ContactName = Normalize(command.ContactName); + profile.ContactPhone = Normalize(command.ContactPhone); + profile.ContactEmail = Normalize(command.ContactEmail); + profile.BillingAddress = Normalize(command.BillingAddress); + profile.InvoiceTitle = Normalize(command.InvoiceTitle); + profile.InvoiceType = command.InvoiceType; + profile.BankName = Normalize(command.BankName); + profile.BankAccountMasked = MaskBankAccount(command.BankAccountMasked); + profile.Metadata = JsonObjectOrDefault(command.Metadata); + AddAudit(dbContext, actor, "platform.tenant.billing_profile.updated", command.TenantId, new { profile.BillingName, profile.InvoiceType }); + await dbContext.SaveChangesAsync(cancellationToken); + return ToBillingProfileItem(profile); + }, cancellationToken); + } + + public async Task GetPlansAsync( + PlatformAdminActor actor, + PlatformAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); + return await ExecuteSystemAsync("platform plan list", async dbContext => + { + var plans = dbContext.PlatformSaasPlans.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Status)) + { + plans = plans.Where(plan => plan.Status == ParsePlanStatus(query.Status)); + } + + return new PlatformPlanList(await plans + .OrderBy(plan => plan.SortOrder) + .ThenBy(plan => plan.Code) + .Take(Limit(query.Limit)) + .ToArrayAsync(cancellationToken)); + }, cancellationToken); + } + + public async Task UpsertSubscriptionAsync( + PlatformAdminActor actor, + UpsertPlatformSubscriptionCommand command, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); + return await ExecuteSystemAsync("platform tenant subscription upsert", async dbContext => + { + await RequireTenantAsync(dbContext, command.TenantId, cancellationToken); + if (!await dbContext.PlatformSaasPlans.AnyAsync(plan => plan.Code == NormalizeCode(command.PlanCode), cancellationToken)) + { + throw new PlatformAdminException("SaaS plan was not found.", "plan_not_found"); + } + + var subscription = await dbContext.TenantSubscriptions.SingleOrDefaultAsync( + item => item.TenantId == command.TenantId && item.PlanCode == NormalizeCode(command.PlanCode), + cancellationToken); + if (subscription is null) + { + subscription = new TenantSubscription { TenantId = command.TenantId }; + dbContext.TenantSubscriptions.Add(subscription); + } + + subscription.PlanCode = NormalizeCode(command.PlanCode); + subscription.Status = command.Status; + subscription.StartsAt = command.StartsAt; + subscription.ExpiresAt = command.ExpiresAt; + subscription.BillingCycle = Normalize(command.BillingCycle); + subscription.AmountCents = command.AmountCents; + subscription.Metadata = JsonObjectOrDefault(command.Metadata); + AddAudit(dbContext, actor, "platform.tenant.subscription.updated", command.TenantId, new + { + subscription.PlanCode, + subscription.Status, + subscription.ExpiresAt + }); + await dbContext.SaveChangesAsync(cancellationToken); + return ToSubscriptionItem(subscription); + }, cancellationToken); + } + + public async Task GetDomainsAsync( + PlatformAdminActor actor, + PlatformAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); + return await ExecuteSystemAsync("platform domain list", async dbContext => + { + var domains = dbContext.TenantDomains.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Status)) + { + domains = domains.Where(domain => domain.Status == ParseDomainStatus(query.Status)); + } + + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var search = query.Search.Trim(); + domains = domains.Where(domain => domain.Host.Contains(search)); + } + + return new PlatformDomainList(await domains + .OrderByDescending(domain => domain.UpdatedAt) + .Take(Limit(query.Limit)) + .Select(domain => ToDomainItem(domain)) + .ToArrayAsync(cancellationToken)); + }, cancellationToken); + } + + public async Task RecheckDomainAsync( + PlatformAdminActor actor, + Guid domainId, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformTenantManage, cancellationToken); + return await ExecuteSystemAsync("platform domain recheck", async dbContext => + { + var domain = await dbContext.TenantDomains.SingleOrDefaultAsync(domain => domain.Id == domainId, cancellationToken) + ?? throw new PlatformAdminException("Tenant domain was not found.", "domain_not_found"); + domain.Status = domain.Status == TenantDomainStatus.Disabled ? TenantDomainStatus.Disabled : TenantDomainStatus.Pending; + domain.LastCheckedAt = DateTimeOffset.UtcNow; + domain.LastFailureReason = null; + dbContext.BackgroundJobs.Add(new BackgroundJob + { + TenantId = domain.TenantId, + JobType = "tenant_domain_recheck", + Payload = JsonSerializer.SerializeToElement(new { domain.Id, domain.Host }), + MaxRetries = 3 + }); + AddAudit(dbContext, actor, "platform.tenant_domain.recheck_requested", domain.TenantId, new { domain.Id, domain.Host }); + await dbContext.SaveChangesAsync(cancellationToken); + return new PlatformDomainRecheckResult(domain.Id, domain.Status, domain.LastCheckedAt.Value); + }, cancellationToken); + } + + public async Task GetStaffAsync( + PlatformAdminActor actor, + PlatformAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformStaffManage, cancellationToken); + return await ExecuteSystemAsync("platform staff list", async dbContext => + { + var roleRows = await ( + from userRole in dbContext.PlatformBackendUserRoles.AsNoTracking() + join role in dbContext.PlatformBackendRoles.AsNoTracking() on userRole.RoleId equals role.Id + join user in dbContext.Users.AsNoTracking() on userRole.UserId equals user.Id + select new { user, role.Code }) + .ToArrayAsync(cancellationToken); + var items = roleRows + .GroupBy(row => row.user.Id) + .Select(group => ToStaffItem(group.First().user, group.Select(row => row.Code).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray())) + .OrderBy(item => item.Email ?? item.PhoneMasked ?? item.UserId.ToString()) + .Take(Limit(query.Limit)) + .ToArray(); + return new PlatformStaffList(items); + }, cancellationToken); + } + + public async Task UpsertStaffAsync( + PlatformAdminActor actor, + UpsertPlatformStaffCommand command, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformStaffManage, cancellationToken); + return await ExecuteSystemAsync("platform staff upsert", async dbContext => + { + var user = command.UserId.HasValue + ? await dbContext.Users.SingleOrDefaultAsync(item => item.Id == command.UserId.Value, cancellationToken) + : await dbContext.Users.SingleOrDefaultAsync(item => + (!string.IsNullOrWhiteSpace(command.Email) && item.Email == command.Email) || + (!string.IsNullOrWhiteSpace(command.Phone) && item.Phone == command.Phone), + cancellationToken); + if (user is null) + { + user = new User + { + Email = Normalize(command.Email), + NormalizedEmail = Normalize(command.Email)?.ToUpperInvariant(), + UserName = Normalize(command.Email) ?? Normalize(command.Phone), + NormalizedUserName = (Normalize(command.Email) ?? Normalize(command.Phone))?.ToUpperInvariant(), + Phone = Normalize(command.Phone), + PhoneNumber = Normalize(command.Phone), + Name = Normalize(command.Name), + PrimaryRole = "platform_admin", + Status = command.Status, + ForcePasswordChange = true + }; + dbContext.Users.Add(user); + } + else + { + user.Email = Normalize(command.Email) ?? user.Email; + user.NormalizedEmail = user.Email?.ToUpperInvariant(); + user.Phone = Normalize(command.Phone) ?? user.Phone; + user.PhoneNumber = user.Phone; + user.Name = Normalize(command.Name) ?? user.Name; + user.Status = command.Status; + user.PrimaryRole = "platform_admin"; + } + + var roleIds = command.RoleIds.Distinct().ToArray(); + var roleCount = await dbContext.PlatformBackendRoles.CountAsync(role => roleIds.Contains(role.Id), cancellationToken); + if (roleCount != roleIds.Length) + { + throw new PlatformAdminException("One or more platform roles were not found.", "role_not_found"); + } + + await dbContext.SaveChangesAsync(cancellationToken); + await dbContext.PlatformBackendUserRoles.Where(binding => binding.UserId == user.Id).ExecuteDeleteAsync(cancellationToken); + dbContext.PlatformBackendUserRoles.AddRange(roleIds.Select(roleId => new PlatformBackendUserRole + { + UserId = user.Id, + RoleId = roleId + })); + AddAudit(dbContext, actor, "platform.staff.upserted", user.Id, new { user.Email, Phone = MaskPhone(user.Phone), user.Status, RoleIds = roleIds }); + await dbContext.SaveChangesAsync(cancellationToken); + var roleCodes = await dbContext.PlatformBackendRoles.AsNoTracking() + .Where(role => roleIds.Contains(role.Id)) + .Select(role => role.Code) + .ToArrayAsync(cancellationToken); + return ToStaffItem(user, roleCodes); + }, cancellationToken); + } + + public async Task UpdateStaffStatusAsync( + PlatformAdminActor actor, + UpdatePlatformStaffStatusCommand command, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformStaffManage, cancellationToken); + return await ExecuteSystemAsync("platform staff status update", async dbContext => + { + var user = await dbContext.Users.SingleOrDefaultAsync(item => item.Id == command.UserId, cancellationToken) + ?? throw new PlatformAdminException("Platform staff user was not found.", "staff_not_found"); + var from = user.Status; + user.Status = command.Status; + AddAudit(dbContext, actor, "platform.staff.status_changed", user.Id, new { From = from, To = command.Status, command.Reason }); + await dbContext.SaveChangesAsync(cancellationToken); + var roleCodes = await ( + from binding in dbContext.PlatformBackendUserRoles.AsNoTracking() + join role in dbContext.PlatformBackendRoles.AsNoTracking() on binding.RoleId equals role.Id + where binding.UserId == user.Id + select role.Code) + .ToArrayAsync(cancellationToken); + return ToStaffItem(user, roleCodes); + }, cancellationToken); + } + + public async Task GetAuditLogsAsync( + PlatformAdminActor actor, + PlatformAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformAuditView, cancellationToken); + return await ExecuteSystemAsync("platform audit log list", async dbContext => + { + var logs = dbContext.AuditLogs.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var search = query.Search.Trim(); + logs = logs.Where(log => log.Action.Contains(search) || (log.TargetType != null && log.TargetType.Contains(search))); + } + + return new PlatformAuditLogList(await logs + .OrderByDescending(log => log.CreatedAt) + .Take(Limit(query.Limit)) + .Select(log => new PlatformAuditLogItem( + log.Id, + log.TenantId, + log.ActorUserId, + log.Action, + log.TargetType, + log.TargetId, + log.Details, + log.IpAddress, + log.UserAgent, + log.CreatedAt)) + .ToArrayAsync(cancellationToken)); + }, cancellationToken); + } + + public async Task GetAuditAlertsAsync( + PlatformAdminActor actor, + PlatformAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformAuditView, cancellationToken); + return await ExecuteSystemAsync("platform audit alert list", async dbContext => + { + var alerts = dbContext.PlatformAuditAlerts.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Status)) + { + alerts = alerts.Where(alert => alert.Status == ParseAuditAlertStatus(query.Status)); + } + + return new PlatformAuditAlertList(await alerts + .OrderByDescending(alert => alert.LastSeenAt) + .Take(Limit(query.Limit)) + .ToArrayAsync(cancellationToken)); + }, cancellationToken); + } + + public async Task UpdateAuditAlertStatusAsync( + PlatformAdminActor actor, + UpdatePlatformAuditAlertStatusCommand command, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformAuditView, cancellationToken); + return await ExecuteSystemAsync("platform audit alert status update", async dbContext => + { + var alert = await dbContext.PlatformAuditAlerts.SingleOrDefaultAsync(item => item.Id == command.AlertId, cancellationToken) + ?? throw new PlatformAdminException("Platform audit alert was not found.", "audit_alert_not_found"); + alert.Status = command.Status; + alert.ResolutionNote = Normalize(command.ResolutionNote); + if (command.Status == PlatformAuditAlertStatus.Acknowledged) + { + alert.AcknowledgedBy = actor.UserId; + alert.AcknowledgedAt ??= DateTimeOffset.UtcNow; + } + else if (command.Status is PlatformAuditAlertStatus.Resolved or PlatformAuditAlertStatus.Ignored) + { + alert.ResolvedBy = actor.UserId; + alert.ResolvedAt ??= DateTimeOffset.UtcNow; + } + + AddAudit(dbContext, actor, "platform.audit_alert.status_changed", alert.Id, new { alert.Status, command.ResolutionNote }); + await dbContext.SaveChangesAsync(cancellationToken); + return alert; + }, cancellationToken); + } + + public async Task GetDunningChannelsAsync( + PlatformAdminActor actor, + PlatformAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken); + return await ExecuteSystemAsync("platform dunning channel list", async dbContext => + { + var channels = dbContext.PlatformDunningNotificationChannels.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var search = query.Search.Trim(); + channels = channels.Where(channel => + channel.ChannelCode.Contains(search) || + channel.Name.Contains(search)); + } + + if (!string.IsNullOrWhiteSpace(query.Status)) + { + var enabled = ParseEnabledStatus(query.Status); + channels = channels.Where(channel => channel.Enabled == enabled); + } + + return new PlatformDunningChannelList(await channels + .OrderByDescending(channel => channel.Enabled) + .ThenBy(channel => channel.MinReminderLevel) + .ThenBy(channel => channel.ChannelCode) + .Take(Limit(query.Limit)) + .Select(channel => ToDunningChannelItem(channel)) + .ToArrayAsync(cancellationToken)); + }, cancellationToken); + } + + public async Task UpsertDunningChannelAsync( + PlatformAdminActor actor, + UpsertPlatformDunningChannelCommand command, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken); + return await ExecuteSystemAsync("platform dunning channel upsert", async dbContext => + { + var code = NormalizeCode(command.ChannelCode); + var channel = command.ChannelId.HasValue + ? await dbContext.PlatformDunningNotificationChannels.SingleOrDefaultAsync(item => item.Id == command.ChannelId.Value, cancellationToken) + : await dbContext.PlatformDunningNotificationChannels.SingleOrDefaultAsync(item => item.ChannelCode == code, cancellationToken); + if (channel is null) + { + channel = new PlatformDunningNotificationChannel { ChannelCode = code }; + dbContext.PlatformDunningNotificationChannels.Add(channel); + } + + channel.ChannelCode = code; + channel.Name = command.Name.Trim(); + channel.Description = Normalize(command.Description); + channel.Enabled = command.Enabled; + channel.Provider = command.Provider; + channel.WebhookUrl = command.WebhookUrl.Trim(); + channel.SecretRef = Normalize(command.SecretRef); + channel.ReminderTypes = NormalizeArray(command.ReminderTypes, ["overdue", "final_notice"]); + channel.ReminderChannels = NormalizeArray(command.ReminderChannels, ["internal"]); + channel.MinReminderLevel = Math.Clamp(command.MinReminderLevel, 1, 20); + channel.TenantIds = command.TenantIds.Where(id => id != Guid.Empty).Distinct().ToArray(); + channel.TimeoutSeconds = Math.Clamp(command.TimeoutSeconds, 1, 60); + channel.Metadata = JsonObjectOrDefault(command.Metadata); + AddAudit(dbContext, actor, "platform.dunning_channel.upserted", channel.Id, new + { + channel.ChannelCode, + channel.Name, + channel.Enabled, + channel.Provider, + Webhook = MaskWebhook(channel.WebhookUrl), + channel.SecretRef + }); + await dbContext.SaveChangesAsync(cancellationToken); + return ToDunningChannelItem(channel); + }, cancellationToken); + } + + public async Task DisableDunningChannelAsync( + PlatformAdminActor actor, + DisablePlatformDunningChannelCommand command, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken); + return await ExecuteSystemAsync("platform dunning channel disable", async dbContext => + { + var channel = await dbContext.PlatformDunningNotificationChannels.SingleOrDefaultAsync(item => item.Id == command.ChannelId, cancellationToken) + ?? throw new PlatformAdminException("Platform dunning channel was not found.", "dunning_channel_not_found"); + channel.Enabled = false; + AddAudit(dbContext, actor, "platform.dunning_channel.disabled", channel.Id, new + { + channel.ChannelCode, + command.Reason + }); + await dbContext.SaveChangesAsync(cancellationToken); + return ToDunningChannelItem(channel); + }, cancellationToken); + } + + public async Task GetDunningEventsAsync( + PlatformAdminActor actor, + PlatformAdminQuery query, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken); + return await ExecuteSystemAsync("platform dunning event list", async dbContext => + { + var events = dbContext.PlatformDunningNotificationEvents.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Status)) + { + events = events.Where(item => item.Status == ParseDunningEventStatus(query.Status)); + } + + return new PlatformDunningEventList(await events + .OrderByDescending(item => item.CreatedAt) + .Take(Limit(query.Limit)) + .Select(item => ToDunningEventItem(item)) + .ToArrayAsync(cancellationToken)); + }, cancellationToken); + } + + public async Task GetDunningEventDetailAsync( + PlatformAdminActor actor, + Guid eventId, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken); + return await ExecuteSystemAsync("platform dunning event detail", async dbContext => + { + var item = await dbContext.PlatformDunningNotificationEvents.AsNoTracking() + .SingleOrDefaultAsync(item => item.Id == eventId, cancellationToken) + ?? throw new PlatformAdminException("Platform dunning event was not found.", "dunning_event_not_found"); + return ToDunningEventItem(item); + }, cancellationToken); + } + + public async Task RetryDunningEventAsync( + PlatformAdminActor actor, + RetryPlatformDunningEventCommand command, + CancellationToken cancellationToken = default) + { + await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformBillingNotification, cancellationToken); + return await ExecuteSystemAsync("platform dunning event retry", async dbContext => + { + var item = await dbContext.PlatformDunningNotificationEvents.SingleOrDefaultAsync(item => item.Id == command.EventId, cancellationToken) + ?? throw new PlatformAdminException("Platform dunning event was not found.", "dunning_event_not_found"); + item.Status = PlatformDunningNotificationStatus.Pending; + item.NextAttemptAt = DateTimeOffset.UtcNow; + item.LastError = null; + AddAudit(dbContext, actor, "platform.dunning_event.retry_requested", item.Id, new + { + item.TenantId, + item.ChannelId, + item.ReminderId, + item.InvoiceId, + command.Reason + }); + await dbContext.SaveChangesAsync(cancellationToken); + return ToDunningEventItem(item); + }, cancellationToken); + } + + private async Task AssertPlatformPermissionAsync( + PlatformAdminActor actor, + string permissionCode, + CancellationToken cancellationToken) + { + var access = await currentAccessContext.GetAsync(cancellationToken); + if (access.UserId != actor.UserId || !access.HasPlatformPermission(permissionCode)) + { + throw new PlatformAdminException("Platform admin access is required.", "platform_access_denied"); + } + } + + private Task ExecuteSystemAsync( + string reason, + Func> operation, + CancellationToken cancellationToken) + { + return tenantExecutionScope.ExecuteAsync( + null, + reason, + async (provider, _) => await operation(provider.GetRequiredService()), + cancellationToken); + } + + private static async Task RequireTenantAsync(TikuDbContext dbContext, Guid tenantId, CancellationToken cancellationToken) + { + var exists = await dbContext.Tenants.AnyAsync(tenant => tenant.Id == tenantId && tenant.Mode != TenantMode.PlatformOwned, cancellationToken); + if (!exists) + { + throw new PlatformAdminException("Tenant was not found.", "tenant_not_found"); + } + } + + private static void AddAudit(TikuDbContext dbContext, PlatformAdminActor actor, string action, Guid targetId, object details) + { + dbContext.AuditLogs.Add(new AuditLog + { + ActorUserId = actor.UserId, + Action = action, + TargetType = action.Split('.')[1], + TargetId = targetId.ToString("N"), + Details = JsonSerializer.SerializeToElement(details) + }); + } + + private static PlatformTenantItem ToTenantItem(Tenant tenant, int domainCount, DateTimeOffset? subscriptionExpiresAt) => + new( + tenant.Id, + tenant.Slug, + tenant.Name, + tenant.LegalName, + tenant.Status, + tenant.Mode, + tenant.BillingStatus, + subscriptionExpiresAt, + domainCount, + tenant.CreatedAt, + tenant.UpdatedAt); + + private static PlatformTenantDomainItem ToDomainItem(TenantDomain domain) => + new( + domain.Id, + domain.TenantId, + domain.Host, + domain.DomainType, + domain.Status, + domain.IsPrimary, + domain.VerifiedAt, + domain.DnsVerifiedAt, + domain.TlsReadyAt, + domain.LastCheckedAt, + domain.LastFailureReason); + + private static PlatformTenantSubscriptionItem ToSubscriptionItem(TenantSubscription subscription) => + new( + subscription.Id, + subscription.TenantId, + subscription.PlanCode, + subscription.Status, + subscription.StartsAt, + subscription.ExpiresAt, + subscription.BillingCycle, + subscription.AmountCents, + subscription.Metadata); + + private static TenantBillingProfileItem ToBillingProfileItem(TenantBillingProfile profile) => + new( + profile.TenantId, + profile.BillingName, + profile.TaxId, + profile.ContactName, + MaskPhone(profile.ContactPhone), + profile.ContactEmail, + profile.BillingAddress, + profile.InvoiceTitle, + profile.InvoiceType, + profile.BankName, + profile.BankAccountMasked, + profile.Metadata); + + private static PlatformDunningChannelItem ToDunningChannelItem(PlatformDunningNotificationChannel channel) => + new( + channel.Id, + channel.ChannelCode, + channel.Name, + channel.Description, + channel.Enabled, + channel.Provider, + MaskWebhook(channel.WebhookUrl), + channel.SecretRef, + channel.ReminderTypes, + channel.ReminderChannels, + channel.MinReminderLevel, + channel.TenantIds, + channel.TimeoutSeconds, + channel.Metadata, + channel.CreatedAt, + channel.UpdatedAt); + + private static PlatformDunningEventItem ToDunningEventItem(PlatformDunningNotificationEvent item) => + new( + item.Id, + item.TenantId, + item.ChannelId, + item.ReminderId, + item.InvoiceId, + item.Provider, + item.Status, + item.Attempts, + item.ScheduledAt, + item.NextAttemptAt, + item.LastAttemptAt, + item.SentAt, + item.LastError, + item.LastHttpCode, + item.LastResponseSummary, + item.RequestPayload, + item.Metadata, + item.CreatedAt, + item.UpdatedAt); + + private static PlatformStaffItem ToStaffItem(User user, IReadOnlyCollection roleCodes) => + new( + user.Id, + user.Name, + MaskPhone(user.Phone), + user.Email, + user.Status, + roleCodes, + user.CreatedAt, + user.UpdatedAt); + + private static int Limit(int? limit) => Math.Clamp(limit ?? 50, 1, 200); + + private static string NormalizeCode(string value) => value.Trim().ToLowerInvariant(); + private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + + private static JsonElement JsonObjectOrDefault(JsonElement value) => + value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDocument.Parse("{}").RootElement.Clone(); + + private static string? MaskPhone(string? phone) + { + var value = Normalize(phone); + return value is { Length: >= 7 } + ? $"{value[..3]}****{value[^4..]}" + : value; + } + + private static string? MaskBankAccount(string? account) + { + var value = Normalize(account); + return value is { Length: > 8 } + ? $"****{value[^4..]}" + : value; + } + + private static string MaskWebhook(string webhookUrl) + { + var value = Normalize(webhookUrl) ?? string.Empty; + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri)) + { + return value.Length <= 16 ? "****" : $"{value[..8]}****{value[^4..]}"; + } + + return $"{uri.Scheme}://{uri.Host}/****"; + } + + private static string[] NormalizeArray(IReadOnlyCollection values, string[] fallback) + { + var normalized = values + .Select(Normalize) + .Where(value => value is not null) + .Select(value => value!) + .Distinct(StringComparer.Ordinal) + .ToArray(); + return normalized.Length == 0 ? fallback : normalized; + } + + private static bool ParseEnabledStatus(string status) + { + return NormalizeCode(status) switch + { + "enabled" or "active" or "true" => true, + "disabled" or "inactive" or "false" => false, + _ => throw InvalidStatus(status) + }; + } + + private static TenantStatus ParseTenantStatus(string status) => + Enum.TryParse(status, true, out var value) ? value : throw InvalidStatus(status); + + private static TenantDomainStatus ParseDomainStatus(string status) => + Enum.TryParse(status, true, out var value) ? value : throw InvalidStatus(status); + + private static PlatformSaasPlanStatus ParsePlanStatus(string status) => + Enum.TryParse(status, true, out var value) ? value : throw InvalidStatus(status); + + private static PlatformAuditAlertStatus ParseAuditAlertStatus(string status) => + Enum.TryParse(status, true, out var value) ? value : throw InvalidStatus(status); + + private static PlatformDunningNotificationStatus ParseDunningEventStatus(string status) => + Enum.TryParse(status, true, out var value) ? value : throw InvalidStatus(status); + + private static PlatformAdminException InvalidStatus(string status) => + new($"Unsupported status '{status}'.", "invalid_status"); +} diff --git a/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs b/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs index bd69673..a91f7dc 100644 --- a/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs +++ b/Tiku.Infrastructure/TenantAdmin/TenantAdminDirectService.cs @@ -15,6 +15,11 @@ using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.Security; +using CommerceRefundStatus = Tiku.Domain.Commerce.CommerceRefundStatus; +using OrderStatus = Tiku.Domain.Commerce.OrderStatus; +using PointActivityClaimStatus = Tiku.Domain.Commerce.PointActivityClaimStatus; +using PointExchangeOrderStatus = Tiku.Domain.Commerce.PointExchangeOrderStatus; +using ReconciliationIssueStatus = Tiku.Domain.Commerce.ReconciliationIssueStatus; namespace Tiku.Infrastructure.TenantAdmin; @@ -25,6 +30,90 @@ public sealed class TenantAdminDirectService( ICurrentAccessContext currentAccessContext, IAuthSessionStore sessionStore) : ITenantAdminDirectService { + public async Task GetOverviewAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var regionIds = scope.RegionIds.ToArray(); + var classIds = scope.ClassIds.ToArray(); + var now = DateTimeOffset.UtcNow; + var today = new DateTimeOffset(now.UtcDateTime.Date, TimeSpan.Zero); + var scopedClasses = dbContext.TenantClasses.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + item => item.CreatedBy == actor.UserId, + item => classIds.Contains(item.Id) || (item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value))); + var scopedStudents = dbContext.StudentProfiles.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + item => item.UserId == actor.UserId, + item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); + var scopedOrders = dbContext.Orders.AsNoTracking() + .Where(item => item.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + item => item.UserId == actor.UserId, + item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)); + + var studentCount = await scopedStudents.CountAsync(cancellationToken); + var classCount = await scopedClasses.CountAsync(item => item.Status == TenantRecordStatus.Active, cancellationToken); + var staffCount = await dbContext.TenantMemberships.AsNoTracking() + .CountAsync(item => + item.TenantId == actor.TenantId && + item.Status == MembershipStatus.Active && + item.Role != TenantRole.Student, + cancellationToken); + var activePracticeCount = await dbContext.PracticeSessions.AsNoTracking() + .CountAsync(item => + item.TenantId == actor.TenantId && + item.FinishedAt == null && + (!item.ExpiresAt.HasValue || item.ExpiresAt > now), + cancellationToken); + var todayPracticeCount = await dbContext.PracticeSessions.AsNoTracking() + .CountAsync(item => item.TenantId == actor.TenantId && item.StartedAt >= today, cancellationToken); + var pendingFollowupCount = await dbContext.TenantStudentFollowups.AsNoTracking() + .CountAsync(item => + item.TenantId == actor.TenantId && + (item.Status == StudentFollowupStatus.Open || item.Status == StudentFollowupStatus.InProgress), + cancellationToken); + var unreadNotificationCount = await dbContext.UserNotifications.AsNoTracking() + .CountAsync(item => item.TenantId == actor.TenantId && item.Status == NotificationStatus.Unread, cancellationToken); + var paidOrderCount = await scopedOrders.CountAsync(item => item.Status == OrderStatus.Paid, cancellationToken); + var revenueCents = await scopedOrders + .Where(item => item.Status == OrderStatus.Paid || item.Status == OrderStatus.PartiallyRefunded || item.Status == OrderStatus.Refunded) + .SumAsync(item => item.AmountCents - item.RefundedAmountCents, cancellationToken); + var pendingRefundCount = await dbContext.CommerceRefundRequests.AsNoTracking() + .CountAsync(item => + item.TenantId == actor.TenantId && + (item.Status == CommerceRefundStatus.Requested || + item.Status == CommerceRefundStatus.Approved || + item.Status == CommerceRefundStatus.Processing), + cancellationToken); + var openReconciliationIssueCount = await dbContext.CommerceReconciliationIssues.AsNoTracking() + .CountAsync(item => + item.TenantId == actor.TenantId && + item.Status != ReconciliationIssueStatus.Resolved && + item.Status != ReconciliationIssueStatus.Ignored, + cancellationToken); + + return new TenantAdminOverviewItem( + studentCount, + classCount, + staffCount, + activePracticeCount, + todayPracticeCount, + pendingFollowupCount, + unreadNotificationCount, + paidOrderCount, + revenueCents, + pendingRefundCount, + openReconciliationIssueCount, + now); + } + public async Task GetClassesAsync( TenantAdminActor actor, TenantAdminClassFilter filter, @@ -494,6 +583,289 @@ public sealed class TenantAdminDirectService( new TenantAdminStudentStatusItem(membership.Id, membership.UserId, membership.Role, membership.Status, membership.UpdatedAt)); } + public async Task PreviewStudentImportAsync( + TenantAdminActor actor, + TenantAdminStudentImportCommand command, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var items = await BuildStudentImportPreviewAsync(actor, scope, command, cancellationToken); + return new TenantAdminStudentImportPreview( + command.Rows.Count, + items.Count(item => item.Valid), + items.Count(item => !item.Valid), + items); + } + + public async Task ImportStudentsAsync( + TenantAdminActor actor, + TenantAdminStudentImportCommand command, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + var previewItems = await BuildStudentImportPreviewAsync(actor, scope, command, cancellationToken); + var invalidItems = previewItems.Where(item => !item.Valid).ToArray(); + var createdOrUpdated = 0; + var classAssigned = 0; + var rowNo = 0; + foreach (var row in command.Rows) + { + rowNo++; + if (invalidItems.Any(item => item.RowNo == rowNo)) + { + continue; + } + + var user = await ResolveUserAsync(row.User, "student", cancellationToken); + if (row.RawProfile.ValueKind == JsonValueKind.Object) + { + user.RawProfile = row.RawProfile.Clone(); + } + + await EnsureMembershipAsync(actor.TenantId, user.Id, TenantRole.Student, cancellationToken); + await EnsureStudentProfileAsync( + actor.TenantId, + user.Id, + row.RegionId, + null, + null, + Normalize(row.AvatarPreset), + JsonDefaults.Object(), + JsonDefaults.Object(), + JsonDefaults.Object(), + cancellationToken); + createdOrUpdated++; + + if (row.ClassId.HasValue) + { + await UpsertClassMemberCoreAsync( + actor, + row.ClassId.Value, + user.Id, + TenantClassMemberType.Student, + TenantClassMemberStatus.Active, + JsonSerializer.SerializeToElement(new { source = "student_import" }), + cancellationToken); + classAssigned++; + } + } + + await AddAuditAsync(actor, "tenant.student.imported", "student_profiles", actor.TenantId, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new TenantAdminStudentImportResult(command.Rows.Count, createdOrUpdated, classAssigned, invalidItems); + } + + public async Task BulkAssignClassAsync( + TenantAdminActor actor, + TenantAdminBulkAssignClassCommand command, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + await AssertClassAsync(actor, scope, command.ClassId, cancellationToken); + var failed = new List(); + var succeeded = 0; + foreach (var userId in command.UserIds.Where(id => id != Guid.Empty).Distinct()) + { + try + { + await AssertStudentAsync(actor, scope, userId, cancellationToken); + await UpsertClassMemberCoreAsync( + actor, + command.ClassId, + userId, + TenantClassMemberType.Student, + TenantClassMemberStatus.Active, + JsonSerializer.SerializeToElement(new { source = "bulk_assign_class" }), + cancellationToken); + succeeded++; + } + catch (TenantAdminDirectException) + { + failed.Add(userId); + } + } + + await AddAuditAsync(actor, "tenant.student.bulk_class_assigned", "tenant_classes", command.ClassId, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new TenantAdminBulkOperationResult(command.UserIds.Count, succeeded, failed.Count, failed); + } + + public async Task BulkUpdateStudentStatusAsync( + TenantAdminActor actor, + TenantAdminBulkStatusCommand command, + CancellationToken cancellationToken = default) + { + var failed = new List(); + var succeeded = 0; + foreach (var userId in command.UserIds.Where(id => id != Guid.Empty).Distinct()) + { + try + { + await UpdateStudentStatusAsync( + actor, + new UpdateTenantAdminStudentStatusCommand(userId, command.Status, command.Reason), + cancellationToken); + succeeded++; + } + catch (TenantAdminDirectException) + { + failed.Add(userId); + } + } + + await AddAuditAsync(actor, "tenant.student.bulk_status_updated", "tenant_memberships", actor.TenantId, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new TenantAdminBulkOperationResult(command.UserIds.Count, succeeded, failed.Count, failed); + } + + public async Task> GetSupervisionRulesAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default) + { + await RequireDataScopeAsync(actor, cancellationToken); + return new CatalogList(await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken)); + } + + public async Task> UpsertSupervisionRuleAsync( + TenantAdminActor actor, + UpsertTenantSupervisionRuleCommand command, + CancellationToken cancellationToken = default) + { + await RequireDataScopeAsync(actor, cancellationToken); + var code = NormalizeCode(command.Code); + ArgumentException.ThrowIfNullOrWhiteSpace(command.Title); + var rules = (await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken)).ToList(); + var item = new TenantSupervisionRuleItem( + code, + command.Title.Trim(), + command.Enabled, + command.DaysWithoutCheckIn, + command.MaxQuestionsAnsweredToday, + Normalize(command.FollowupType) ?? StudentFollowupType.Risk.ToString(), + Normalize(command.Priority) ?? StudentFollowupPriority.High.ToString(), + JsonObjectOrDefault(command.Metadata)); + rules.RemoveAll(rule => string.Equals(rule.Code, code, StringComparison.Ordinal)); + rules.Add(item); + await SaveSupervisionRulesCoreAsync(actor.TenantId, rules.OrderBy(rule => rule.Code).ToArray(), cancellationToken); + await AddAuditAsync(actor, "tenant.supervision_rule.upserted", "tenant_settings", actor.TenantId, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(item); + } + + public async Task PreviewSupervisionAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + return new TenantSupervisionPreview(await BuildSupervisionRiskStudentsAsync(actor, scope, cancellationToken)); + } + + public async Task GenerateSupervisionFollowupsAsync( + TenantAdminActor actor, + TenantSupervisionGenerateCommand command, + CancellationToken cancellationToken = default) + { + var scope = await RequireDataScopeAsync(actor, cancellationToken); + await AssertTenantMemberAsync(actor.TenantId, command.AssignedToUserId, cancellationToken); + var riskStudents = await BuildSupervisionRiskStudentsAsync(actor, scope, cancellationToken); + if (command.UserIds is { Count: > 0 }) + { + var selected = command.UserIds.Where(id => id != Guid.Empty).Distinct().ToHashSet(); + riskStudents = riskStudents.Where(item => selected.Contains(item.UserId)).ToArray(); + } + + var created = 0; + foreach (var student in riskStudents) + { + if (await dbContext.TenantStudentFollowups.AnyAsync(item => + item.TenantId == actor.TenantId && + item.StudentUserId == student.UserId && + item.Status != StudentFollowupStatus.Done && + item.FollowupType == StudentFollowupType.Risk, + cancellationToken)) + { + continue; + } + + dbContext.TenantStudentFollowups.Add(new TenantStudentFollowup + { + TenantId = actor.TenantId, + StudentUserId = student.UserId, + AssignedToUserId = command.AssignedToUserId, + Title = "学习风险督导", + Description = string.Join(";", student.Reasons), + FollowupType = StudentFollowupType.Risk, + Priority = StudentFollowupPriority.High, + Status = StudentFollowupStatus.Open, + DueAt = command.DueAt, + CreatedBy = actor.UserId, + UpdatedBy = actor.UserId, + Metadata = JsonSerializer.SerializeToElement(new { ruleCodes = student.RuleCodes }) + }); + created++; + } + + await AddAuditAsync(actor, "tenant.supervision_followups.generated", "tenant_student_followups", actor.TenantId, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + return new TenantSupervisionGenerateResult(created); + } + + public async Task GetFollowupReportAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default) + { + await RequireDataScopeAsync(actor, cancellationToken); + var now = DateTimeOffset.UtcNow; + return new TenantFollowupReport( + await dbContext.TenantStudentFollowups.CountAsync(item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.Open, cancellationToken), + await dbContext.TenantStudentFollowups.CountAsync(item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.InProgress, cancellationToken), + await dbContext.TenantStudentFollowups.CountAsync(item => item.TenantId == actor.TenantId && item.Status == StudentFollowupStatus.Done, cancellationToken), + await dbContext.TenantStudentFollowups.CountAsync(item => + item.TenantId == actor.TenantId && + item.DueAt.HasValue && + item.DueAt < now && + item.Status != StudentFollowupStatus.Done && + item.Status != StudentFollowupStatus.Cancelled, + cancellationToken)); + } + + public async Task GetFeedbackReportAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default) + { + await RequireDataScopeAsync(actor, cancellationToken); + return new TenantFeedbackReport( + await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Pending, cancellationToken), + await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Accepted, cancellationToken), + await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Rejected, cancellationToken), + await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Resolved, cancellationToken), + await dbContext.Reports.CountAsync(item => item.TenantId == actor.TenantId && item.Status == ReportStatus.Closed, cancellationToken)); + } + + public async Task GetPointRiskReportAsync( + TenantAdminActor actor, + CancellationToken cancellationToken = default) + { + await RequireDataScopeAsync(actor, cancellationToken); + var negativeScoreUsers = await dbContext.Users + .Where(user => user.Score < 0 && + dbContext.TenantMemberships.Any(member => + member.TenantId == actor.TenantId && + member.UserId == user.Id && + member.Status == MembershipStatus.Active)) + .CountAsync(cancellationToken); + var since = DateTimeOffset.UtcNow.AddDays(-7); + var highClaimUsers = await dbContext.PointActivityClaims + .Where(item => item.TenantId == actor.TenantId && item.Status == PointActivityClaimStatus.Claimed && item.ClaimedAt >= since) + .GroupBy(item => item.UserId) + .Where(group => group.Sum(item => item.Points) >= 1000) + .CountAsync(cancellationToken); + var cancelledExchangeOrders = await dbContext.PointExchangeOrders.CountAsync( + item => item.TenantId == actor.TenantId && item.Status == PointExchangeOrderStatus.Cancelled, + cancellationToken); + return new TenantPointRiskReport(negativeScoreUsers, highClaimUsers, cancelledExchangeOrders); + } + public async Task> GetStudentNotesAsync( TenantAdminActor actor, TenantAdminStudentActivityFilter filter, @@ -1669,6 +2041,197 @@ public sealed class TenantAdminDirectService( return profile; } + private async Task> BuildStudentImportPreviewAsync( + TenantAdminActor actor, + CurrentDataScope scope, + TenantAdminStudentImportCommand command, + CancellationToken cancellationToken) + { + var items = new List(); + var rowNo = 0; + foreach (var row in command.Rows.Take(1000)) + { + rowNo++; + string? reason = null; + var phone = Normalize(row.User.Phone); + var email = Normalize(row.User.Email); + var name = Normalize(row.User.Name); + if (row.User.UserId is null && phone is null && email is null && name is null) + { + reason = "user_required"; + } + else if (!scope.AllowsResource(actor.UserId, actor.UserId, row.RegionId)) + { + reason = "data_scope_denied"; + } + else if (row.RegionId.HasValue && !await dbContext.Regions.AnyAsync(item => item.TenantId == actor.TenantId && item.Id == row.RegionId.Value, cancellationToken)) + { + reason = "region_not_found"; + } + else if (row.ClassId.HasValue) + { + try + { + await AssertClassAsync(actor, scope, row.ClassId, cancellationToken); + } + catch (TenantAdminDirectException exception) + { + reason = exception.Code; + } + } + + items.Add(new TenantAdminStudentImportPreviewItem( + rowNo, + reason is null, + reason, + phone, + email, + name, + row.RegionId, + row.ClassId)); + } + + return items; + } + + private async Task UpsertClassMemberCoreAsync( + TenantAdminActor actor, + Guid classId, + Guid userId, + TenantClassMemberType memberType, + TenantClassMemberStatus status, + JsonElement metadata, + CancellationToken cancellationToken) + { + var item = await dbContext.TenantClassMembers.FirstOrDefaultAsync(member => + member.TenantId == actor.TenantId && + member.ClassId == classId && + member.UserId == userId && + member.MemberType == memberType, + cancellationToken); + if (item is null) + { + item = new TenantClassMember + { + TenantId = actor.TenantId, + ClassId = classId, + UserId = userId, + MemberType = memberType, + JoinedAt = DateTimeOffset.UtcNow + }; + dbContext.TenantClassMembers.Add(item); + } + + item.Status = status; + item.LeftAt = status == TenantClassMemberStatus.Removed ? DateTimeOffset.UtcNow : null; + item.Metadata = JsonObjectOrDefault(metadata); + return item; + } + + private async Task> GetSupervisionRulesCoreAsync( + Guid tenantId, + CancellationToken cancellationToken) + { + var settings = await dbContext.TenantSettings.AsNoTracking().SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); + if (settings is null || + settings.AdminFeatureFlags.ValueKind != JsonValueKind.Object || + !settings.AdminFeatureFlags.TryGetProperty("supervisionRules", out var rulesElement) || + rulesElement.ValueKind != JsonValueKind.Array) + { + return []; + } + + return JsonSerializer.Deserialize(rulesElement.GetRawText()) ?? []; + } + + private async Task SaveSupervisionRulesCoreAsync( + Guid tenantId, + IReadOnlyCollection rules, + CancellationToken cancellationToken) + { + var settings = await dbContext.TenantSettings.SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); + if (settings is null) + { + settings = new TenantSettings { TenantId = tenantId }; + dbContext.TenantSettings.Add(settings); + } + + var existing = settings.AdminFeatureFlags.ValueKind == JsonValueKind.Object + ? JsonSerializer.Deserialize>(settings.AdminFeatureFlags.GetRawText()) ?? [] + : []; + existing["supervisionRules"] = JsonSerializer.SerializeToElement(rules); + settings.AdminFeatureFlags = JsonSerializer.SerializeToElement(existing); + } + + private async Task> BuildSupervisionRiskStudentsAsync( + TenantAdminActor actor, + CurrentDataScope scope, + CancellationToken cancellationToken) + { + var rules = (await GetSupervisionRulesCoreAsync(actor.TenantId, cancellationToken)) + .Where(rule => rule.Enabled) + .ToArray(); + if (rules.Length == 0) + { + return []; + } + + var regionIds = scope.RegionIds.ToArray(); + var students = await dbContext.StudentProfiles.AsNoTracking() + .Where(profile => profile.TenantId == actor.TenantId) + .ApplyDataScope( + scope, + profile => profile.UserId == actor.UserId, + profile => profile.RegionId.HasValue && regionIds.Contains(profile.RegionId.Value)) + .Select(profile => new + { + Profile = profile, + User = dbContext.Users.Where(user => user.Id == profile.UserId).FirstOrDefault() + }) + .ToArrayAsync(cancellationToken); + var today = DateOnly.FromDateTime(DateTime.UtcNow); + var result = new List(); + foreach (var row in students) + { + var hitRules = new List(); + var reasons = new List(); + foreach (var rule in rules) + { + if (rule.DaysWithoutCheckIn.HasValue) + { + var days = row.Profile.LastCheckInDate.HasValue + ? today.DayNumber - row.Profile.LastCheckInDate.Value.DayNumber + : int.MaxValue; + if (days >= rule.DaysWithoutCheckIn.Value) + { + hitRules.Add(rule.Code); + reasons.Add($"{rule.Title}: {days} days without check-in"); + } + } + + if (rule.MaxQuestionsAnsweredToday.HasValue && + row.Profile.QuestionsAnsweredToday <= rule.MaxQuestionsAnsweredToday.Value) + { + hitRules.Add(rule.Code); + reasons.Add($"{rule.Title}: questions answered today <= {rule.MaxQuestionsAnsweredToday.Value}"); + } + } + + if (hitRules.Count > 0) + { + result.Add(new TenantSupervisionRiskStudentItem( + row.Profile.UserId, + row.User?.Name, + MaskPhone(row.User?.Phone), + row.Profile.RegionId, + hitRules.Distinct(StringComparer.Ordinal).ToArray(), + reasons.Distinct(StringComparer.Ordinal).ToArray())); + } + } + + return result; + } + private async Task AssertStudentAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken) { var exists = await dbContext.TenantMemberships.AnyAsync( @@ -2261,6 +2824,19 @@ public sealed class TenantAdminDirectService( return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } + private static string NormalizeCode(string value) + { + return value.Trim().ToLowerInvariant(); + } + + private static string? MaskPhone(string? phone) + { + var value = Normalize(phone); + return value is { Length: >= 7 } + ? $"{value[..3]}****{value[^4..]}" + : value; + } + private static JsonElement JsonObjectOrDefault(JsonElement value) { return value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDefaults.Object(); diff --git a/Tiku.IntegrationTests/Api/AuthenticationTestClientExtensions.cs b/Tiku.IntegrationTests/Api/AuthenticationTestClientExtensions.cs index 9c14bcc..b55e8d0 100644 --- a/Tiku.IntegrationTests/Api/AuthenticationTestClientExtensions.cs +++ b/Tiku.IntegrationTests/Api/AuthenticationTestClientExtensions.cs @@ -34,6 +34,24 @@ internal static class AuthenticationTestClientExtensions return await client.CompleteTenantAuthenticationAsync(response, tenantId, identifier); } + public static async Task LoginAsPlatformAsync( + this HttpClient client, + string identifier, + string password = PasswordTestUserExtensions.TestPassword) + { + client.DefaultRequestHeaders.Remove("x-tenant-code"); + var response = await client.PostAsJsonAsync( + "/api/auth/login/password", + new PasswordLoginDto + { + Realm = AuthRealm.Platform, + Identifier = identifier, + Password = password + }); + + return await client.CompletePlatformAuthenticationAsync(response, identifier); + } + public static async Task CompleteTenantAuthenticationAsync( this HttpClient client, HttpResponseMessage response, @@ -96,6 +114,67 @@ internal static class AuthenticationTestClientExtensions throw new InvalidOperationException($"Unsupported test authentication status '{status}'."); } + private static async Task CompletePlatformAuthenticationAsync( + this HttpClient client, + HttpResponseMessage response, + string authenticatorCacheKey) + { + client.DefaultRequestHeaders.Remove("x-tenant-code"); + using var authentication = await ReadSuccessfulJsonAsync(response); + var root = authentication.RootElement; + var status = root.GetProperty("status").GetString(); + + if (string.Equals(status, "authenticated", StringComparison.OrdinalIgnoreCase)) + { + return ReadTokens(root.GetProperty("user").GetProperty("tokens")); + } + + var challengeToken = root.GetProperty("challengeToken").GetString() + ?? throw new InvalidOperationException("Authentication challenge did not contain a challenge token."); + var keyId = $"platform:{authenticatorCacheKey}"; + + if (string.Equals(status, "mfa_enrollment_required", StringComparison.OrdinalIgnoreCase)) + { + var setupResponse = await client.PostAsJsonAsync( + "/api/auth/mfa/totp/setup", + new MfaChallengeDto { ChallengeToken = challengeToken }); + using var setup = await ReadSuccessfulJsonAsync(setupResponse); + var sharedKey = setup.RootElement.GetProperty("sharedKey").GetString() + ?? throw new InvalidOperationException("MFA setup did not return a shared key."); + AuthenticatorKeys[keyId] = sharedKey; + + var confirmResponse = await client.PostAsJsonAsync( + "/api/auth/mfa/totp/confirm", + new MfaChallengeDto + { + ChallengeToken = challengeToken, + Code = GenerateTotp(sharedKey) + }); + using var confirmation = await ReadSuccessfulJsonAsync(confirmResponse); + return ReadTokens( + confirmation.RootElement + .GetProperty("authentication") + .GetProperty("user") + .GetProperty("tokens")); + } + + if (string.Equals(status, "mfa_required", StringComparison.OrdinalIgnoreCase) && + AuthenticatorKeys.TryGetValue(keyId, out var existingKey)) + { + var verifyResponse = await client.PostAsJsonAsync( + "/api/auth/mfa/totp/verify", + new MfaChallengeDto + { + ChallengeToken = challengeToken, + Code = GenerateTotp(existingKey) + }); + using var verification = await ReadSuccessfulJsonAsync(verifyResponse); + return ReadTokens(verification.RootElement.GetProperty("user").GetProperty("tokens")); + } + + throw new InvalidOperationException($"Unsupported test authentication status '{status}'."); + } + public static void UseAccessToken(this HttpClient client, TestAuthenticationTokens tokens) { client.DefaultRequestHeaders.Authorization = new("Bearer", tokens.AccessToken); diff --git a/Tiku.IntegrationTests/Api/PlatformAdminEndpointTests.cs b/Tiku.IntegrationTests/Api/PlatformAdminEndpointTests.cs new file mode 100644 index 0000000..091b0d3 --- /dev/null +++ b/Tiku.IntegrationTests/Api/PlatformAdminEndpointTests.cs @@ -0,0 +1,310 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Tiku.Api.Contracts; +using Tiku.Application.Security; +using Tiku.Domain.Commerce; +using Tiku.Domain.Common; +using Tiku.Domain.Identity; +using Tiku.Domain.Operations; +using Tiku.Domain.Platform; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Auth; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.IntegrationTests.Api; + +public sealed class PlatformAdminEndpointTests +{ + [Fact] + public async Task Platform_admin_can_manage_tenant_subscription_domain_recheck_and_audit() + { + await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary + { + ["Tenancy:Resolution:PlatformHosts:0"] = "localhost" + }); + var platform = await SeedPlatformAdminAsync(factory); + var tenantId = Guid.NewGuid(); + var domainId = Guid.NewGuid(); + await factory.SeedAsync( + new Tenant + { + Id = tenantId, + Slug = "tenant-six-a", + Name = "Tenant Six A", + Status = TenantStatus.Active, + BillingStatus = BillingStatus.Trial + }, + new TenantDomain + { + Id = domainId, + TenantId = tenantId, + Host = "six-a.example.test", + Status = TenantDomainStatus.Active, + IsPrimary = true, + VerificationToken = "verify-six-a", + VerifiedAt = DateTimeOffset.UtcNow, + DnsVerifiedAt = DateTimeOffset.UtcNow, + TlsReadyAt = DateTimeOffset.UtcNow + }, + new PlatformSaasPlan + { + Code = "standard", + Name = "Standard", + BaseAmountCents = 99900, + Status = PlatformSaasPlanStatus.Active + }); + + using var client = factory.CreateClient(); + client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email)); + + var overview = await client.GetAsync("/api/platform-admin/overview"); + var tenants = await client.GetAsync("/api/platform-admin/tenants?search=six-a"); + var subscription = await client.PostAsJsonAsync( + "/api/platform-admin/subscriptions", + new UpsertPlatformSubscriptionDto + { + TenantId = tenantId, + PlanCode = "standard", + Status = TenantSubscriptionStatus.Active, + StartsAt = DateTimeOffset.UtcNow.AddDays(-1), + ExpiresAt = DateTimeOffset.UtcNow.AddDays(30), + AmountCents = 99900 + }); + var recheck = await client.PostAsync($"/api/platform-admin/domains/{domainId}/recheck", null); + var suspended = await client.PatchAsJsonAsync( + "/api/platform-admin/tenants/status", + new UpdatePlatformTenantStatusDto + { + TenantId = tenantId, + Status = TenantStatus.Suspended, + BillingStatus = BillingStatus.PastDue, + Reason = "integration test suspension" + }); + + using var runtimeRequest = new HttpRequestMessage(HttpMethod.Get, "/api/runtime/bootstrap"); + runtimeRequest.Headers.Host = "six-a.example.test"; + var runtimeAfterSuspend = await client.SendAsync(runtimeRequest); + + Assert.Equal(HttpStatusCode.OK, overview.StatusCode); + Assert.Equal(HttpStatusCode.OK, tenants.StatusCode); + Assert.Equal(HttpStatusCode.OK, subscription.StatusCode); + Assert.Equal(HttpStatusCode.OK, recheck.StatusCode); + Assert.Equal(HttpStatusCode.OK, suspended.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, runtimeAfterSuspend.StatusCode); + + using var scope = factory.CreateSystemScope("Verify platform admin side effects"); + var dbContext = scope.ServiceProvider.GetRequiredService(); + Assert.True(await dbContext.BackgroundJobs.AnyAsync(job => + job.TenantId == tenantId && + job.JobType == "tenant_domain_recheck")); + Assert.True(await dbContext.AuditLogs.AnyAsync(log => + log.ActorUserId == platform.UserId && + log.Action == "platform.tenant.status_changed")); + } + + [Fact] + public async Task Tenant_token_cannot_access_platform_admin_endpoints() + { + await using var factory = new ApiTestFactory(); + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var phone = "13866660000"; + await factory.SeedAsync( + new Tenant + { + Id = tenantId, + Slug = tenantId.ToString("N"), + Name = "Tenant Realm" + }, + new User + { + Id = userId, + Phone = phone, + Name = "Tenant Admin" + }.WithTestPassword(), + new TenantMembership + { + TenantId = tenantId, + UserId = userId, + Role = TenantRole.TenantAdmin, + Status = MembershipStatus.Active + }); + using var client = factory.CreateClient(); + client.UseAccessToken(await client.LoginAsTenantAsync(tenantId, phone)); + + var response = await client.GetAsync("/api/platform-admin/overview"); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task Platform_admin_can_manage_dunning_channels_and_retry_events() + { + await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary + { + ["Tenancy:Resolution:PlatformHosts:0"] = "localhost" + }); + var platform = await SeedPlatformAdminAsync(factory); + var tenantId = Guid.NewGuid(); + var invoiceId = Guid.NewGuid(); + var reminderId = Guid.NewGuid(); + await factory.SeedAsync( + new Tenant + { + Id = tenantId, + Slug = "tenant-dunning-a", + Name = "Tenant Dunning A", + Status = TenantStatus.Active, + BillingStatus = BillingStatus.PastDue + }, + new TenantInvoice + { + Id = invoiceId, + TenantId = tenantId, + InvoiceNo = "INV-DUNNING-1", + Status = TenantInvoiceStatus.Overdue, + TotalCents = 10_000, + BalanceCents = 10_000 + }, + new TenantInvoiceReminder + { + Id = reminderId, + TenantId = tenantId, + InvoiceId = invoiceId, + ReminderType = TenantInvoiceReminderType.Overdue, + Channel = TenantInvoiceReminderChannel.Wechat, + Status = TenantInvoiceReminderStatus.Failed, + ReminderDate = DateOnly.FromDateTime(DateTime.UtcNow.Date), + BalanceCentsSnapshot = 10_000 + }); + using var client = factory.CreateClient(); + client.UseAccessToken(await client.LoginAsPlatformAsync(platform.Email)); + + var upsertResponse = await client.PutAsJsonAsync( + "/api/platform-admin/dunning-notification-channels", + new UpsertPlatformDunningChannelDto + { + ChannelCode = "wecom-overdue", + Name = "企业微信逾期提醒", + Provider = PlatformDunningProvider.Wecom, + WebhookUrl = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret-key", + SecretRef = "platform_secrets:dunning:wecom:default", + ReminderTypes = ["overdue"], + ReminderChannels = ["wechat"], + MinReminderLevel = 2, + TenantIds = [tenantId] + }); + var upsertBody = await upsertResponse.Content.ReadAsStringAsync(); + var channelJson = JsonDocument.Parse(upsertBody); + var channelId = channelJson.RootElement.GetProperty("id").GetGuid(); + await factory.SeedAsync(new PlatformDunningNotificationEvent + { + TenantId = tenantId, + ChannelId = channelId, + ReminderId = reminderId, + InvoiceId = invoiceId, + Provider = PlatformDunningProvider.Wecom, + Status = PlatformDunningNotificationStatus.Failed, + Attempts = 2, + LastError = "timeout", + LastHttpCode = 500, + LastResponseSummary = "server error", + RequestPayload = JsonSerializer.SerializeToElement(new { phone = "13800001111", amount = 10000 }) + }); + + var channelsResponse = await client.GetAsync("/api/platform-admin/dunning-notification-channels?search=wecom"); + var eventsResponse = await client.GetAsync("/api/platform-admin/dunning-notification-events?status=failed"); + var eventsJson = await JsonDocument.ParseAsync(await eventsResponse.Content.ReadAsStreamAsync()); + var eventId = eventsJson.RootElement.GetProperty("items")[0].GetProperty("id").GetGuid(); + var detailResponse = await client.GetAsync($"/api/platform-admin/dunning-notification-events/detail?eventId={eventId}"); + var retryResponse = await client.PostAsJsonAsync( + "/api/platform-admin/dunning-notification-events/retry", + new RetryPlatformDunningEventDto + { + EventId = eventId, + Reason = "manual retry" + }); + var disableResponse = await client.PostAsJsonAsync( + "/api/platform-admin/dunning-notification-channels/disable", + new DisablePlatformDunningChannelDto + { + ChannelId = channelId, + Reason = "disable test" + }); + + Assert.Equal(HttpStatusCode.OK, upsertResponse.StatusCode); + Assert.DoesNotContain("secret-key", upsertBody, StringComparison.OrdinalIgnoreCase); + Assert.Contains("https://qyapi.weixin.qq.com/****", upsertBody, StringComparison.OrdinalIgnoreCase); + Assert.Equal(HttpStatusCode.OK, channelsResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, eventsResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, retryResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, disableResponse.StatusCode); + + using var scope = factory.CreateSystemScope("Verify platform dunning side effects"); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var storedEvent = await dbContext.PlatformDunningNotificationEvents.AsNoTracking().SingleAsync(item => item.Id == eventId); + var storedChannel = await dbContext.PlatformDunningNotificationChannels.AsNoTracking().SingleAsync(item => item.Id == channelId); + Assert.Equal(PlatformDunningNotificationStatus.Pending, storedEvent.Status); + Assert.Null(storedEvent.LastError); + Assert.False(storedChannel.Enabled); + Assert.True(await dbContext.AuditLogs.AnyAsync(log => + log.ActorUserId == platform.UserId && + log.Action == "platform.dunning_event.retry_requested")); + Assert.True(await dbContext.AuditLogs.AnyAsync(log => + log.ActorUserId == platform.UserId && + log.Action == "platform.dunning_channel.disabled")); + } + + private static async Task<(Guid UserId, string Email)> SeedPlatformAdminAsync(ApiTestFactory factory) + { + var userId = Guid.NewGuid(); + var roleId = Guid.NewGuid(); + var email = $"platform-{Guid.NewGuid():N}@example.test"; + var permissions = BackendPermissions.Platform.Select(code => new BackendPermission + { + Code = code, + Name = code, + Area = BackendPermissionArea.Platform, + Module = code.Split(':')[1], + IsSystem = true + }).Cast().ToList(); + await factory.SeedAsync( + [ + ..permissions, + new User + { + Id = userId, + Email = email, + NormalizedEmail = email.ToUpperInvariant(), + UserName = email, + NormalizedUserName = email.ToUpperInvariant(), + Name = "Platform Admin", + PrimaryRole = "platform_admin", + RawProfile = JsonDefaults.Object() + }.WithTestPassword(), + new PlatformBackendRole + { + Id = roleId, + Code = "platform_super_admin", + Name = "Platform Super Admin", + Status = BackendRoleStatus.Active, + IsSystem = true + }, + ..BackendPermissions.Platform.Select(code => new PlatformBackendRolePermission + { + RoleId = roleId, + PermissionCode = code + }), + new PlatformBackendUserRole + { + UserId = userId, + RoleId = roleId + } + ]); + return (userId, email); + } +} diff --git a/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs b/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs index 0db84e7..b933eda 100644 --- a/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/TenantAdminDirectEndpointTests.cs @@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Tiku.Api.Contracts; using Tiku.Domain.Catalog; +using Tiku.Domain.Commerce; using Tiku.Domain.Common; using Tiku.Domain.Identity; using Tiku.Domain.Learning; @@ -17,6 +18,177 @@ namespace Tiku.IntegrationTests.Api; public sealed class TenantAdminDirectEndpointTests { + [Fact] + public async Task Tenant_admin_can_view_operational_overview() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedAdminAsync(factory); + var studentId = Guid.NewGuid(); + var classId = Guid.NewGuid(); + await factory.SeedAsync( + new User { Id = studentId, Phone = "13900009901", Name = "概览学生" }, + new TenantMembership { TenantId = seed.TenantId, UserId = studentId, Role = TenantRole.Student, Status = MembershipStatus.Active }, + new StudentProfile { TenantId = seed.TenantId, UserId = studentId }, + new TenantClass { Id = classId, TenantId = seed.TenantId, Name = "概览班级", Status = TenantRecordStatus.Active }, + new TenantStudentFollowup + { + TenantId = seed.TenantId, + StudentUserId = studentId, + Title = "待跟进", + Status = StudentFollowupStatus.Open + }, + new UserNotification + { + TenantId = seed.TenantId, + UserId = studentId, + NotificationType = "admin", + Title = "通知", + Message = "概览通知", + Status = NotificationStatus.Unread + }, + new Order + { + TenantId = seed.TenantId, + UserId = studentId, + OrderNo = "OVERVIEW-ORDER", + Status = OrderStatus.Paid, + AmountCents = 2_000, + PaidAt = DateTimeOffset.UtcNow + }); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + var response = await client.GetAsync("/api/tenant-admin/overview"); + var body = await ReadJsonAsync(response); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(1, body.RootElement.GetProperty("studentCount").GetInt32()); + Assert.Equal(1, body.RootElement.GetProperty("classCount").GetInt32()); + Assert.Equal(1, body.RootElement.GetProperty("pendingFollowupCount").GetInt32()); + Assert.Equal(1, body.RootElement.GetProperty("unreadNotificationCount").GetInt32()); + Assert.Equal(1, body.RootElement.GetProperty("paidOrderCount").GetInt32()); + Assert.Equal(2_000, body.RootElement.GetProperty("revenueCents").GetInt32()); + } + + [Fact] + public async Task Tenant_admin_can_run_student_bulk_operations_supervision_and_reports() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedAdminAsync(factory); + var regionId = Guid.NewGuid(); + var classId = Guid.NewGuid(); + var pointTaskId = Guid.NewGuid(); + var pointItemId = Guid.NewGuid(); + var existingStudentId = Guid.NewGuid(); + await factory.SeedAsync( + new Region { Id = regionId, TenantId = seed.TenantId, Name = "批量区域" }, + new TenantClass { Id = classId, TenantId = seed.TenantId, RegionId = regionId, Name = "批量班级", Status = TenantRecordStatus.Active }, + new User { Id = existingStudentId, Phone = "13900008888", Name = "已有学生", Score = -10 }, + new TenantMembership { TenantId = seed.TenantId, UserId = existingStudentId, Role = TenantRole.Student, Status = MembershipStatus.Active }, + new StudentProfile + { + TenantId = seed.TenantId, + UserId = existingStudentId, + RegionId = regionId, + LastCheckInDate = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-10)), + QuestionsAnsweredToday = 0 + }, + new Report { TenantId = seed.TenantId, UserId = existingStudentId, Status = ReportStatus.Pending, Type = ReportType.Suggestion }, + new PointActivityTask { Id = pointTaskId, TenantId = seed.TenantId, TaskKey = "bulk-risk", Title = "风险任务", Points = 1000 }, + new PointActivityClaim + { + TenantId = seed.TenantId, + TaskId = pointTaskId, + UserId = existingStudentId, + TaskKey = "bulk-risk", + Points = 1000, + Status = PointActivityClaimStatus.Claimed + }, + new PointExchangeItem { Id = pointItemId, TenantId = seed.TenantId, ItemKey = "risk-item", Name = "风险兑换", PointsCost = 10 }, + new PointExchangeOrder + { + TenantId = seed.TenantId, + ItemId = pointItemId, + UserId = existingStudentId, + OrderNo = "POINT-RISK-1", + ItemName = "风险兑换", + Status = PointExchangeOrderStatus.Cancelled, + PointsCost = 10 + }); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + var importRequest = new TenantAdminStudentImportDto + { + Rows = + [ + new TenantAdminStudentImportRowDto + { + User = new TenantAdminUserLookupDto { Phone = "13900007777", Name = "批量学生" }, + RegionId = regionId, + ClassId = classId + }, + new TenantAdminStudentImportRowDto() + ] + }; + + var previewResponse = await client.PostAsJsonAsync("/api/tenant-admin/students/import/preview", importRequest); + var importResponse = await client.PostAsJsonAsync("/api/tenant-admin/students/import", importRequest); + var importJson = await ReadJsonAsync(importResponse); + var importedUserId = await GetUserIdByPhoneAsync(factory, "13900007777"); + var assignResponse = await client.PostAsJsonAsync( + "/api/tenant-admin/students/bulk-assign-class", + new TenantAdminBulkAssignClassDto { ClassId = classId, UserIds = [existingStudentId, importedUserId] }); + var statusResponse = await client.PostAsJsonAsync( + "/api/tenant-admin/students/bulk-status", + new TenantAdminBulkStatusDto { UserIds = [importedUserId], Status = "disabled", Reason = "batch test" }); + var ruleResponse = await client.PutAsJsonAsync( + "/api/tenant-admin/students/supervision/rules", + new UpsertTenantSupervisionRuleDto + { + Code = "inactive", + Title = "长时间未学习", + DaysWithoutCheckIn = 3, + MaxQuestionsAnsweredToday = 0 + }); + var rulesResponse = await client.GetAsync("/api/tenant-admin/students/supervision/rules"); + var previewRiskResponse = await client.GetAsync("/api/tenant-admin/students/supervision/preview"); + var generateResponse = await client.PostAsJsonAsync( + "/api/tenant-admin/students/supervision/generate", + new TenantSupervisionGenerateDto { UserIds = [existingStudentId], AssignedToUserId = seed.UserId }); + var followupReportResponse = await client.GetAsync("/api/tenant-admin/student-followups/report"); + var feedbackReportResponse = await client.GetAsync("/api/tenant-admin/feedbacks/report"); + var pointRiskReportResponse = await client.GetAsync("/api/tenant-admin/points/risk-report"); + + Assert.Equal(HttpStatusCode.OK, previewResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, importResponse.StatusCode); + Assert.Equal(1, importJson.RootElement.GetProperty("createdOrUpdatedCount").GetInt32()); + Assert.Equal(1, importJson.RootElement.GetProperty("invalidItems").GetArrayLength()); + Assert.Equal(HttpStatusCode.OK, assignResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, statusResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, ruleResponse.StatusCode); + Assert.Contains("inactive", await rulesResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); + Assert.Contains(existingStudentId.ToString(), await previewRiskResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); + Assert.Equal(HttpStatusCode.OK, generateResponse.StatusCode); + Assert.Contains("openCount", await followupReportResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); + Assert.Contains("pendingCount", await feedbackReportResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); + Assert.Contains("negativeScoreUserCount", await pointRiskReportResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); + + using var scope = factory.CreateSystemScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + Assert.True(await dbContext.TenantClassMembers.AnyAsync(item => + item.TenantId == seed.TenantId && + item.ClassId == classId && + item.UserId == importedUserId)); + Assert.True(await dbContext.TenantMemberships.AnyAsync(item => + item.TenantId == seed.TenantId && + item.UserId == importedUserId && + item.Status == MembershipStatus.Disabled)); + Assert.True(await dbContext.TenantStudentFollowups.AnyAsync(item => + item.TenantId == seed.TenantId && + item.StudentUserId == existingStudentId && + item.FollowupType == StudentFollowupType.Risk)); + } + [Fact] public async Task Tenant_admin_can_manage_classes_members_and_students() { @@ -404,6 +576,16 @@ public sealed class TenantAdminDirectEndpointTests client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone)); } + private static async Task GetUserIdByPhoneAsync(ApiTestFactory factory, string phone) + { + using var scope = factory.CreateSystemScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + return await dbContext.Users + .Where(user => user.Phone == phone) + .Select(user => user.Id) + .SingleAsync(); + } + private static async Task ReadJsonAsync(HttpResponseMessage response) { var stream = await response.Content.ReadAsStreamAsync(); diff --git a/Tiku.IntegrationTests/Api/TenantCommerceEndpointTests.cs b/Tiku.IntegrationTests/Api/TenantCommerceEndpointTests.cs index 5c18633..2083e2e 100644 --- a/Tiku.IntegrationTests/Api/TenantCommerceEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/TenantCommerceEndpointTests.cs @@ -1,14 +1,18 @@ using System.Net; using System.Net.Http.Json; using System.Text.Json; +using System.Text.Json.Serialization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Tiku.Api.Contracts; using Tiku.Application.Auth; using Tiku.Application.Commerce; +using Tiku.Application.Jobs; using Tiku.Domain.Catalog; using Tiku.Domain.Commerce; using Tiku.Domain.Identity; +using Tiku.Domain.Learning; +using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Persistence; @@ -17,6 +21,11 @@ namespace Tiku.IntegrationTests.Api; public sealed class TenantCommerceEndpointTests { + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter() } + }; + [Fact] public async Task Non_admin_cannot_access_tenant_commerce_operations() { @@ -152,6 +161,89 @@ public sealed class TenantCommerceEndpointTests item.SourceType == "activation_code"); } + [Fact] + public async Task Commerce_reconciliation_worker_does_not_succeed_without_active_provider_config() + { + await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway()); + var tenantId = Guid.NewGuid(); + await factory.SeedAsync(new Tenant + { + Id = tenantId, + Slug = tenantId.ToString("N"), + Name = "Worker Provider Required" + }); + using var scope = factory.CreateSystemScope(); + var jobService = scope.ServiceProvider.GetRequiredService(); + var job = await jobService.EnqueueAsync(new CreateBackgroundJobCommand( + tenantId, + "commerce_reconciliation", + JsonSerializer.SerializeToElement(new + { + provider = PaymentProviders.WechatPay, + billDate = DateOnly.FromDateTime(DateTime.UtcNow.Date), + billType = ReconciliationBillType.Combined.ToString() + }))); + + var processed = await jobService.ProcessPendingAsync("integration-worker", 10); + + var dbContext = scope.ServiceProvider.GetRequiredService(); + var storedJob = await dbContext.BackgroundJobs.AsNoTracking().SingleAsync(item => item.Id == job.Id); + Assert.Equal(1, processed); + Assert.Equal(BackgroundJobStatus.Pending, storedJob.Status); + Assert.Equal(1, storedJob.RetryCount); + Assert.Contains("Active payment provider", storedJob.LastError, StringComparison.OrdinalIgnoreCase); + Assert.False(await dbContext.CommerceReconciliationBatches.AnyAsync(item => item.TenantId == tenantId)); + } + + [Fact] + public async Task Worker_processes_content_export_and_statistics_aggregation() + { + await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway()); + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + await factory.SeedAsync( + new Tenant + { + Id = tenantId, + Slug = tenantId.ToString("N"), + Name = "Worker Export Tenant" + }, + new User { Id = userId, Phone = "13900006666", Name = "Worker Student" }, + new StudentProfile { TenantId = tenantId, UserId = userId }, + new PracticeSession { TenantId = tenantId, UserId = userId, QuestionCount = 1 }, + new Order + { + TenantId = tenantId, + UserId = userId, + OrderNo = "WORKER-STATS-ORDER", + Status = OrderStatus.Paid, + AmountCents = 800, + PaidAt = DateTimeOffset.UtcNow + }); + using var scope = factory.CreateSystemScope(); + var jobService = scope.ServiceProvider.GetRequiredService(); + var exportJob = await jobService.EnqueueAsync(new CreateBackgroundJobCommand( + tenantId, + "content_export", + JsonSerializer.SerializeToElement(new { exportType = "students" }))); + var statsJob = await jobService.EnqueueAsync(new CreateBackgroundJobCommand( + tenantId, + "statistics_aggregation", + JsonSerializer.SerializeToElement(new { scope = "tenant" }))); + + var processed = await jobService.ProcessPendingAsync("integration-worker", 10); + + var dbContext = scope.ServiceProvider.GetRequiredService(); + var storedExportJob = await dbContext.BackgroundJobs.AsNoTracking().SingleAsync(item => item.Id == exportJob.Id); + var storedStatsJob = await dbContext.BackgroundJobs.AsNoTracking().SingleAsync(item => item.Id == statsJob.Id); + Assert.Equal(2, processed); + Assert.Equal(BackgroundJobStatus.Succeeded, storedExportJob.Status); + Assert.NotNull(storedExportJob.OutputAssetId); + Assert.True(await dbContext.ContentAssets.AnyAsync(item => item.Id == storedExportJob.OutputAssetId)); + Assert.Equal(BackgroundJobStatus.Succeeded, storedStatsJob.Status); + Assert.Equal(800, storedStatsJob.Result.GetProperty("revenueCents").GetInt32()); + } + [Fact] public async Task Admin_can_manage_points_and_coupons() { @@ -252,6 +344,266 @@ public sealed class TenantCommerceEndpointTests Assert.Equal(HttpStatusCode.OK, allowedRefund.StatusCode); } + [Fact] + public async Task Admin_can_preview_import_and_query_reconciliation_operations() + { + await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway()); + var seed = await SeedLoginUserAsync(factory, TenantRole.TenantAdmin); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + var rows = JsonSerializer.SerializeToElement(new object[] + { + new + { + transactionType = "payment", + providerTradeNo = "wx-trade-1", + orderNo = "ORDER-1", + amountCents = 1000, + providerStatus = "paid", + localStatus = "paid", + matchStatus = "matched" + }, + new + { + transactionType = "refund", + providerTradeNo = "wx-trade-2", + providerRefundNo = "wx-refund-2", + orderNo = "ORDER-2", + refundNo = "REFUND-2", + refundAmountCents = 300, + providerStatus = "succeeded", + localStatus = "processing", + matchStatus = "status_mismatch", + issueCode = "refund_status_mismatch" + } + }); + var request = new PreviewReconciliationImportDto + { + Provider = PaymentProviders.WechatPay, + BillDate = DateOnly.FromDateTime(DateTime.UtcNow.Date), + BillType = ReconciliationBillType.Combined, + SourceName = "wechat-bill.csv", + Rows = rows + }; + + var previewResponse = await client.PostAsJsonAsync("/api/tenant-commerce/reconciliation/preview", request); + var preview = await previewResponse.Content.ReadFromJsonAsync(JsonOptions); + var importResponse = await client.PostAsJsonAsync("/api/tenant-commerce/reconciliation/import", request); + var batch = await importResponse.Content.ReadFromJsonAsync(JsonOptions); + var itemsResponse = await client.GetAsync($"/api/tenant-commerce/reconciliation/items?batchId={batch!.Id}"); + var items = await itemsResponse.Content.ReadFromJsonAsync(JsonOptions); + var issuesResponse = await client.GetAsync("/api/tenant-commerce/reconciliation/issues"); + var issues = await issuesResponse.Content.ReadFromJsonAsync(JsonOptions); + var issueEventsResponse = await client.GetAsync($"/api/tenant-commerce/reconciliation/issues/events?issueId={issues!.Items.First().Id}"); + var anomalies = await client.GetFromJsonAsync("/api/tenant-commerce/reconciliation/anomalies", JsonOptions); + var accountResponse = await client.PutAsJsonAsync( + "/api/tenant-commerce/payment-accounts", + new UpsertPaymentAccountDto + { + Provider = PaymentProviders.WechatPay, + Status = TenantExternalProviderStatus.Active, + ConfigPublic = JsonSerializer.SerializeToElement(new { merchantId = "mch" }) + }); + var jobResponse = await client.PostAsJsonAsync( + "/api/tenant-commerce/reconciliation/provider-bills/request", + new RequestProviderBillJobDto + { + Provider = PaymentProviders.WechatPay, + BillDate = request.BillDate, + BillType = ReconciliationBillType.Combined + }); + var jobsResponse = await client.GetAsync("/api/tenant-commerce/reconciliation/provider-bills/jobs?limit=5"); + var jobs = await jobsResponse.Content.ReadFromJsonAsync>(JsonOptions); + using var scope = factory.CreateSystemScope(); + var jobService = scope.ServiceProvider.GetRequiredService(); + var processedJobs = await jobService.ProcessPendingAsync("integration-worker", 10); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var providerBatch = await dbContext.CommerceReconciliationBatches.AsNoTracking() + .SingleAsync(item => + item.TenantId == seed.TenantId && + item.Provider == PaymentProviders.WechatPay && + item.Source == ReconciliationSource.ProviderDownload); + var storedJob = await dbContext.BackgroundJobs.AsNoTracking() + .SingleAsync(item => item.TenantId == seed.TenantId && item.JobType == "commerce_reconciliation"); + + Assert.Equal(HttpStatusCode.OK, previewResponse.StatusCode); + Assert.NotNull(preview); + Assert.Equal(2, preview.TotalCount); + Assert.Equal(1, preview.PaymentCount); + Assert.Equal(1, preview.RefundCount); + Assert.Equal(1, preview.InvalidCount); + Assert.Equal(1000, preview.AmountCents); + Assert.Equal(300, preview.RefundAmountCents); + Assert.Equal(HttpStatusCode.OK, importResponse.StatusCode); + Assert.Equal(ReconciliationBatchStatus.CompletedWithIssues, batch.Status); + Assert.Equal(HttpStatusCode.OK, itemsResponse.StatusCode); + Assert.NotNull(items); + Assert.Equal(2, items.Items.Count); + Assert.Equal(HttpStatusCode.OK, issuesResponse.StatusCode); + Assert.Single(issues.Items); + Assert.Equal(HttpStatusCode.OK, issueEventsResponse.StatusCode); + Assert.NotNull(anomalies); + Assert.True(anomalies.OpenReconciliationIssueCount >= 1); + Assert.True(anomalies.PaymentMismatchCount >= 1); + Assert.Equal(HttpStatusCode.OK, accountResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, jobResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, jobsResponse.StatusCode); + Assert.NotNull(jobs); + Assert.Contains(jobs, item => item.JobType == "commerce_reconciliation"); + Assert.True(processedJobs >= 1); + Assert.Equal(ReconciliationBatchStatus.Pending, providerBatch.Status); + Assert.Equal(BackgroundJobStatus.Succeeded, storedJob.Status); + Assert.Equal(providerBatch.Id, storedJob.Result.GetProperty("batchId").GetGuid()); + } + + [Fact] + public async Task Refund_notification_is_idempotent_and_updates_order_once() + { + await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway()); + var seed = await SeedLoginUserAsync(factory, TenantRole.TenantAdmin); + var order = NewPaidOrder(seed.TenantId, seed.UserId, null, "REFUND-NOTIFY-ORDER"); + var payment = new Payment + { + TenantId = seed.TenantId, + OrderId = order.Id, + Provider = PaymentProviders.WechatPay, + Method = "jsapi", + Status = PaymentStatus.Paid, + AmountCents = order.AmountCents, + ProviderTradeNo = "wx-paid-trade", + PaidAt = DateTimeOffset.UtcNow + }; + var refund = new CommerceRefundRequest + { + TenantId = seed.TenantId, + OrderId = order.Id, + PaymentId = payment.Id, + RequestedBy = seed.UserId, + ReviewedBy = seed.UserId, + ProcessedBy = seed.UserId, + RefundNo = "REFUND-NOTIFY-1", + Provider = PaymentProviders.WechatPay, + Status = CommerceRefundStatus.Processing, + AmountCents = 400, + Reason = "integration test", + RequestedAt = DateTimeOffset.UtcNow, + ReviewedAt = DateTimeOffset.UtcNow, + ProcessedAt = DateTimeOffset.UtcNow + }; + await factory.SeedAsync(order, payment, refund); + using var client = factory.CreateClient(); + var request = new RefundNotificationDto + { + RefundNo = refund.RefundNo, + ProviderRefundNo = "wx-refund-notify-1", + Status = CommerceRefundStatus.Succeeded, + EventId = "refund-event-1", + Payload = JsonSerializer.SerializeToElement(new { status = "SUCCESS" }) + }; + + var first = await client.PostAsJsonAsync($"/api/commerce/refunds/notify/wechat_pay?tenantCode={seed.TenantId:N}", request); + var second = await client.PostAsJsonAsync($"/api/commerce/refunds/notify/wechat_pay?tenantCode={seed.TenantId:N}", request); + + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + using var scope = factory.CreateSystemScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var storedOrder = await dbContext.Orders.AsNoTracking().SingleAsync(item => item.Id == order.Id); + var storedPayment = await dbContext.Payments.AsNoTracking().SingleAsync(item => item.Id == payment.Id); + var storedRefund = await dbContext.CommerceRefundRequests.AsNoTracking().SingleAsync(item => item.Id == refund.Id); + var matchingEvents = await dbContext.PaymentEvents.AsNoTracking() + .CountAsync(item => + item.TenantId == seed.TenantId && + item.Provider == PaymentProviders.WechatPay && + item.EventType == "refund" && + item.EventId == "refund-event-1"); + var refundEvents = await dbContext.CommerceRefundEvents.AsNoTracking() + .CountAsync(item => item.TenantId == seed.TenantId && item.RefundRequestId == refund.Id); + + Assert.Equal(400, storedOrder.RefundedAmountCents); + Assert.Equal(OrderStatus.PartiallyRefunded, storedOrder.Status); + Assert.Equal(400, storedPayment.RefundedAmountCents); + Assert.Equal(PaymentStatus.PartiallyRefunded, storedPayment.Status); + Assert.Equal(CommerceRefundStatus.Succeeded, storedRefund.Status); + Assert.Equal("wx-refund-notify-1", storedRefund.ProviderRefundNo); + Assert.Equal(1, matchingEvents); + Assert.Equal(1, refundEvents); + } + + [Fact] + public async Task Admin_can_create_review_and_report_adjustment_vouchers() + { + await using var factory = new ApiTestFactory(paymentProviderGateway: new FakePaymentGateway()); + var seed = await SeedLoginUserAsync(factory, TenantRole.TenantAdmin); + var order = NewPaidOrder(seed.TenantId, seed.UserId, null, "ADJUSTMENT-ORDER"); + var payment = new Payment + { + TenantId = seed.TenantId, + OrderId = order.Id, + Provider = PaymentProviders.WechatPay, + Method = "jsapi", + Status = PaymentStatus.Paid, + AmountCents = order.AmountCents, + ProviderTradeNo = "adjustment-trade", + PaidAt = DateTimeOffset.UtcNow + }; + await factory.SeedAsync(order, payment); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + var createResponse = await client.PostAsJsonAsync( + "/api/tenant-commerce/adjustment-vouchers", + new CreateAdjustmentVoucherDto + { + OrderId = order.Id, + PaymentId = payment.Id, + Direction = CommerceAdjustmentDirection.IncreaseRevenue, + AmountCents = 100, + Reason = "manual reconciliation adjustment", + ProofAssetKey = "proofs/adjustment.txt" + }); + var created = await createResponse.Content.ReadFromJsonAsync(JsonOptions); + var pendingResponse = await client.PostAsJsonAsync( + "/api/tenant-commerce/adjustment-vouchers/status", + new UpdateAdjustmentVoucherStatusDto + { + VoucherId = created!.Id, + Status = CommerceAdjustmentVoucherStatus.PendingReview, + Note = "submit" + }); + var approvedResponse = await client.PostAsJsonAsync( + "/api/tenant-commerce/adjustment-vouchers/status", + new UpdateAdjustmentVoucherStatusDto + { + VoucherId = created.Id, + Status = CommerceAdjustmentVoucherStatus.Approved, + Note = "approved" + }); + var detailResponse = await client.GetAsync($"/api/tenant-commerce/adjustment-vouchers/detail?voucherId={created.Id}"); + var eventsResponse = await client.GetAsync($"/api/tenant-commerce/adjustment-vouchers/events?voucherId={created.Id}"); + var listResponse = await client.GetAsync("/api/tenant-commerce/adjustment-vouchers?status=approved"); + var reportResponse = await client.GetAsync("/api/tenant-commerce/adjustment-vouchers/report"); + + Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, pendingResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, approvedResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, eventsResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); + Assert.Contains("increaseRevenueCents", await reportResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase); + + using var scope = factory.CreateSystemScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + Assert.Equal(CommerceAdjustmentVoucherStatus.Approved, await dbContext.CommerceAdjustmentVouchers + .Where(item => item.Id == created.Id) + .Select(item => item.Status) + .SingleAsync()); + Assert.Equal(3, await dbContext.CommerceAdjustmentVoucherEvents.CountAsync(item => item.VoucherId == created.Id)); + Assert.True(await dbContext.AuditLogs.AnyAsync(item => + item.TenantId == seed.TenantId && + item.Action == "commerce.adjustment_voucher.status_changed")); + } + private static async Task SeedLoginUserAsync(ApiTestFactory factory, TenantRole role) { var tenantId = Guid.NewGuid(); @@ -286,7 +638,7 @@ public sealed class TenantCommerceEndpointTests client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone)); } - private static Order NewPaidOrder(Guid tenantId, Guid userId, Guid regionId, string orderNo) => new() + private static Order NewPaidOrder(Guid tenantId, Guid userId, Guid? regionId, string orderNo) => new() { TenantId = tenantId, UserId = userId,