diff --git a/Tiku.Api/Controllers/PlatformQuestionBanksController.cs b/Tiku.Api/Controllers/PlatformQuestionBanksController.cs new file mode 100644 index 0000000..74186ca --- /dev/null +++ b/Tiku.Api/Controllers/PlatformQuestionBanksController.cs @@ -0,0 +1,161 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tiku.Api.Contracts; +using Tiku.Application.Assets; +using Tiku.Application.PlatformAdmin; +using Tiku.Application.Security; + +namespace Tiku.Api.Controllers; + +[ApiController] +[Tags("平台端-公共题库")] +[Authorize(Policy = BackendPermissions.PlatformQuestionBankManage)] +[Produces("application/json")] +[Route("api/platform-admin/question-banks")] +public sealed class PlatformQuestionBanksController( + IPlatformQuestionBankService service, + ICurrentUser currentUser) : ControllerBase +{ + [HttpGet] + [EndpointSummary("查询平台公共题库")] + public async Task>> GetBanks( + [FromQuery] string? keyword, + [FromQuery] string? status, + CancellationToken cancellationToken) + { + return Ok(await service.GetBanksAsync(ResolveActor(), new PlatformQuestionBankFilter(Keyword: keyword, Status: status), cancellationToken)); + } + + [HttpPut] + [EndpointSummary("新增或更新平台公共题库")] + public async Task> UpsertBank( + UpsertPlatformQuestionBankCommand request, + CancellationToken cancellationToken) + { + return Ok(await service.UpsertBankAsync(ResolveActor(), request, cancellationToken)); + } + + [HttpPost("{bankId:guid}/archive")] + [EndpointSummary("归档平台公共题库")] + public async Task> ArchiveBank(Guid bankId, CancellationToken cancellationToken) + { + return Ok(await service.ArchiveBankAsync(ResolveActor(), bankId, cancellationToken)); + } + + [HttpGet("{bankId:guid}/nodes")] + [EndpointSummary("查询公共题库内容结构")] + public async Task>> GetNodes(Guid bankId, CancellationToken cancellationToken) + { + return Ok(await service.GetNodesAsync(ResolveActor(), bankId, cancellationToken)); + } + + [HttpPut("nodes")] + [EndpointSummary("新增或更新公共题库内容节点")] + public async Task> UpsertNode( + UpsertPlatformQuestionBankNodeCommand request, + CancellationToken cancellationToken) + { + return Ok(await service.UpsertNodeAsync(ResolveActor(), request, cancellationToken)); + } + + [HttpPost("nodes/batch")] + [EndpointSummary("批量创建公共题库章节或试卷")] + public async Task>> BatchCreateNodes( + BatchCreatePlatformQuestionBankNodesCommand request, + CancellationToken cancellationToken) + { + return Ok(await service.BatchCreateNodesAsync(ResolveActor(), request, cancellationToken)); + } + + [HttpPost("nodes/{nodeId:guid}/archive")] + [EndpointSummary("归档公共题库内容节点")] + public async Task> ArchiveNode(Guid nodeId, CancellationToken cancellationToken) + { + return Ok(await service.ArchiveNodeAsync(ResolveActor(), nodeId, cancellationToken)); + } + + [HttpGet("questions")] + [EndpointSummary("分页查询公共题库题目")] + public async Task> GetQuestions( + [FromQuery] Guid questionBankId, + [FromQuery] Guid? contentNodeId, + [FromQuery] string? keyword, + [FromQuery] string? type, + [FromQuery] int? difficulty, + [FromQuery] string? status, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + CancellationToken cancellationToken = default) + { + return Ok(await service.GetQuestionsAsync(ResolveActor(), new PlatformQuestionBankFilter( + questionBankId, contentNodeId, keyword, type, difficulty, status, page, pageSize), cancellationToken)); + } + + [HttpPut("questions")] + [EndpointSummary("新增或更新公共题库题目并保留版本")] + public async Task> UpsertQuestion( + UpsertPlatformQuestionCommand request, + CancellationToken cancellationToken) + { + return Ok(await service.UpsertQuestionAsync(ResolveActor(), request, cancellationToken)); + } + + [HttpPost("questions/archive")] + [EndpointSummary("批量归档公共题库题目")] + public async Task> ArchiveQuestions( + ArchivePlatformQuestionsCommand request, + CancellationToken cancellationToken) + { + return Ok(await service.ArchiveQuestionsAsync(ResolveActor(), request, cancellationToken)); + } + + [HttpPost("imports/preview")] + [EndpointSummary("预检公共题库 JSON 导入")] + public async Task> PreviewImport( + PlatformQuestionImportCommand request, + CancellationToken cancellationToken) + { + return Ok(await service.PreviewImportAsync(ResolveActor(), request, cancellationToken)); + } + + [HttpPost("imports")] + [EndpointSummary("执行公共题库 JSON 导入")] + public async Task> ExecuteImport( + PlatformQuestionImportCommand request, + CancellationToken cancellationToken) + { + return Ok(await service.ExecuteImportAsync(ResolveActor(), request, cancellationToken)); + } + + [HttpGet("imports/{jobId:guid}")] + [EndpointSummary("查询公共题库导入结果")] + public async Task> GetImport(Guid jobId, CancellationToken cancellationToken) + { + return Ok(await service.GetImportAsync(ResolveActor(), jobId, cancellationToken)); + } + + [HttpPost("assets/upload-sign")] + [EndpointSummary("签发公共题库图片上传地址")] + public async Task> SignAssetUpload( + AssetUploadSignDto request, + CancellationToken cancellationToken) + { + return Ok(await service.SignQuestionAssetUploadAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + [HttpPost("assets/upload-confirm")] + [EndpointSummary("确认公共题库图片上传结果")] + public async Task> ConfirmAssetUpload( + AssetUploadConfirmDto request, + CancellationToken cancellationToken) + { + return Ok(await service.ConfirmQuestionAssetUploadAsync(ResolveActor(), request.ToCommand(), cancellationToken)); + } + + private PlatformAdminActor ResolveActor() + { + return currentUser.UserId is { } userId + ? new PlatformAdminActor(userId) + : throw new PlatformAdminException("无法识别当前平台员工。", "platform_access_denied"); + } +} diff --git a/Tiku.Api/wwwroot/platform-admin/app.js b/Tiku.Api/wwwroot/platform-admin/app.js index 6e36557..5c5c918 100644 --- a/Tiku.Api/wwwroot/platform-admin/app.js +++ b/Tiku.Api/wwwroot/platform-admin/app.js @@ -17,9 +17,12 @@ const HISTORY_APP_KEY = 'gongxuePlatform'; const API_CONNECTED_PAGES = new Set([ 'overview', 'tenants', + 'domains', 'tenantDetail', 'billingProfile', 'subscriptions', + 'catalog', + 'saasFinance', 'billing', 'usage', 'dunning', @@ -28,6 +31,7 @@ const API_CONNECTED_PAGES = new Set([ 'sms', 'paymentSettings', 'staff', + 'roles', 'audit', 'alerts', ]); @@ -35,37 +39,41 @@ const API_CONNECTED_PAGES = new Set([ const pageMeta = { overview: { id: 'P-01', title: '平台经营工作台', permission: 'platform:overview:read' }, tenants: { id: 'P-02', title: '租户列表与创建', permission: 'platform:tenant:read' }, + domains: { id: 'P-02A', title: '租户域名与证书', permission: 'platform:tenant:read' }, tenantDetail: { id: 'P-03', title: '租户详情与生命周期', permission: 'platform:tenant:read', level: 2 }, billingProfile: { id: 'P-04', title: '租户账务资料', permission: 'platform:tenant:billing_profile', level: 3 }, subscriptions: { id: 'P-05', title: 'SaaS 套餐与订阅', permission: 'platform:plan:read' }, + catalog: { id: 'P-05A', title: 'SaaS 商品与版本', permission: 'platform:plan:read' }, + saasFinance: { id: 'P-05B', title: 'SaaS 订单与交易', permission: 'platform:billing:read' }, billing: { id: 'P-06', title: '服务费账单', permission: 'platform:billing:read' }, usage: { id: 'P-07', title: '用量与超额计费', permission: 'platform:usage:read' }, dunning: { id: 'P-08', title: '收款、逾期与催缴', permission: 'platform:billing:read' }, - bank: { id: 'P-09', title: '公共题库目录与授权', permission: 'platform:question_bank:read' }, + bank: { id: 'P-09', title: '公共题库', permission: 'platform:question-bank:manage' }, crm: { id: 'P-10', title: 'CRM 接入与线索流转', permission: 'platform:crm:read' }, sms: { id: 'P-11', title: 'SMS 短信服务', permission: 'platform:sms:read' }, paymentSettings: { id: 'P-12', title: '支付设置', permission: 'platform:payment:read' }, staff: { id: 'P-13', title: '平台员工与权限', permission: 'platform:staff:read' }, + roles: { id: 'P-13A', title: '平台角色与功能', permission: 'platform:staff:read' }, audit: { id: 'P-14', title: '平台审计日志', permission: 'platform:audit:read' }, alerts: { id: 'P-15', title: '审计告警与通知', permission: 'platform:audit:alert' }, }; const navGroups = [ ['平台概览', [['overview', 'gauge', '经营工作台']]], - ['租户管理', [['tenants', 'building-2', '租户列表']]], - ['SaaS 账务', [['subscriptions', 'badge-check', '套餐与订阅'], ['billing', 'receipt-text', '服务费账单'], ['usage', 'chart-column-big', '用量与超额'], ['dunning', 'bell-ring', '收款与催缴']]], + ['租户管理', [['tenants', 'building-2', '租户列表'], ['domains', 'globe-2', '域名与证书']]], + ['SaaS 商品与账务', [['catalog', 'packages', '商品与版本'], ['subscriptions', 'badge-check', '租户订阅'], ['saasFinance', 'landmark', '订单与交易'], ['billing', 'receipt-text', '服务费账单'], ['usage', 'chart-column-big', '用量与超额'], ['dunning', 'bell-ring', '收款与催缴']]], ['平台资产', [['bank', 'database-zap', '公共题库']]], ['租户能力', [['crm', 'handshake', 'CRM 接入'], ['sms', 'message-square-text', '短信服务'], ['paymentSettings', 'credit-card', '支付设置']]], - ['安全治理', [['staff', 'user-cog', '员工与权限'], ['audit', 'scroll-text', '审计日志'], ['alerts', 'shield-alert', '告警与通知']]], + ['安全治理', [['staff', 'user-cog', '平台员工'], ['roles', 'shield-check', '角色与功能'], ['audit', 'scroll-text', '审计日志'], ['alerts', 'shield-alert', '告警与通知']]], ]; const roleDefinitions = { super: { label: '超级管理员', permissions: ['*'] }, - tenant_ops: { label: '租户运营', permissions: ['platform:overview:read', 'platform:tenant:read', 'platform:tenant:write', 'platform:tenant:status', 'platform:tenant:billing_profile', 'platform:plan:read', 'platform:billing:read', 'platform:question_bank:read', 'platform:crm:read', 'platform:crm:write', 'platform:sms:read', 'platform:sms:write'] }, + tenant_ops: { label: '租户运营', permissions: ['platform:overview:read', 'platform:tenant:read', 'platform:tenant:write', 'platform:tenant:status', 'platform:tenant:billing_profile', 'platform:plan:read', 'platform:billing:read', 'platform:crm:read', 'platform:crm:write', 'platform:sms:read', 'platform:sms:write'] }, finance: { label: '平台财务', permissions: ['platform:overview:read', 'platform:tenant:read', 'platform:tenant:billing_profile', 'platform:plan:read', 'platform:billing:read', 'platform:billing:write', 'platform:billing:payment', 'platform:billing:dunning', 'platform:billing:notification', 'platform:usage:read', 'platform:usage:write', 'platform:payment:read', 'platform:payment:write', 'platform:audit:read'] }, audit: { label: '安全审计', permissions: ['platform:overview:read', 'platform:audit:read', 'platform:audit:export', 'platform:audit:alert', 'platform:audit:notification'] }, - bank_ops: { label: '题库运营', permissions: ['platform:overview:read', 'platform:question_bank:read', 'platform:question_bank:grant', 'platform:question_bank:ops'] }, - readonly: { label: '只读观察员', permissions: ['platform:overview:read', 'platform:tenant:read', 'platform:plan:read', 'platform:billing:read', 'platform:question_bank:read', 'platform:crm:read', 'platform:sms:read', 'platform:payment:read', 'platform:audit:read'] }, + bank_ops: { label: '题库运营', permissions: ['platform:dashboard:view', 'platform:question-bank:manage'] }, + readonly: { label: '只读观察员', permissions: ['platform:overview:read', 'platform:tenant:read', 'platform:plan:read', 'platform:billing:read', 'platform:crm:read', 'platform:sms:read', 'platform:payment:read', 'platform:audit:read'] }, }; const seed = { @@ -78,6 +86,13 @@ const seed = { { id: 'tenant_xuezhi_heb', slug: 'xuezhi-heb', name: '学知专接本 · 河北', legal: '石家庄学知教育有限公司', brand: '学知专接本', shortName: '学知河北', host: 'app.xuezhizjb.cn', plan: '专业版', planCode: 'pro', status: '正常', billing: '正常', mode: '正式', students: 3160, questions: 39860, storageGB: 164, trafficTB: 2.1, expires: '2027-01-08', balance: 0, owner: '赵老师', ownerPhone: '137****6116', createdAt: '2025-01-08 08:40' }, { id: 'tenant_youcai_js', slug: 'youcai-js', name: '优才专转本 · 江苏', legal: '南京优才教育科技有限公司', brand: '优才专转本', shortName: '优才江苏', host: 'study.youcai.cn', plan: '专业版', planCode: 'pro', status: '待配置', billing: '正常', mode: '试用', students: 0, questions: 0, storageGB: 0, trafficTB: 0, expires: '', balance: 0, owner: '顾老师', ownerPhone: '138****1120', createdAt: '2026-07-22 16:08' }, ], + domains: [], + saasCatalog: { features: [], limitDefinitions: [], offerings: [], versions: [] }, + saasOrders: [], + saasRefunds: [], + paymentChannels: [], + rebateSummary: {}, + crmLogs: [], billingProfiles: { tenant_gongxue_tj: { billingName: '天津恭学教育科技有限公司', taxId: '91120101MA07GX2026', contactName: '李会计', phone: '13800138000', email: 'finance@gongxue.cn', billingAddress: '天津市南开区科研西路 12 号', invoiceTitle: '天津恭学教育科技有限公司', type: '增值税专用发票', bankName: '招商银行天津南开支行', bankAccountMasked: '**** **** **** 8826' }, tenant_qihang_gd: { billingName: '广州启航教育咨询有限公司', taxId: '91440101MA5QH2026X', contactName: '黄会计', phone: '13900139000', email: 'billing@qihangzsb.cn', billingAddress: '广州市天河区科韵路 18 号', invoiceTitle: '广州启航教育咨询有限公司', type: '增值税普通发票', bankName: '中国银行广州天河支行', bankAccountMasked: '**** **** **** 6672' }, @@ -100,6 +115,7 @@ const seed = { { id: 'INV-202607-0016', tenantId: 'tenant_gongxue_tj', type: '订阅服务费', period: '2026年7月', total: 16800, paid: 16800, balance: 0, due: '2026-07-15', status: '已支付', note: '2026年7月服务费' }, { id: 'INV-202607-0015', tenantId: 'tenant_xuezhi_heb', type: '订阅服务费', period: '2026年7月', total: 9800, paid: 9800, balance: 0, due: '2026-07-15', status: '已支付', note: '2026年7月服务费' }, ], + billingPayments: [], usage: [ { tenantId: 'tenant_gongxue_tj', period: '2026-07', source: 'Worker 自动采集', students: 2846, questions: 42680, storageGB: 186, trafficTB: 1.8, overage: [], estimated: 0 }, { tenantId: 'tenant_qihang_gd', period: '2026-07', source: 'Worker 自动采集', students: 5280, questions: 68420, storageGB: 248, trafficTB: 3.2, overage: ['流量'], estimated: 7820 }, @@ -120,15 +136,12 @@ const seed = { { id: 'QB-EN', name: '公共英语能力库', catalog: '全国 / 公共英语', source: '平台自建', questions: 86640, version: 'v2026.6', status: '稳定版' }, { id: 'QB-SD', name: '山东专升本全科', catalog: '山东专升本', source: '合作内容方', questions: 52360, version: 'v2026.7', status: '稳定版' }, ], - grants: [ - { id: 'GRANT-001', bankId: 'QB-TJ-MATH', scope: '指定租户', allowedPlanCodes: [], allowedTenantIds: ['tenant_gongxue_tj', 'tenant_qihang_gd'], allowedRegionIds: ['天津'], allowedSubjectIds: ['高等数学'], status: '启用', startsAt: '2026-07-18', expiresAt: '2027-07-18', localEdit: false }, - { id: 'GRANT-002', bankId: 'QB-EN', scope: '指定套餐', allowedPlanCodes: ['pro', 'enterprise'], allowedTenantIds: [], allowedRegionIds: [], allowedSubjectIds: ['公共英语'], status: '启用', startsAt: '2026-06-01', expiresAt: '', localEdit: true }, - ], - bankSync: [ - { tenantId: 'tenant_gongxue_tj', bankId: 'QB-TJ-MATH', current: 'v2026.6', target: 'v2026.7', progress: 100, conflicts: 0, status: '已完成' }, - { tenantId: 'tenant_qihang_gd', bankId: 'QB-EN', current: 'v2026.5', target: 'v2026.6', progress: 72, conflicts: 3, status: '同步中' }, - { tenantId: 'tenant_mingtu_hn', bankId: 'QB-EN', current: 'v2026.3', target: 'v2026.6', progress: 38, conflicts: 12, status: '同步失败' }, + bankNodes: [ + { id: 'NODE-MATH', questionBankId: 'QB-TJ-MATH', parentId: null, name: '高等数学', nodeType: 'subject', depth: 0, sortOrder: 0, isActive: true, questionCount: 12860 }, + { id: 'NODE-MATH-01', questionBankId: 'QB-TJ-MATH', parentId: 'NODE-MATH', name: '函数、极限与连续', nodeType: 'chapter', depth: 1, sortOrder: 0, isActive: true, questionCount: 1260 }, ], + bankQuestions: [], + bankImportResult: null, crmConfigs: [ { id: 'CRM-GX-TJ', tenantId: 'tenant_gongxue_tj', provider: '标准 CRM', endpoint: 'https://crm.gongxue.cn/open/leads', assignmentMode: '按地区轮转', pool: '天津升本线索池', enabled: true, status: '正常', timeout: 8, retry: 3, updatedAt: '2026-07-23 09:12' }, { id: 'CRM-QH-GD', tenantId: 'tenant_qihang_gd', provider: '自建 CRM', endpoint: 'https://crm.qihangzsb.cn/api/leads', assignmentMode: '按校区容量', pool: '广东转化队列', enabled: true, status: '异常', timeout: 10, retry: 5, updatedAt: '2026-07-22 18:40' }, @@ -159,9 +172,33 @@ const seed = { { id: 'PEVT-260723-029', appId: 'PAY-QH-ALI', orderNo: 'ORD-260723-2021', amount: 1280, status: '回调失败', channel: '支付宝 WAP', notified: '重试中', updatedAt: '2026-07-23 09:49' }, { id: 'PEVT-260722-188', appId: 'PAY-BS-MANUAL', orderNo: 'ORD-260722-1098', amount: 99, status: '待确认', channel: '线下转账', notified: '无', updatedAt: '2026-07-22 20:18' }, ], + platformPermissions: { + 'platform:dashboard:view': '平台总览', + 'platform:tenant:manage': '平台租户管理', + 'platform:staff:manage': '平台员工管理', + 'platform:role:manage': '平台角色权限管理', + 'platform:question-bank:manage': '平台公共题库运营', + 'platform:audit:view': '平台审计查询', + 'platform:billing:notification': '平台账务通知', + 'platform:saas-catalog:manage': 'SaaS 商品目录管理', + 'platform:saas-billing:manage': 'SaaS 订单与账务管理', + 'platform:crm:read': 'CRM 配置查询', + 'platform:crm:write': 'CRM 配置管理', + 'platform:sms:read': '短信服务查询', + 'platform:sms:write': '短信服务管理', + 'platform:payment:read': '支付设置查询', + 'platform:payment:write': '支付设置管理', + }, + platformRoles: [ + { id: 'role-super', code: 'platform_super_admin', name: '超级管理员', description: '拥有全部平台管理权限', permissionCodes: ['*'] }, + { id: 'role-tenant', code: 'platform_tenant_operator', name: '租户运营', description: '负责租户开通与生命周期管理', permissionCodes: ['platform:dashboard:view', 'platform:tenant:manage', 'platform:crm:read', 'platform:crm:write'] }, + { id: 'role-finance', code: 'platform_finance', name: '平台财务', description: '负责 SaaS 商品、订单、账务与支付', permissionCodes: ['platform:dashboard:view', 'platform:saas-catalog:manage', 'platform:saas-billing:manage', 'platform:billing:notification', 'platform:payment:read', 'platform:payment:write'] }, + { id: 'role-audit', code: 'platform_auditor', name: '安全审计', description: '查询审计日志与处理安全告警', permissionCodes: ['platform:dashboard:view', 'platform:audit:view'] }, + { id: 'role-bank', code: 'platform_question_bank_operator', name: '题库运营', description: '负责公共题库结构、题目与导入', permissionCodes: ['platform:dashboard:view', 'platform:question-bank:manage'] }, + ], staff: [ - { id: 'STAFF-01', authUserId: 'auth-chenhao', username: 'chenhao', name: '陈浩', email: 'chenhao@gongxue.cn', phone: '138****6600', role: '超级管理员', permissions: ['*'], status: '正常', lastActive: '刚刚' }, - { id: 'STAFF-02', authUserId: 'auth-zhoumin', username: 'zhoumin', name: '周敏', email: 'zhoumin@gongxue.cn', phone: '139****2188', role: '题库运营', permissions: ['platform:question_bank:read'], status: '正常', lastActive: '8分钟前' }, + { id: 'STAFF-01', userId: 'STAFF-01', roleIds: ['role-super'], roleCodes: ['platform_super_admin'], name: '陈浩', email: 'chenhao@gongxue.cn', phone: '138****6600', role: '超级管理员', permissions: ['*'], status: '正常', lastActive: '刚刚' }, + { id: 'STAFF-02', authUserId: 'auth-zhoumin', username: 'zhoumin', name: '周敏', email: 'zhoumin@gongxue.cn', phone: '139****2188', role: '题库运营', permissions: ['platform:question-bank:manage'], status: '正常', lastActive: '8分钟前' }, { id: 'STAFF-03', authUserId: 'auth-liujia', username: 'liujia', name: '刘佳', email: 'liujia@gongxue.cn', phone: '136****9028', role: '平台财务', permissions: ['platform:billing:read', 'platform:billing:write', 'platform:billing:payment', 'platform:usage:read', 'platform:usage:write'], status: '正常', lastActive: '21分钟前' }, { id: 'STAFF-04', authUserId: 'auth-wanglei', username: 'wanglei', name: '王磊', email: 'wanglei@gongxue.cn', phone: '135****2086', role: '租户运营', permissions: ['platform:tenant:read', 'platform:tenant:write', 'platform:tenant:status'], status: '正常', lastActive: '1小时前' }, { id: 'STAFF-05', authUserId: 'auth-sunchen', username: 'sunchen', name: '孙晨', email: 'sunchen@gongxue.cn', phone: '137****1118', role: '安全审计', permissions: ['platform:audit:read', 'platform:audit:alert'], status: '已停用', lastActive: '3天前' }, @@ -169,7 +206,7 @@ const seed = { auditLogs: [ { id: 'AUD-20260723-094218', time: '2026-07-23 09:42:18 CST', actor: '陈浩', role: '平台管理员', action: 'tenant.status.update', targetType: 'tenant', targetId: 'tenant_mingtu_hn', target: '明途教育 · 河南', severity: '高', result: '成功', ip: '172.16.4.28', details: '将业务状态由正常更新为暂停,原因:账单逾期。' }, { id: 'AUD-20260723-093602', time: '2026-07-23 09:36:02 CST', actor: 'system-billing', role: '系统任务', action: 'invoice.batch.create', targetType: 'invoice_batch', targetId: 'TASK-260723-001', target: '2026年7月账单', severity: '中', result: '成功', ip: 'internal', details: '生成 48 张账单,跳过 2 条重复候选。' }, - { id: 'AUD-20260723-085844', time: '2026-07-23 08:58:44 CST', actor: '周敏', role: '题库运营', action: 'bank.grant.update', targetType: 'grant', targetId: 'GRANT-001', target: '高数公共题库 v2026.7', severity: '中', result: '成功', ip: '172.16.3.16', details: '授权范围更新为 2 家指定租户。' }, + { id: 'AUD-20260723-085844', time: '2026-07-23 08:58:44 CST', actor: '周敏', role: '题库运营', action: 'platform.question_bank.import_executed', targetType: 'question_bank', targetId: 'QB-TJ-MATH', target: '高数公共题库 v2026.7', severity: '中', result: '成功', ip: '172.16.3.16', details: '结构化 JSON 导入完成,新增 8 个内容节点和 126 道题。' }, { id: 'AUD-20260723-084127', time: '2026-07-23 08:41:27 CST', actor: '刘佳', role: '平台财务', action: 'invoice.payment.confirm', targetType: 'invoice', targetId: 'INV-202607-0016', target: 'INV-202607-0016', severity: '高', result: '成功', ip: '172.16.8.12', details: '人工确认收款 ¥16,800,流水号已脱敏。' }, { id: 'AUD-20260723-081209', time: '2026-07-23 08:12:09 CST', actor: 'unknown', role: '-', action: 'auth.login.failed', targetType: 'application', targetId: 'platform-console', target: 'platform-console', severity: '严重', result: '失败', ip: '45.62.18.9', details: '5 分钟内连续登录失败 12 次。' }, { id: 'AUD-20260720-163510', time: '2026-07-20 16:35:10 CST', actor: '周敏', role: '题库运营', action: 'bank.catalog.read', targetType: 'question_bank', targetId: 'QB-SD', target: '山东专升本全科', severity: '中', result: '成功', ip: '172.16.3.16', details: '查看公共题库版本与租户采用状态。' }, @@ -177,14 +214,14 @@ const seed = { alerts: [ { id: 'ALT-20260723-008', title: '异常登录尝试', summary: '5 分钟内 12 次平台登录失败', severity: '严重', status: 'open', owner: '安全审计', targetType: 'application', targetId: 'platform-console', evidence: ['来源 IP 45.62.18.9', '账号枚举行为 12 次', '命中规则 LOGIN_FAILURE_BURST'], updatedAt: '2分钟前', note: '' }, { id: 'ALT-20260723-006', title: '租户账单逾期', summary: '明途教育服务费账单逾期 5 天', severity: '高', status: 'acknowledged', owner: '平台财务', targetType: 'invoice', targetId: 'INV-202607-0017', evidence: ['余额 ¥8,600', '到期日 2026-07-18', '已发送 3 次催缴'], updatedAt: '18分钟前', note: '财务已联系租户。' }, - { id: 'ALT-20260723-004', title: '公共题库同步失败', summary: '河南租户同步发生 12 个内容冲突', severity: '中', status: 'open', owner: '题库运营', targetType: 'question_bank', targetId: 'QB-EN', evidence: ['当前版本 v2026.3', '目标版本 v2026.6', '冲突 12 条'], updatedAt: '42分钟前', note: '' }, + { id: 'ALT-20260723-004', title: '公共题库导入失败', summary: '结构化 JSON 文件存在 12 个稳定编码冲突', severity: '中', status: 'open', owner: '题库运营', targetType: 'question_bank', targetId: 'QB-EN', evidence: ['文件版本 2.0', '目标题库 公共英语能力库', '冲突 12 条'], updatedAt: '42分钟前', note: '' }, { id: 'ALT-20260722-019', title: '存储用量告警', summary: '启航专升本存储用量达到套餐 92%', severity: '中', status: 'open', owner: '租户运营', targetType: 'tenant', targetId: 'tenant_qihang_gd', evidence: ['当前 460 GB', '套餐上限 500 GB'], updatedAt: '昨天', note: '' }, { id: 'ALT-20260722-011', title: 'Provider 密钥即将过期', summary: '优才专转本支付 Provider 密钥 7 天后到期', severity: '高', status: 'resolved', owner: '平台运维', targetType: 'tenant', targetId: 'tenant_youcai_js', evidence: ['密钥只写不可回显', '新密钥已于昨天更新'], updatedAt: '昨天', note: '完成轮换。' }, ], alertRules: [ { id: 'RULE-LOGIN-BURST', code: 'LOGIN_FAILURE_BURST', name: '平台登录失败突增', severity: '严重', enabled: true, window: '5 分钟', threshold: '≥ 10 次', target: '平台账号与来源 IP' }, { id: 'RULE-INVOICE-OVERDUE', code: 'INVOICE_OVERDUE', name: '服务费账单逾期', severity: '高', enabled: true, window: '每日 09:00', threshold: '逾期 ≥ 1 天', target: '未结清平台账单' }, - { id: 'RULE-BANK-SYNC', code: 'QUESTION_BANK_SYNC_FAILED', name: '公共题库同步失败', severity: '中', enabled: true, window: '任务结束时', threshold: '失败或冲突 ≥ 10', target: '租户题库同步任务' }, + { id: 'RULE-BANK-IMPORT', code: 'QUESTION_BANK_IMPORT_FAILED', name: '公共题库导入失败', severity: '中', enabled: true, window: '导入结束时', threshold: '无效记录或冲突 ≥ 10', target: '平台公共题库导入任务' }, ], auditChannels: [ { id: 'CH-SEC-MAIL', code: 'security_email', name: '平台安全邮箱', provider: '邮件', endpoint: 'security@gongxue.cn', minSeverity: '中', statusFilter: 'open,acknowledged', timeout: 8, maxAttempts: 3, enabled: true, lastStatus: '正常' }, @@ -216,14 +253,32 @@ function normalizeData(saved) { if (saved[key] !== undefined) normalized[key] = saved[key]; }); normalized.usage = normalized.usage.map(item => ({ overage: [], estimated: 0, source: 'Worker 自动采集', ...item })); - normalized.grants = normalized.grants.map(item => ({ allowedPlanCodes: [], allowedTenantIds: [], allowedRegionIds: [], allowedSubjectIds: [], localEdit: false, ...item })); normalized.crmConfigs = normalized.crmConfigs.map(item => ({ enabled: false, status: '未启用', retry: 0, ...item })); normalized.crmLeads = normalized.crmLeads.map(item => ({ attempts: 0, lastError: '', ...item })); normalized.smsChannels = normalized.smsChannels.map(item => ({ enabled: false, quota: 0, used: 0, ...item })); normalized.smsTemplates = normalized.smsTemplates.map(item => ({ lastSend: '-', successRate: '-', ...item })); normalized.paymentApps = normalized.paymentApps.map(item => ({ mode: 'sandbox', status: '未启用', ...item })); normalized.paymentEvents = normalized.paymentEvents.map(item => ({ notified: '无', ...item })); - normalized.staff = normalized.staff.map(item => ({ permissions: [], status: '正常', lastActive: '尚未登录', ...item })); + normalized.platformPermissions = Object.fromEntries(Object.entries(normalized.platformPermissions || {}).map(([code, name]) => [ + code, + seed.platformPermissions[code] || name, + ])); + normalized.platformRoles = normalized.platformRoles.map(item => item.code === 'platform_super_admin' + ? { ...item, name: '超级管理员', description: '拥有全部平台管理权限' } + : item); + normalized.staff = normalized.staff.map(item => { + const roleIds = item.roleIds?.length ? item.roleIds : normalized.platformRoles.filter(role => role.name === item.role).map(role => role.id); + const assignedRoles = normalized.platformRoles.filter(role => roleIds.includes(role.id)); + return { + permissions: assignedRoles.flatMap(role => role.permissionCodes), + status: '正常', + lastActive: '尚未登录', + ...item, + userId: item.userId || item.id, + roleIds, + roleCodes: item.roleCodes || assignedRoles.map(role => role.code), + }; + }); normalized.alerts = normalized.alerts.map(item => ({ evidence: [], note: '', status: 'open', ...item })); normalized.tasks = normalized.tasks.map(item => ({ status: 'failed', error: '', resultTarget: '任务中心', updatedAt: item.createdAt || todayTime(), ...item })); normalized.version = seed.version; @@ -252,15 +307,15 @@ let data = loadData(); if (platformApi?.isEnabled()) { [ 'tenants', 'plans', 'subscriptions', 'invoices', 'usage', 'reminders', - 'dunningChannels', 'dunningEvents', 'banks', 'grants', 'bankSync', 'staff', + 'dunningChannels', 'dunningEvents', 'banks', 'bankNodes', 'bankQuestions', 'staff', 'auditLogs', 'alerts', 'alertRules', 'auditChannels', 'notificationEvents', 'tasks', ].forEach(key => { data[key] = []; }); data.billingProfiles = {}; } const state = { view: 'overview', previousView: '', selectedTenant: data.tenants[0]?.id || '', tenantFilter: 'all', tenantPlan: 'all', tenantSearch: '', globalSearch: '', - billingTab: 'invoices', bankTab: 'catalog', bankIncludeTenant: false, selectedBank: data.banks[0]?.id || '', - crmTab: 'configs', smsTab: 'channels', paymentTab: 'apps', + billingTab: 'invoices', bankTab: 'structure', selectedBank: data.banks[0]?.id || '', selectedBankNode: '', bankQuestionPage: 1, bankQuestionTotal: 0, + crmTab: 'configs', smsTab: 'channels', paymentTab: 'apps', saasFinanceTab: 'orders', invoiceSearch: '', invoiceStatus: 'all', bankSearch: '', staffSearch: '', usagePeriod: '2026-07', auditRange: '24h', auditSeverity: 'all', auditSearch: '', alertStatus: 'all', selectedAlert: '', selectedInvoice: '', role: 'super', scenario: 'normal', response: 'success', modal: '', modalData: {}, submitting: '', reading: '', contractResults: {}, history: [], apiConnection: platformApi?.isEnabled() ? 'connecting' : 'mock', apiLoadedAt: '', apiLoadedOperations: 0, apiFailedOperations: 0, @@ -357,10 +412,7 @@ async function hydrateFromBackend({ silent = false } = {}) { state.apiConnection = 'connecting'; updateBackendStatus(); try { - const result = await platformApi.bootstrap({ - includeTenantBanks: state.bankIncludeTenant, - period: state.usagePeriod, - }); + const result = await platformApi.bootstrap({ period: state.usagePeriod }); const successful = Object.keys(result.payloads).length; if (!successful) throw Object.values(result.errors)[0] || new Error('没有平台接口成功返回'); applyBackendPatch(platformApi.adaptBootstrap(result.payloads, data)); @@ -412,7 +464,32 @@ function entityId(prefix) { return `${prefix}-${Date.now().toString(36).toUpperC function tenantById(id) { return data.tenants.find(item => item.id === id); } function planByCode(code) { return data.plans.find(item => item.code === code); } function invoiceById(id) { return data.invoices.find(item => item.id === id); } +function paymentForInvoice(invoice) { return data.billingPayments.find(item => item.orderId && item.orderId === invoice?.orderId && item.status === 'pending'); } +function canConfirmInvoicePayment(invoice) { return !platformApi?.isEnabled() || Boolean(paymentForInvoice(invoice)); } function bankById(id) { return data.banks.find(item => item.id === id); } +function bankNodeById(id) { return data.bankNodes.find(item => item.id === id); } +function questionTypeLabel(value) { return ({ choice: '单选题', multiple_choice: '多选题', true_false: '判断题', fill_blank: '填空题', short_answer: '简答题', reading: '阅读材料题', programming: '编程题' })[String(value || '').toLowerCase()] || value || '-'; } +function bankNodeTypeLabel(value) { return ({ category: '分类', subject: '科目', chapter: '章节', paper: '试卷', custom: '自定义' })[String(value || '').toLowerCase()] || value || '-'; } +function adaptBankNode(item) { return { ...item, questionBankId: item.questionBankId, nodeType: String(item.nodeType || '').replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase(), questionCount: Number(item.questionCount || 0) }; } +function adaptBankQuestion(item) { return { ...item, statusLabel: businessStatusLabel(item.status), versionNo: Number(item.versionNo || 0) }; } +function parseJsonText(value, fallback) { try { return String(value || '').trim() ? JSON.parse(value) : fallback; } catch { return null; } } +async function loadBankWorkspace({ questions = true } = {}) { + const bank = bankById(state.selectedBank); + if (!bank || !platformApi?.isEnabled()) return; + try { + const nodes = await platformApi.request('PlatformQuestionBanksController_getNodes', { pathParams: { bankId: bank.id } }); + data.bankNodes = apiItems(nodes).map(adaptBankNode); + if (!state.selectedBankNode || !data.bankNodes.some(item => item.id === state.selectedBankNode)) state.selectedBankNode = data.bankNodes.find(item => item.isActive && ['chapter', 'paper'].includes(item.nodeType))?.id || data.bankNodes.find(item => item.isActive)?.id || ''; + if (questions) { + const result = await platformApi.request('PlatformQuestionBanksController_getQuestions', { query: { questionBankId: bank.id, contentNodeId: state.selectedBankNode || undefined, page: state.bankQuestionPage, pageSize: 20 } }); + data.bankQuestions = apiItems(result).map(adaptBankQuestion); + state.bankQuestionTotal = Number(result.total || data.bankQuestions.length); + } + saveData(); render(); + } catch (error) { + showToast(backendErrorText(error, '公共题库加载失败'), 'error'); + } +} function notificationChannelName(id) { return [...data.dunningChannels, ...data.auditChannels].find(channel => channel.id === id)?.name || id; } @@ -449,14 +526,6 @@ function rowActionAccessibleName(control) { const channel = data.dunningChannels.find(item => item.id === control.dataset.channelId); return `编辑催缴渠道 ${recordLabel(channel?.name, channel?.id || control.dataset.channelId)}`; } - if (action === 'edit-grant') { - const grant = data.grants.find(item => item.id === control.dataset.grantId); - return `编辑公共题库授权 ${recordLabel(bankById(grant?.bankId)?.name, grant?.id || control.dataset.grantId)}`; - } - if (action === 'sync-detail') { - const [syncTenantId, bankId] = String(control.dataset.syncKey || '').split(':'); - return `查看 ${recordLabel(tenantById(syncTenantId)?.name, syncTenantId)} 与 ${recordLabel(bankById(bankId)?.name, bankId)} 的同步详情`; - } if (action === 'edit-staff') { const person = data.staff.find(item => item.id === control.dataset.staffId); return `编辑员工 ${recordLabel(person?.name, person?.id || control.dataset.staffId)}`; @@ -495,10 +564,6 @@ function switchAccessibleName(control) { const channel = data.dunningChannels.find(item => item.id === control.dataset.channelId); return `${enabled ? '停用' : '启用'}催缴渠道 ${recordLabel(channel?.name, channel?.id || control.dataset.channelId)}`; } - if (action === 'toggle-grant') { - const grant = data.grants.find(item => item.id === control.dataset.grantId); - return `${enabled ? '禁用' : '启用'}公共题库授权 ${recordLabel(bankById(grant?.bankId)?.name, grant?.id || control.dataset.grantId)}`; - } if (action === 'toggle-staff') { const person = data.staff.find(item => item.id === control.dataset.staffId); return `${enabled ? '停用员工' : '启用员工'} ${recordLabel(person?.name, person?.id || control.dataset.staffId)}${enabled ? '并撤销会话' : ''}`; @@ -729,6 +794,39 @@ function renderScenario(content) { return content; } +function businessStatusLabel(value) { + const key = String(value || '').replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase(); + return ({ draft: '草稿', active: '正常', published: '已发布', retired: '已下架', archived: '已归档', pending: '待处理', pending_payment: '待付款', paid: '已支付', succeeded: '已成功', failed: '失败', requested: '已申请', completed: '已完成', verified: '已验证', dns_pending: 'DNS 待验证', tls_pending: 'TLS 待就绪', disabled: '已停用' })[key] || value || '-'; +} + +function renderDomains() { + return `
${pageHead('租户域名与证书', '统一查看租户域名的 DNS 验证、证书和主域名状态,可对未就绪域名重新检查。')} +
${data.domains.map(item => ``).join('')}
租户域名用途主域名DNS 验证TLS 就绪最近检查状态
${escapeHtml(tenantById(item.tenantId)?.name || item.tenantId)}${escapeHtml(item.host)}${escapeHtml(item.domainType || '-')}${item.isPrimary ? '是' : '否'}${item.dnsVerifiedAt ? '已验证' : '待验证'}${item.tlsReadyAt ? '已就绪' : '待就绪'}${escapeHtml(item.lastCheckedAt || '-')}${statusBadge(businessStatusLabel(item.status))}${escapeHtml(item.lastFailureReason || '')}
${data.domains.length ? '' : emptyBlock('暂无租户域名', '租户配置域名后将在这里显示。')}
`; +} + +function renderCatalog() { + const catalog = data.saasCatalog || {}; + const features = catalog.features || []; + const offerings = catalog.offerings || []; + const versions = catalog.versions || []; + const offeringName = id => offerings.find(item => item.id === id)?.name || id; + return `
${pageHead('SaaS 商品与版本', '管理可售套餐、增值服务、功能权益、使用限额和定价版本。', ``)} +
${icon('blocks')}功能${features.length}

其中 ${features.filter(item => String(item.status).toLowerCase() === 'active').length} 项启用

${icon('packages')}商品${offerings.length}

基础套餐与增值包

${icon('git-branch')}版本${versions.length}

草稿、发布与下架生命周期

+

套餐版本

发布后版本不可直接覆写
${versions.map(item => ``).join('')}
商品版本周期原价售价生效时间状态
${escapeHtml(offeringName(item.offeringId))}${escapeHtml(item.offeringId)}V${item.version}${escapeHtml(item.billingCycle || '-')}${money(Number(item.originalAmountCents || 0) / 100)}${money(Number(item.amountCents || 0) / 100)}${escapeHtml(item.effectiveAt || '-')}${statusBadge(businessStatusLabel(item.status))}
${String(item.status).toLowerCase() === 'draft' ? `` : ``}${String(item.status).toLowerCase() === 'published' ? `` : ''}
+

功能目录

配置套餐可包含的业务功能
${features.map(item => ``).join('')}
功能编码分类参考价核心功能状态
${escapeHtml(item.name)}${escapeHtml(item.description || '')}${escapeHtml(item.code)}${escapeHtml(item.category || '-')}${money(Number(item.referencePriceCents || 0) / 100)}${item.isCore ? '是' : '否'}${statusBadge(businessStatusLabel(item.status))}

功能限额

设置席位、题量、存储等使用上限
${(catalog.limitDefinitions || []).map(item => ``).join('')}
限额指标编码所属功能单位计量方式告警阈值硬限额
${escapeHtml(item.name)}${escapeHtml(item.metricCode)}${escapeHtml(features.find(feature => feature.code === item.featureCode)?.name || item.featureCode)}${escapeHtml(item.unit)}${String(item.kind).toLowerCase() === 'period' ? '周期累计' : '当前值'}${item.warningPercent}%${item.isHardLimit ? '是' : '否'}
`; +} + +function renderSaasFinance() { + const tabs = [['orders', '订单'], ['payments', '支付'], ['refunds', '退款'], ['invoices', '发票'], ['subscriptions', '订阅']]; + const rows = state.saasFinanceTab === 'orders' ? data.saasOrders : state.saasFinanceTab === 'payments' ? data.billingPayments : state.saasFinanceTab === 'refunds' ? data.saasRefunds : state.saasFinanceTab === 'invoices' ? data.invoices : data.subscriptions; + const cells = item => state.saasFinanceTab === 'orders' ? [item.orderNo, tenantById(item.tenantId)?.name || item.tenantId, item.purpose, money(Number(item.totalAmountCents || 0) / 100), businessStatusLabel(item.status), item.expiresAt] : state.saasFinanceTab === 'payments' ? [item.paymentNo || item.id, tenantById(item.tenantId)?.name || item.tenantId, item.provider || '-', money(item.amount), businessStatusLabel(item.status), item.paidAt || '-'] : state.saasFinanceTab === 'refunds' ? [item.refundNo || item.id, tenantById(item.tenantId)?.name || item.tenantId, item.reason || '-', money(Number(item.amountCents || 0) / 100), businessStatusLabel(item.status), item.completedAt || '-'] : state.saasFinanceTab === 'invoices' ? [item.id, tenantById(item.tenantId)?.name || item.tenantId, item.type, money(item.total), item.status, item.due] : [item.id, tenantById(item.tenantId)?.name || item.tenantId, item.planCode || '-', money(item.amount), item.status, item.expires]; + return `
${pageHead('SaaS 订单与交易', '集中查看租户订购、收款、退款、开票和订阅情况。')}
${rows.map(item => `${cells(item).map((value, index) => ``).join('')}`).join('')}
编号租户类型 / 说明金额状态时间
${index === 0 ? `${escapeHtml(value)}` : escapeHtml(value)}
${rows.length ? '' : emptyBlock('当前暂无数据', '有新的业务记录后会在这里显示。')}
`; +} + +function renderRoles() { + return `
${pageHead('平台角色与功能', '按岗位配置可访问的菜单和可执行的业务操作。', ``)}

平台角色

${data.platformRoles.length} 个启用角色
${data.platformRoles.map(role => `
${icon('shield-check')}
${escapeHtml(role.name)}${escapeHtml(role.description || '暂无角色说明')}
${role.permissionCodes.length} 项功能
`).join('')}

可分配功能

选择角色可以使用的业务功能
${Object.entries(data.platformPermissions).map(([code, name]) => `
${icon('key-round')}
${escapeHtml(name)}${escapeHtml(code)}
${data.platformRoles.filter(role => role.permissionCodes.includes(code) || role.permissionCodes.includes('*')).length} 个角色
`).join('')}
`; +} + function renderOverview() { if (platformApi?.isEnabled()) return renderApiOverview(); const active = data.tenants.filter(tenant => tenant.status === '正常').length; @@ -792,7 +890,7 @@ function renderBillingProfile() { function renderSubscriptions() { const selectedTenant = state.selectedTenant ? tenantById(state.selectedTenant) : null; const rows = state.scenario === 'empty' ? [] : data.subscriptions; - return `
${pageHead('SaaS 套餐与订阅', '维护套餐能力边界,并为租户创建或续订订阅。当前 API 只提供套餐查询,因此不提供套餐编辑。', `${canWrite('platform:billing:write') ? `` : ''}`)} + return `
${pageHead('SaaS 套餐与订阅', '查看套餐权益,并为租户创建或续订订阅。套餐内容请在“商品与版本”中维护。', `${canWrite('platform:billing:write') ? `` : ''}`)}
${data.plans.map((plan, index) => `
${plan.name}

${plan.description}

${plan.tenantCount} 家租户
${money(plan.amount)}/年
    ${plan.quotas.map(item => `
  • ${icon('check')}${item}
  • `).join('')}
`).join('')}
${rows.length ? `

活跃与试用订阅

${rows.length} 条${selectedTenant ? ` · 已定位 ${selectedTenant.name}` : ''}
${rows.map(subscription => { const tenant = tenantById(subscription.tenantId); return ``; }).join('')}
租户订阅号套餐周期开始时间到期时间自动续费状态
${escapeHtml(tenant?.name || '-')}${escapeHtml(tenant?.slug || '-')}${subscription.id}${planByCode(subscription.planCode)?.name || subscription.planCode}${subscription.cycle}${subscription.start}${subscription.expires}${statusBadge(subscription.status)}
` : emptyBlock('还没有订阅', '创建租户订阅后将在此显示。', canWrite('platform:billing:write') ? '' : '')}
`; } @@ -827,28 +925,49 @@ function renderDunning() { function renderBank() { const selected = bankById(state.selectedBank) || data.banks[0]; + if (selected && state.selectedBank !== selected.id) state.selectedBank = selected.id; const query = state.bankSearch.trim().toLowerCase(); - const bankRows = data.banks.filter(bank => !query || [bank.name, bank.catalog, bank.source, bank.version].some(value => String(value).toLowerCase().includes(query))); - return `
${pageHead('公共题库目录与授权', '维护公共题库目录、授权规则与租户采用同步状态。授权禁用不会静默删除租户快照。', `${canWrite('platform:question_bank:grant') ? `` : ''}`)} -
${contractResult('bank-query')}${contractResult('bank-sync')} - - ${state.bankTab === 'catalog' ? `
${escapeHtml(selected.catalog)}

${escapeHtml(selected.name)}

当前版本 ${selected.version} · 来源 ${selected.source}

题目${formatNumber(selected.questions)}
授权规则${data.grants.filter(grant => grant.bankId === selected.id).length}
采用租户${data.bankSync.filter(sync => sync.bankId === selected.id).length}
当前状态${selected.status}
${selected.status}

${selected.version}

公共题库内容由平台内容生产链路维护,本页只负责授权和采用状态。

更新时间 2026-07-23 09:00 CST
` : state.bankTab === 'grants' ? `
${data.grants.filter(grant => !query || [grant.id, bankById(grant.bankId)?.name, grant.scope, ...grant.allowedRegionIds, ...grant.allowedSubjectIds].some(value => String(value || '').toLowerCase().includes(query))).map(grant => ``).join('')}
规则题库授权范围套餐 / 租户地区 / 科目有效期本地改编状态
${grant.id}${escapeHtml(bankById(grant.bankId)?.name || '-')}${grant.scope}${grant.allowedPlanCodes.length ? grant.allowedPlanCodes.map(code => planByCode(code)?.name || code).join('、') : grant.allowedTenantIds.map(id => tenantById(id)?.name || id).join('、') || '-'}${[...grant.allowedRegionIds, ...grant.allowedSubjectIds].join(' / ') || '不限'}${grant.startsAt} 至 ${grant.expiresAt || '长期'}${grant.localEdit ? '允许' : '只读采用'}${statusBadge(grant.status)}
` : `

租户采用与同步状态

源版本、任务状态与冲突均来自专属查询
${data.bankSync.filter(sync => !query || [tenantById(sync.tenantId)?.name, bankById(sync.bankId)?.name, sync.current, sync.target, sync.status].some(value => String(value || '').toLowerCase().includes(query))).map(sync => ``).join('')}
租户题库当前版本目标版本同步进度冲突状态
${escapeHtml(tenantById(sync.tenantId)?.name || '-')}${escapeHtml(bankById(sync.bankId)?.name || '-')}${sync.current}${sync.target}
${sync.progress}%
${sync.conflicts}${statusBadge(sync.status)}
`}
`; + const bankRows = data.banks.filter(bank => !query || [bank.name, bank.status].some(value => String(value || '').toLowerCase().includes(query))); + const nodes = data.bankNodes.filter(node => node.questionBankId === selected?.id); + const nodeOptions = nodes.filter(node => node.isActive).map(node => ``).join(''); + const tabs = [['structure', '内容结构'], ['single', '单题录入'], ['batch', '批量导入'], ['manage', '题目管理'], ['structured', 'JSON 文件导入']]; + const bankSelect = ``; + let content = emptyBlock('尚未创建公共题库', '点击“新建题库”后即可配置分类、科目、章节和试卷。'); + if (selected && state.bankTab === 'structure') content = `
平台公共题库

${escapeHtml(selected.name)}

通过分类、科目、章节和试卷组织题目。

内容节点${selected.nodes || nodes.length}
题目总数${formatNumber(selected.questions || 0)}
启用节点${nodes.filter(item => item.isActive).length}
题库状态${selected.status}
${state.selectedBankNode ? `
${bankNodeTypeLabel(bankNodeById(state.selectedBankNode)?.nodeType)}

${escapeHtml(bankNodeById(state.selectedBankNode)?.name || '')}

当前节点共 ${bankNodeById(state.selectedBankNode)?.questionCount || 0} 道未归档题目。

` : ''}
`; + if (selected && state.bankTab === 'structure' && state.selectedBankNode) content += `

节点排序

调整当前节点在同级内容中的显示顺序
`; + if (selected && state.bankTab === 'single') content = `

单题录入

保存后自动形成新题目版本
`; + if (selected && state.bankTab === 'batch') content = `

普通题目批量导入

先选择章节或试卷,再粘贴题目数组
${renderBankImportResult()}
`; + if (selected && state.bankTab === 'manage') content = `
${data.bankQuestions.map(item => ``).join('')}
题目题型难度节点版本状态
${escapeHtml(String(item.content || '').slice(0, 80) || '未填写题干')}${escapeHtml(item.legacyId || item.id)}${questionTypeLabel(item.type)}${item.difficulty || '-'}${escapeHtml(bankNodeById(item.contentNodeId)?.name || '-')}V${item.versionNo}${statusBadge(item.statusLabel)}${item.statusLabel !== '已归档' ? `` : ''}
${data.bankQuestions.length ? `
共 ${state.bankQuestionTotal} 道题
` : emptyBlock('暂无题目', '选择节点后查询,或先录入、导入题目。')}
`; + if (selected && state.bankTab === 'structured') content = `

JSON 文件导入

支持带分类、科目、章节/试卷和题目的 2.0 结构化文件
${renderBankImportResult()}
`; + return `
${pageHead('公共题库', '配置公共题库内容结构,完成单题录入、题目管理和两种 JSON 导入。', `${selected ? `` : ''}`)}
${bankSelect}
${contractResult('bank-query')}${content}
`; +} + +function renderBankImportResult() { + const result = data.bankImportResult; + if (!result) return ''; + const job = result.detail?.job || result.job || {}; + const issues = result.detail?.issues || result.issues || []; + return `
${job.dryRun ? '预检完成' : '导入完成'}

共 ${job.totalCount || 0} 条,${job.validCount || 0} 条有效

新增题目 ${result.insertedQuestionCount || 0},更新 ${result.updatedQuestionCount || 0},跳过 ${result.skippedQuestionCount || 0},新建节点 ${result.createdNodeCount || 0}。

${issues.length ? `${issues.slice(0, 8).map(item => `第 ${item.rowNo || '-'} 行:${escapeHtml(item.message)}`).join('
')}
` : '没有发现数据问题。'}
`; } function renderCrm() { const activeConfigs = data.crmConfigs.filter(item => item.enabled).length; const failedLeads = data.crmLeads.filter(item => item.status === '推送失败').length; - const notice = platformApi?.isEnabled() ? '已接入平台 CRM API,可集中维护租户线索推送配置、失败队列和同步日志。' : '集中管理租户 CRM 接入、线索队列与分配策略。'; + const notice = '集中维护租户线索推送配置、失败队列、分配策略和同步日志。'; + if (state.crmTab === 'logs') return `
${pageHead('CRM 推送日志', notice)}
${data.crmLogs.map(item => ``).join('')}
日志 ID租户队列任务HTTP 状态耗时尝试次数结果 / 错误时间
${escapeHtml(item.id)}${escapeHtml(tenantById(item.tenantId)?.name || item.tenantId)}${escapeHtml(item.queueId || '-')}${escapeHtml(item.statusCode || '-')}${escapeHtml(item.durationMs || 0)} ms${escapeHtml(item.attempt || item.attempts || 1)}${statusBadge(item.success ? '成功' : '失败')}${escapeHtml(item.error || item.responseSummary || '')}${escapeHtml(item.createdAt || '-')}
${data.crmLogs.length ? '' : emptyBlock('暂无 CRM 推送日志', '线索推送后会在这里保留请求结果与错误摘要。')}
`; return `
${pageHead('CRM 接入与线索流转', notice, `${canWrite('platform:crm:write') ? `` : ''}`)}
已启用接入${activeConfigs}共 ${data.crmConfigs.length} 家租户配置
待推送线索${data.crmLeads.filter(item => item.status === '待推送').length}来自学生端留资与活动报名
推送失败${failedLeads}进入重试或死信处理
权限${hasPermission('platform:crm:write') ? '可维护' : '只读'}platform:crm:read/write
- + ${state.crmTab === 'configs' ? `
${data.crmConfigs.map(config => ``).join('')}
租户ProviderEndpoint分配方式线索池超时 / 重试状态
${escapeHtml(tenantById(config.tenantId)?.name || '-')}${escapeHtml(config.id)}${escapeHtml(config.provider)}${escapeHtml(config.endpoint)}${escapeHtml(config.assignmentMode)}${escapeHtml(config.pool)}${config.timeout}s / ${config.retry} 次${statusBadge(config.status)}
${canWrite('platform:crm:write') ? `` : ''}
` : state.crmTab === 'leads' ? `
${data.crmLeads.map(lead => ``).join('')}
线索租户来源 / 科目负责人尝试次数状态错误摘要
${escapeHtml(lead.student)}${lead.id} · ${lead.phone}${escapeHtml(tenantById(lead.tenantId)?.name || '-')}${escapeHtml(lead.source)}${escapeHtml(lead.subject)}${escapeHtml(lead.owner)}${lead.attempts}${statusBadge(lead.status)}${escapeHtml(lead.lastError || '-')}${lead.status === '推送失败' && canWrite('platform:crm:write') ? `` : ''}
` : `

平台默认策略

租户可在套餐授权后覆盖
${[['按地区轮转', '地区、校区、课程标签优先,适合多校区机构'], ['按校区容量', '按顾问容量与当日跟进上限分配'], ['手工分配', '只进入队列,由租户管理员手工认领']].map(([name, desc]) => `
${icon('route')}
${name}${desc}
启用
`).join('')}

线索同步边界

平台统一监控推送质量和异常重试

接入配置维护 endpoint、secret 引用、超时和重试策略

线索队列集中查看待推送、失败和重试中的线索

同步日志保留请求结果、错误摘要和审计线索

`}
`; } function renderSms() { const sent = data.smsChannels.reduce((sum, item) => sum + item.used, 0); const quota = data.smsChannels.reduce((sum, item) => sum + item.quota, 0); - const notice = platformApi?.isEnabled() ? '已接入平台 SMS API,渠道、模板和发送日志来自后端真实数据。' : '集中管理租户短信渠道、模板审核状态和发送记录。'; + const notice = '集中管理租户短信渠道、模板审核状态和发送记录。'; + const smsNav = ``; + if (state.smsTab === 'templates') return `
${pageHead('短信模板与审核', notice, ``)}${smsNav}
${data.smsTemplates.map(template => ``).join('')}
租户模板渠道编码类型发送 / 失败审核状态启用状态
${escapeHtml(tenantById(template.tenantId)?.name || template.tenantId)}${escapeHtml(template.name)}${escapeHtml(template.id)}${escapeHtml(template.channelName || data.smsChannels.find(item => item.id === template.channelId)?.signature || '-')}${escapeHtml(template.code)}${escapeHtml(template.type)}${template.sentCount || 0} / ${template.failedCount || 0}${statusBadge(template.status)}${statusBadge(template.templateStatus || '-')}${template.status === '草稿' ? `` : ''}
`; + if (state.smsTab === 'logs') return `
${pageHead('短信发送日志', notice)}${smsNav}
${(data.smsLogs || []).map(item => ``).join('')}
日志 ID租户模板手机号Provider时间状态
${escapeHtml(item.id)}${escapeHtml(tenantById(item.tenantId)?.name || item.tenantId)}${escapeHtml(data.smsTemplates.find(template => template.id === item.templateId)?.name || item.templateId || '-')}${escapeHtml(item.phone)}${escapeHtml(item.provider)}${escapeHtml(item.updatedAt)}${statusBadge(item.status)}
${data.smsLogs?.length ? '' : emptyBlock('暂无短信发送日志', '发送短信后会在这里显示脱敏记录。')}
`; return `
${pageHead('SMS 短信服务', notice, `${canWrite('platform:sms:write') ? `` : ''}`)}
启用渠道${data.smsChannels.filter(item => item.enabled).length}租户自有短信服务
本月发送${formatNumber(sent)}总额度 ${formatNumber(quota)}
模板通过${data.smsTemplates.filter(item => item.status === '已通过').length}${data.smsTemplates.length} 个模板
权限${hasPermission('platform:sms:write') ? '可维护' : '只读'}platform:sms:read/write
@@ -857,10 +976,13 @@ function renderSms() { function renderPaymentSettings() { const activeApps = data.paymentApps.filter(item => item.status === '正常').length; - const notice = platformApi?.isEnabled() ? '已接入平台支付设置 API,可维护平台收款应用、租户代管应用和支付事件。' : '集中管理平台收款、租户支付代管和回调健康。'; + const notice = '集中管理平台收款、租户支付代管、支付渠道和回调健康。'; + const paymentNav = ``; + if (state.paymentTab === 'channels') return `
${pageHead('平台支付渠道', '管理平台收款应用的支付服务商、回调地址和密钥引用。', ``)}${paymentNav}
${data.paymentChannels.map(item => ``).join('')}
应用渠道支付服务商模式回调路径密钥引用状态
${escapeHtml(data.paymentApps.find(appItem => appItem.id === item.appId)?.appName || item.appId)}${escapeHtml(item.displayName)}${escapeHtml(item.id)}${escapeHtml(item.provider)}${escapeHtml(item.mode)}${escapeHtml(item.callbackPath || '-')}${escapeHtml(item.secretRef || '-')}${statusBadge(businessStatusLabel(item.status))}
${data.paymentChannels.length ? '' : emptyBlock('暂无平台支付渠道', '请先创建平台收款应用。')}
`; + if (state.paymentTab === 'rebates') { const summary = data.rebateSummary || {}; return `
${pageHead('平台返佣汇总', '查看平台交易的返佣、待结算、已结算与异常数据。')}${paymentNav}
交易总额${money(Number(summary.grossAmountCents || 0) / 100)}
返佣总额${money(Number(summary.commissionAmountCents || 0) / 100)}
待结算${money(Number(summary.pendingSettlementCents || 0) / 100)}
已结算${money(Number(summary.paidSettlementCents || 0) / 100)}
异常数${summary.exceptionCount || 0}
`; } return `
${pageHead('支付设置', notice, `${canWrite('platform:payment:write') ? `` : ''}`)}
正常应用${activeApps}共 ${data.paymentApps.length} 个收款应用
本日支付事件${data.paymentEvents.length}含成功、回调失败与人工确认
回调异常${data.paymentEvents.filter(item => item.status === '回调失败').length}需要财务或运维介入
权限${hasPermission('platform:payment:write') ? '可维护' : '只读'}platform:payment:read/write
- + ${paymentNav} ${state.paymentTab === 'apps' ? `
${data.paymentApps.map(appItem => ``).join('')}
租户应用Provider模式结算Secret Ref状态
${escapeHtml(tenantById(appItem.tenantId)?.name || '-')}${appItem.id}${escapeHtml(appItem.appName)}${escapeHtml(appItem.provider)}${escapeHtml(appItem.mode)}${escapeHtml(appItem.settlement)}${escapeHtml(appItem.secretRef)}${statusBadge(appItem.status)}
${canWrite('platform:payment:write') ? `` : ''}
` : state.paymentTab === 'events' ? `
${data.paymentEvents.map(event => { const appItem = data.paymentApps.find(item => item.id === event.appId); return ``; }).join('')}
事件应用订单号金额渠道回调时间状态
${event.id}${escapeHtml(appItem?.appName || '-')}${escapeHtml(tenantById(appItem?.tenantId)?.name || '-')}${escapeHtml(event.orderNo)}${money(event.amount)}${escapeHtml(event.channel)}${escapeHtml(event.notified)}${escapeHtml(event.updatedAt)}${statusBadge(event.status)}
` : `

平台风控规则

配置证书、回调、人工确认等支付安全策略
${[['证书过期提醒', '支付证书 14 天内过期生成高危告警'], ['回调失败重试', '按 1m/5m/30m/2h 退避重试并记录审计'], ['人工确认保护', '线下收款必须二次确认并保留流水号脱敏值']].map(([name, desc]) => `
${icon('shield-check')}
${name}${desc}
启用
`).join('')}

支付能力边界

区分平台收款和租户收款代管

平台收款应用用于套餐、模块和增值服务收款

租户代管应用维护学生端收款渠道和回调地址

支付事件只读跟踪交易、退款、回调和人工确认状态

`}
`; } @@ -869,7 +991,7 @@ function renderStaff() { const staffRows = data.staff.filter(person => !query || [person.name, person.username, person.email, person.phone, person.role, person.status].some(value => String(value || '').toLowerCase().includes(query))); return `
${pageHead('平台员工与权限', '平台权限独立于租户权限;禁用员工可同步撤销会话,且不能禁用最后一名超级管理员。', `${canWrite('platform:staff:write') ? `` : ''}`)}
-

平台员工

${staffRows.length} 名匹配 · ${data.staff.filter(item => item.status === '正常').length} 名活跃
${staffRows.map(person => `
${escapeHtml(person.name.slice(0, 1))}
${escapeHtml(person.name)}${escapeHtml(person.email)} · ${escapeHtml(person.phone)}
${escapeHtml(person.role)}${person.permissions.includes('*') ? '全部权限' : `${person.permissions.length} 项权限`}${person.status}
`).join('') || emptyBlock('没有匹配员工', '调整姓名、账号、邮箱、角色或状态关键词。')}

权限目录

遵循最小权限;当前演示角色可在顶部切换
${Object.entries(roleDefinitions).filter(([key]) => key !== 'readonly').map(([key, role]) => `
${icon('shield-check')}
${role.label}${role.permissions.includes('*') ? '全部平台权限' : role.permissions.join(' · ')}
${data.staff.filter(person => person.role === role.label).length}人
`).join('')}
`; +

平台员工

${staffRows.length} 名匹配 · ${data.staff.filter(item => item.status === '正常').length} 名活跃
${staffRows.map(person => `
${escapeHtml(person.name.slice(0, 1))}
${escapeHtml(person.name)}${escapeHtml(person.email)} · ${escapeHtml(person.phone)}
${escapeHtml(person.role)}${person.permissions.includes('*') ? '全部平台功能' : `${person.permissions.length} 项功能`}${person.status}
`).join('') || emptyBlock('没有匹配员工', '调整姓名、邮箱、手机号、角色或状态关键词。')}

平台角色与功能

按岗位分配可使用的业务功能
${data.platformRoles.map(role => { const features = role.permissionCodes.map(code => data.platformPermissions[code] || code); return `
${icon('shield-check')}
${escapeHtml(role.name)}${escapeHtml(role.description || (features.includes('*') ? '全部平台功能' : features.join('、')))}
${data.staff.filter(person => person.roleIds?.includes(role.id)).length}人
`; }).join('') || emptyBlock('暂无可用平台角色', '请先创建平台角色并选择可用功能。')}
`; } function renderAudit() { @@ -888,12 +1010,12 @@ function renderAlerts() { let alerts = state.alertStatus === 'all' ? data.alerts : data.alerts.filter(alert => alert.status === state.alertStatus); if (state.scenario === 'empty') alerts = []; return `
${pageHead('审计告警与通知', '按 open → acknowledged → resolved/ignored 流转;状态接口禁止重新打开。规则当前只读。', `${canWrite('platform:audit:notification') ? `` : ''}`)} -

告警规则

后端只读 · ${data.alertRules.length} 条启用规则
GET /api/platform-admin/audit-alert-rules
${data.alertRules.map(rule => `
${rule.severity}
${escapeHtml(rule.name)}${escapeHtml(rule.code)} · ${escapeHtml(rule.window)} · ${escapeHtml(rule.threshold)}

${escapeHtml(rule.target)}

${statusBadge(rule.enabled ? '启用' : '已停用')}
`).join('')}
+

告警规则

${data.alertRules.length} 条启用规则
${data.alertRules.map(rule => `
${rule.severity}
${escapeHtml(rule.name)}${escapeHtml(rule.code)} · ${escapeHtml(rule.window)} · ${escapeHtml(rule.threshold)}

${escapeHtml(rule.target)}

${statusBadge(rule.enabled ? '启用' : '已停用')}
`).join('')}

告警队列

开放 ${data.alerts.filter(a => a.status === 'open').length} · 已确认 ${data.alerts.filter(a => a.status === 'acknowledged').length} · 已结束 ${data.alerts.filter(a => ['resolved', 'ignored'].includes(a.status)).length}
${[['all', '全部'], ['open', '开放'], ['acknowledged', '已确认'], ['resolved', '已解决'], ['ignored', '已忽略']].map(([value, label]) => ``).join('')}
${alerts.length ? alerts.map(alert => `
${alert.severity}
${alert.id} · ${alert.owner}${escapeHtml(alert.title)}

${escapeHtml(alert.summary)}

${alert.updatedAt}
${alertStatusLabel(alert.status)}
${alert.status === 'open' && canWrite('platform:audit:alert') ? `` : ''}${['open', 'acknowledged'].includes(alert.status) && canWrite('platform:audit:alert') ? `` : ''}
`).join('') : emptyBlock('当前筛选没有告警', '告警规则仍保持只读,可切换筛选查看历史状态。')}
`; } -const templates = { overview: renderOverview, tenants: renderTenants, tenantDetail: renderTenantDetail, billingProfile: renderBillingProfile, subscriptions: renderSubscriptions, billing: renderBilling, usage: renderUsage, dunning: renderDunning, bank: renderBank, crm: renderCrm, sms: renderSms, paymentSettings: renderPaymentSettings, staff: renderStaff, audit: renderAudit, alerts: renderAlerts }; +const templates = { overview: renderOverview, tenants: renderTenants, domains: renderDomains, tenantDetail: renderTenantDetail, billingProfile: renderBillingProfile, catalog: renderCatalog, subscriptions: renderSubscriptions, saasFinance: renderSaasFinance, billing: renderBilling, usage: renderUsage, dunning: renderDunning, bank: renderBank, crm: renderCrm, sms: renderSms, paymentSettings: renderPaymentSettings, staff: renderStaff, roles: renderRoles, audit: renderAudit, alerts: renderAlerts }; function render() { renderNav(); @@ -965,7 +1087,7 @@ function closeModal(options = {}) { return true; } function navigationSnapshot() { - return { view: state.view, selectedTenant: state.selectedTenant, selectedInvoice: state.selectedInvoice, selectedAlert: state.selectedAlert, tenantFilter: state.tenantFilter, tenantPlan: state.tenantPlan, tenantSearch: state.tenantSearch, invoiceSearch: state.invoiceSearch, invoiceStatus: state.invoiceStatus, billingTab: state.billingTab, bankTab: state.bankTab, crmTab: state.crmTab, smsTab: state.smsTab, paymentTab: state.paymentTab, bankIncludeTenant: state.bankIncludeTenant, bankSearch: state.bankSearch, selectedBank: state.selectedBank, staffSearch: state.staffSearch, usagePeriod: state.usagePeriod, auditRange: state.auditRange, auditSeverity: state.auditSeverity, auditSearch: state.auditSearch, alertStatus: state.alertStatus, scrollY: window.scrollY }; + return { view: state.view, selectedTenant: state.selectedTenant, selectedInvoice: state.selectedInvoice, selectedAlert: state.selectedAlert, tenantFilter: state.tenantFilter, tenantPlan: state.tenantPlan, tenantSearch: state.tenantSearch, invoiceSearch: state.invoiceSearch, invoiceStatus: state.invoiceStatus, billingTab: state.billingTab, bankTab: state.bankTab, crmTab: state.crmTab, smsTab: state.smsTab, paymentTab: state.paymentTab, saasFinanceTab: state.saasFinanceTab, bankSearch: state.bankSearch, selectedBank: state.selectedBank, selectedBankNode: state.selectedBankNode, staffSearch: state.staffSearch, usagePeriod: state.usagePeriod, auditRange: state.auditRange, auditSeverity: state.auditSeverity, auditSearch: state.auditSearch, alertStatus: state.alertStatus, scrollY: window.scrollY }; } function syncNavigationState() { if (history.state?.[HISTORY_APP_KEY]) history.replaceState({ ...history.state, snapshot: navigationSnapshot() }, '', location.href); } function navigate(view, options = {}) { @@ -986,6 +1108,7 @@ function navigate(view, options = {}) { state.previousView = state.view; state.view = view; state.scenario = 'normal'; closeModal({ force: true }); render(); scrollTo(0, 0); if (options.browser !== false) { historySequence += 1; history.pushState({ [HISTORY_APP_KEY]: true, sequence: historySequence, snapshot: navigationSnapshot() }, '', location.href); } if (['tenantDetail', 'billingProfile'].includes(view)) hydrateTenantDetail(state.selectedTenant); + if (view === 'bank') loadBankWorkspace(); }; if (!options.force && hasUnsavedChanges()) return requestLeave(perform); perform(); return true; @@ -1077,7 +1200,7 @@ async function simulateWrite({ key, permission, validate, apply, success, close }; finish(); } -async function executeBackendMutation(operationId, input, apply, { refresh = true } = {}) { +async function executeBackendMutation(operationId, input, apply, { refresh = true, pathParams = {} } = {}) { if (!platformApi?.isEnabled()) { apply?.(null); return true; @@ -1085,6 +1208,7 @@ async function executeBackendMutation(operationId, input, apply, { refresh = tru try { const payload = await platformApi.request(operationId, { body: platformApi.buildBody(operationId, typeof input === 'function' ? input() : input), + pathParams, }); apply?.(payload); if (refresh) hydrateFromBackend({ silent: true }); @@ -1127,11 +1251,44 @@ function channelEndpointFormValue(channel) { } const modalTemplates = { + saasFeature() { + return modalFrame('新建 SaaS 功能', '功能将用于套餐版本与租户权益校验。', ``, ``, { id: 'SaaS 商品目录', wide: true }); + }, + saasOffering() { + return modalFrame('新建 SaaS 商品', '商品可为基础套餐或增值包。', ``, ``, { id: 'SaaS 商品目录', wide: true }); + }, + saasLimit() { + const features = data.saasCatalog?.features || []; + return modalFrame('新建 SaaS 功能限额', '限额定义供套餐版本配置和运行时用量校验使用。', ``, ``, { id: 'SaaS 功能限额', wide: true }); + }, + tenantFeatureOverride() { + const features = data.saasCatalog?.features || []; + return modalFrame('租户功能覆盖', '对单个租户强制启用或停用某项 SaaS 功能,必须记录可审计原因。', ``, ``, { id: 'SaaS 租户权益', wide: true }); + }, + saasVersion() { + const offerings = data.saasCatalog?.offerings || []; + return modalFrame('新建套餐版本', '新版本先保存为草稿,确认后再单独发布。', ``, ``, { id: 'SaaS 版本管理', wide: true }); + }, + platformRole() { + const role = data.platformRoles.find(item => item.id === state.modalData.roleId) || {}; + return modalFrame(role.id ? '编辑平台角色' : '新建平台角色', '选择该角色可以使用的业务功能,保存后对所属员工生效。', ``, ``, { id: '平台角色管理', wide: true }); + }, + paymentChannel() { + const channel = data.paymentChannels.find(item => item.id === state.modalData.channelId) || {}; + const apps = data.paymentApps.filter(item => item.appScope === 'platform'); + return modalFrame(channel.id ? '编辑平台支付渠道' : '新建平台支付渠道', '渠道密钥只保存 Secret Ref,不在页面保存明文凭据。', ``, ``, { id: '平台支付设置', wide: true }); + }, + smsTemplate() { + const template = data.smsTemplates.find(item => item.id === state.modalData.templateId) || {}; + const tenantId = template.tenantId || state.selectedTenant || data.smsChannels[0]?.tenantId || ''; + const channels = data.smsChannels.filter(item => !tenantId || item.tenantId === tenantId); + return modalFrame(template.id ? '编辑短信模板' : '新建短信模板', '模板先保存为草稿,再通过独立动作提交审核。', ``, ``, { id: '短信模板管理', wide: true }); + }, mobileBoundary() { return modalFrame('请在桌面端继续', `${state.modalData.actionLabel || '此操作'}需要完整工作区。`, `
${icon('monitor-up')}
移动端只支持查看与轻量处理

租户、账务、用量、授权、员工和通知渠道的创建或复杂编辑,请在宽度不少于 768px 的桌面端完成,避免误操作。

`, ``, { id: '全局 · 移动端边界' }); }, createTenant() { - return modalFrame('创建租户', '创建主体、独立域名和首期订阅基础。slug 与域名必须唯一。', ``, ``, { id: 'P-02 · 创建向导', wide: true }); + return modalFrame('开通租户', '创建租户和首位负责人账号;套餐、域名和账务信息可在开通后继续配置。', ``, ``, { id: 'P-02 · 租户开通', wide: true }); }, tenantStatus() { const tenant = tenantById(state.modalData.tenantId); @@ -1151,7 +1308,8 @@ const modalTemplates = { }, payment() { const invoice = invoiceById(state.modalData.invoiceId); - return modalFrame('人工确认收款', `${invoice.id} · 当前余额 ${money(invoice.balance)}。金额不可超过余额,原始支付载荷不会回显。`, ``, ``, { id: 'P-08 · 资金动作', wide: true }); + const payment = paymentForInvoice(invoice); + return modalFrame('人工确认收款', `${invoice.id} · 待确认金额 ${money(payment?.amount || invoice.balance)}。本操作确认后端已存在的待支付记录。`, ``, ``, { id: 'P-08 · 资金动作', wide: true }); }, overduePreview() { const candidates = data.invoices.filter(invoice => invoice.status === '已逾期'); @@ -1169,9 +1327,17 @@ const modalTemplates = { const redactedHint = platformApi?.isEnabled() && channel.endpointRedacted ? ' 后端不会回显完整 URL;编辑时必须重新输入完整 HTTPS Webhook。' : ''; return modalFrame(channel.id ? '编辑催缴渠道' : '新建催缴渠道', `Webhook secret 为只写字段,保存后不会回显。${redactedHint}`, ``, ``, { id: 'P-08 · 通知渠道', wide: true }); }, - grant() { - const grant = data.grants.find(item => item.id === state.modalData.grantId) || {}; - return modalFrame(grant.id ? '编辑公共题库授权' : '新建公共题库授权', '授权范围与允许列表会按后端 DTO 保存;地区和科目字段使用 UUID。', ``, ``, { id: 'P-09 · 授权规则', wide: true }); + bank() { + const bank = bankById(state.modalData.bankId) || {}; + return modalFrame(bank.id ? '编辑公共题库' : '新建公共题库', '题库保存后可继续配置分类、科目、章节和试卷。', ``, ``, { id: 'P-09 · 公共题库' }); + }, + bankNode() { + const node = bankNodeById(state.modalData.nodeId) || {}; + const parents = data.bankNodes.filter(item => item.questionBankId === state.selectedBank && item.isActive && item.id !== node.id); + return modalFrame(node.id ? '编辑内容节点' : '新建内容节点', '使用中文业务层级组织题目,章节和试卷可直接录入题目。', ``, ``, { id: 'P-09 · 内容结构' }); + }, + batchBankNodes() { + return modalFrame('批量创建章节或试卷', '每行一个名称,一次最多创建 100 个。', ``, ``, { id: 'P-09 · 批量创建', wide: true }); }, crmConfig() { const config = data.crmConfigs.find(item => item.id === state.modalData.crmId) || {}; @@ -1188,12 +1354,13 @@ const modalTemplates = { }, staff() { const person = data.staff.find(item => item.id === state.modalData.staffId) || {}; - const presetRole = roleDefinitions[state.modalData.roleKey]?.label || ''; - const selectedRole = person.role || presetRole || '租户运营'; const editablePhone = /^1\d{10}$/.test(person.phone || '') ? person.phone : ''; - const permissionList = ['platform:overview:read', 'platform:tenant:read', 'platform:tenant:write', 'platform:tenant:status', 'platform:tenant:billing_profile', 'platform:plan:read', 'platform:billing:read', 'platform:billing:write', 'platform:billing:payment', 'platform:billing:dunning', 'platform:billing:notification', 'platform:usage:read', 'platform:usage:write', 'platform:question_bank:read', 'platform:question_bank:grant', 'platform:question_bank:ops', 'platform:crm:read', 'platform:crm:write', 'platform:sms:read', 'platform:sms:write', 'platform:payment:read', 'platform:payment:write', 'platform:staff:read', 'platform:staff:write', 'platform:staff:status', 'platform:audit:read', 'platform:audit:export', 'platform:audit:alert', 'platform:audit:notification']; - const presetPermissions = roleDefinitions[state.modalData.roleKey]?.permissions || []; - return modalFrame(person.id ? '编辑平台员工' : '新建平台员工', 'Auth 用户必须存在;按最小权限分组授权。', ``, ``, { id: 'P-13 · 员工权限', wide: true }); + const selectedRoleIds = person.roleIds?.length ? person.roleIds : (state.modalData.roleId ? [state.modalData.roleId] : []); + const roleOptions = data.platformRoles.map(role => { + const featureNames = role.permissionCodes.map(code => data.platformPermissions[code] || code).join('、') || '暂无功能'; + return ``; + }).join(''); + return modalFrame(person.id ? '编辑平台员工' : '新建平台员工', '员工账号与平台角色由后端统一管理,功能说明来自当前权限目录。', ``, ``, { id: 'P-13 · 员工角色', wide: true }); }, auditDetail() { const log = data.auditLogs.find(item => item.id === state.modalData.auditId); @@ -1206,12 +1373,7 @@ const modalTemplates = { notificationDetail() { const event = [...(data.notificationEvents || []), ...(data.dunningEvents || [])].find(item => item.id === state.modalData.eventId); const channel = event ? notificationChannelName(event.channelId) : '-'; - return modalFrame('通知事件详情', `${event?.id || '-'} · 请求与错误摘要已脱敏。`, ``, `${event?.status === '失败' ? `` : ''}`, { id: 'P-15 · 通知事件', wide: true }); - }, - syncDetail() { - const [tenantId, bankId] = String(state.modalData.syncKey || '').split(':'); - const sync = data.bankSync.find(item => item.tenantId === tenantId && item.bankId === bankId); - return modalFrame('租户采用与同步详情', `${tenantById(tenantId)?.name || '-'} · ${bankById(bankId)?.name || '-'}`, ``, ``, { id: 'P-09 · 采用同步', wide: true }); + return modalFrame('通知事件详情', `${event?.id || '-'} · 请求与错误摘要已脱敏。`, ``, `${event?.status === '失败' ? `` : ''}`, { id: 'P-15 · 通知事件', wide: true }); }, auditChannel() { const channel = data.auditChannels.find(item => item.id === state.modalData.channelId) || {}; @@ -1261,15 +1423,52 @@ function validateNotificationChannel(values) { document.addEventListener('submit', event => { event.preventDefault(); const form = event.target; - const desktopOnlyForms = new Set(['createTenantForm', 'tenantStatusForm', 'billingProfileForm', 'subscriptionForm', 'invoiceForm', 'paymentForm', 'usageForm', 'dunningChannelForm', 'grantForm', 'crmConfigForm', 'smsChannelForm', 'paymentAppForm', 'staffForm', 'auditChannelForm']); + const desktopOnlyForms = new Set(['createTenantForm', 'tenantStatusForm', 'billingProfileForm', 'subscriptionForm', 'invoiceForm', 'paymentForm', 'usageForm', 'dunningChannelForm', 'bankForm', 'bankNodeForm', 'bankNodeBatchForm', 'crmConfigForm', 'smsChannelForm', 'paymentAppForm', 'staffForm', 'auditChannelForm']); if (desktopOnlyForms.has(form.id) && requireDesktop('提交复杂配置或资金操作')) return; + if (form.id === 'saasFeatureForm') { + const values = Object.fromEntries(new FormData(form)); + simulateWrite({ key: 'saas-feature', permission: 'platform:plan:write', operationId: 'PlatformSaasController_upsertFeature', body: values, validate: () => !values.code || !values.name || !values.category ? { code: '功能编码、名称和分类不能为空' } : null, apply: payload => { const item = payload || { ...values, id: entityId('FEATURE'), referencePriceCents: Math.round(Number(values.referencePrice || 0) * 100) }; data.saasCatalog.features.unshift(item); return item; }, success: 'SaaS 功能已保存' }); + } + if (form.id === 'saasOfferingForm') { + const values = Object.fromEntries(new FormData(form)); + simulateWrite({ key: 'saas-offering', permission: 'platform:plan:write', operationId: 'PlatformSaasController_upsertOffering', body: values, validate: () => !values.code || !values.name ? { code: '商品编码和名称不能为空' } : null, apply: payload => { const item = payload || { ...values, id: entityId('OFFERING') }; data.saasCatalog.offerings.unshift(item); return item; }, success: 'SaaS 商品已保存' }); + } + if (form.id === 'saasLimitForm') { + const values = Object.fromEntries(new FormData(form)); values.isHardLimit = Boolean(new FormData(form).get('isHardLimit')); + simulateWrite({ key: 'saas-limit', permission: 'platform:plan:write', operationId: 'PlatformSaasController_upsertFeatureLimit', body: values, validate: () => !values.metricCode || !values.featureCode || !values.name ? { metricCode: '指标编码、所属功能和限额名称不能为空' } : null, apply: payload => { const item = payload || { ...values, id: entityId('LIMIT') }; data.saasCatalog.limitDefinitions.unshift(item); return item; }, success: 'SaaS 功能限额已保存' }); + } + if (form.id === 'tenantFeatureOverrideForm') { + const values = Object.fromEntries(new FormData(form)); + simulateWrite({ key: 'tenant-feature-override', permission: 'platform:plan:write', operationId: 'PlatformSaasController_upsertTenantFeatureOverride', body: values, validate: () => !values.tenantId || !values.featureCode || !values.reason ? { reason: '租户、功能和操作原因不能为空' } : null, apply: payload => payload || values, success: '租户功能覆盖规则已保存' }); + } + if (form.id === 'saasVersionForm') { + const values = Object.fromEntries(new FormData(form)); + simulateWrite({ key: 'saas-version', permission: 'platform:plan:write', operationId: 'PlatformSaasController_upsertVersion', body: values, validate: () => !values.offeringId ? { offeringId: '必须选择所属商品' } : Number(values.amount) < 0 ? { amount: '售价不能小于 0' } : null, apply: payload => { const item = payload || { ...values, id: entityId('VERSION'), version: 1, status: 'draft', amountCents: Math.round(Number(values.amount || 0) * 100), originalAmountCents: Math.round(Number(values.originalAmount || 0) * 100) }; data.saasCatalog.versions.unshift(item); return item; }, success: '套餐版本草稿已保存' }); + } + if (form.id === 'platformRoleForm') { + const values = Object.fromEntries(new FormData(form)); const permissionCodes = new FormData(form).getAll('permissionCodes'); + let savedRole = null; + simulateWrite({ key: 'platform-role', permission: 'platform:staff:write', request: async () => { savedRole = await platformApi.request('PlatformBackofficeController_upsertRole', { body: platformApi.buildBody('PlatformBackofficeController_upsertRole', values) }); await platformApi.request('PlatformBackofficeController_replaceRoleBindings', { pathParams: { roleId: savedRole.id }, body: platformApi.buildBody('PlatformBackofficeController_replaceRoleBindings', { permissionCodes, menuCodes: savedRole.menuCodes || [] }) }); return savedRole; }, validate: () => { const errors = {}; if (!values.code) errors.code = '角色编码不能为空'; if (!values.name) errors.name = '角色名称不能为空'; if (!permissionCodes.length) errors.permissionCodes = '至少选择一项平台功能'; return Object.keys(errors).length ? errors : null; }, apply: payload => { const existing = data.platformRoles.find(item => item.id === values.id); const next = { id: payload?.id || values.id || entityId('ROLE'), code: values.code, name: values.name, description: values.description || '', permissionCodes, menuCodes: payload?.menuCodes || existing?.menuCodes || [] }; if (existing) Object.assign(existing, next); else data.platformRoles.unshift(next); return next; }, success: '平台角色与功能绑定已保存' }); + } + if (form.id === 'paymentChannelForm') { + const values = Object.fromEntries(new FormData(form)); + simulateWrite({ key: 'payment-channel', permission: 'platform:payment:write', operationId: 'PlatformAdminPaymentSettingsController_upsertChannel', body: values, validate: () => { const errors = {}; if (!values.appId) errors.appId = '必须选择平台支付应用'; if (!values.displayName) errors.displayName = '渠道名称不能为空'; if (!values.provider) errors.provider = 'Provider 不能为空'; return Object.keys(errors).length ? errors : null; }, apply: payload => { const existing = data.paymentChannels.find(item => item.id === values.id); const next = payload || { ...values, id: values.id || entityId('PAYCH') }; if (existing) Object.assign(existing, next); else data.paymentChannels.unshift(next); return next; }, success: '平台支付渠道已保存' }); + } + if (form.id === 'smsTemplateForm') { + const values = Object.fromEntries(new FormData(form)); + simulateWrite({ key: 'sms-template', permission: 'platform:sms:write', operationId: 'PlatformAdminSmsController_upsertTemplate', body: values, validate: () => { const errors = {}; ['tenantId', 'channelId', 'code', 'name', 'content'].forEach(name => { if (!values[name]) errors[name] = `${name} 不能为空`; }); return Object.keys(errors).length ? errors : null; }, apply: payload => { const existing = data.smsTemplates.find(item => item.id === values.id); const next = payload ? platformApi.adapters.smsTemplate(payload) : { ...values, id: values.id || entityId('SMSTPL'), status: '草稿', templateStatus: '正常', sentCount: 0, failedCount: 0 }; if (existing) Object.assign(existing, next); else data.smsTemplates.unshift(next); return next; }, success: '短信模板草稿已保存' }); + } if (form.id === 'createTenantForm') { - const slug = fieldValue(form, 'slug'); const host = fieldValue(form, 'primaryHost'); const name = fieldValue(form, 'name'); const amount = parseMoney(fieldValue(form, 'amount')); - simulateWrite({ key: 'create-tenant', permission: 'platform:tenant:write', operationId: 'PlatformAdminTenantsController_create', body: () => ({ ...Object.fromEntries(new FormData(form)), amount }), validate: () => { + const values = Object.fromEntries(new FormData(form)); const slug = values.slug; const name = values.name; + simulateWrite({ key: 'create-tenant', permission: 'platform:tenant:write', operationId: 'PlatformAdminTenantsController_create', body: () => values, validate: () => { const errors = {}; if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) errors.slug = 'slug 仅支持小写字母、数字和连字符'; - if (!name) errors.name = '租户名称不能为空'; if (!/^([a-z0-9-]+\.)+[a-z]{2,}$/i.test(host)) errors.primaryHost = '请输入有效域名'; - if (data.tenants.some(item => item.slug === slug)) errors.slug = 'slug 已存在'; if (data.tenants.some(item => item.host === host)) errors.primaryHost = '域名已被其他租户使用'; return Object.keys(errors).length ? errors : null; - }, apply: payload => { const planCode = fieldValue(form, 'planCode'); const plan = planByCode(planCode); const fallback = { id: `tenant_${slug.replaceAll('-', '_')}`, slug, name, legal: fieldValue(form, 'legalName') || name, brand: fieldValue(form, 'brandName') || name, shortName: fieldValue(form, 'shortName') || name, host, plan: plan.name, planCode, status: fieldValue(form, 'status'), billing: fieldValue(form, 'billingStatus') || (amount > 0 ? '待付款' : '正常'), mode: fieldValue(form, 'mode'), students: 0, questions: 0, storageGB: 0, trafficTB: 0, expires: '', balance: amount, owner: '待配置', ownerPhone: '-', createdAt: todayTime() }; const tenant = payload?.item ? platformApi.adapters.tenant({ ...payload.item, planCode, brandName: fallback.brand }, fallback, Object.fromEntries(data.plans.map(item => [item.code, item]))) : fallback; tenant.host = host; tenant.shortName = fallback.shortName; data.tenants.unshift(tenant); if (!platformApi?.isEnabled() && amount > 0) data.invoices.unshift({ id: entityId('INV'), tenantId: tenant.id, type: '首期服务费', period: '首期', total: amount, paid: 0, balance: amount, due: '2026-08-15', status: '待付款', note: '创建租户首期金额' }); state.selectedTenant = tenant.id; addAudit('tenant.create', 'tenant', tenant.id, tenant.name, '高', `创建租户 ${tenant.name},初始套餐 ${tenant.plan},账务状态 ${tenant.billing},首期金额 ${money(amount)}。`); state.modal = ''; navigate('tenantDetail'); return tenant; }, success: '租户与首期账务已创建,已进入租户详情', close: false, rerender: false }); + if (!name) errors.name = '租户名称不能为空'; + if (!values.ownerName) errors.ownerName = '负责人姓名不能为空'; + if (!values.ownerEmail && !values.ownerPhone) errors.ownerEmail = '负责人邮箱和手机号至少填写一项'; + if (values.ownerPhone && !/^1\d{10}$/.test(values.ownerPhone)) errors.ownerPhone = '请输入 11 位中国大陆手机号'; + if (!/^(?=.*[A-Za-z])(?=.*\d).{12,}$/.test(values.temporaryPassword || '')) errors.temporaryPassword = '临时密码至少 12 位且必须同时包含字母和数字'; + if (data.tenants.some(item => item.slug === slug)) errors.slug = 'slug 已存在'; return Object.keys(errors).length ? errors : null; + }, apply: payload => { const fallback = { id: `tenant_${slug.replaceAll('-', '_')}`, slug, name, legal: values.legalName || name, brand: name, shortName: name, host: '-', plan: '未订阅', planCode: '', status: values.status, billing: values.billingStatus, mode: '-', students: 0, questions: 0, storageGB: 0, trafficTB: 0, expires: '', balance: 0, owner: values.ownerName, ownerPhone: values.ownerPhone || '-', createdAt: todayTime() }; const tenantPayload = payload?.tenant || payload?.item; const tenant = tenantPayload ? platformApi.adapters.tenant(tenantPayload, fallback, {}) : fallback; tenant.owner = values.ownerName; tenant.ownerPhone = values.ownerPhone || '-'; data.tenants.unshift(tenant); state.selectedTenant = tenant.id; addAudit('tenant.create', 'tenant', tenant.id, tenant.name, '高', `开通租户 ${tenant.name} 并创建首位负责人账号。`); state.modal = ''; navigate('tenantDetail'); return tenant; }, success: '租户与负责人账号已开通', close: false, rerender: false }); } if (form.id === 'tenantStatusForm') { const tenant = tenantById(state.modalData.tenantId); const reason = fieldValue(form, 'reason'); @@ -1287,8 +1486,8 @@ document.addEventListener('submit', event => { simulateWrite({ key: 'invoice', permission: 'platform:billing:write', operationId: 'PlatformAdminBillingController_createInvoice', body: values, validate: () => { const errors = {}; if (!values.amount || Number(values.amount) <= 0) errors.amount = '金额必须大于 0'; if (!values.due) errors.due = '到期日不能为空'; if (!values.period) errors.period = '账期不能为空'; return Object.keys(errors).length ? errors : null; }, apply: () => { const invoice = { id: entityId('INV'), tenantId: values.tenantId, type: values.type, period: values.period, total: Number(values.amount), paid: 0, balance: Number(values.amount), due: values.due, status: '待付款', note: values.note || values.type }; data.invoices.unshift(invoice); const tenant = tenantById(values.tenantId); tenant.balance += invoice.balance; tenant.billing = '待付款'; addAudit('invoice.create', 'invoice', invoice.id, invoice.id, '高', `手工创建 ${money(invoice.total)} 服务费账单。`); }, success: '账单草稿已创建' }); } if (form.id === 'paymentForm') { - const invoice = invoiceById(state.modalData.invoiceId); const values = Object.fromEntries(new FormData(form)); const amount = parseMoney(values.amount); - simulateWrite({ key: 'payment', permission: 'platform:billing:payment', operationId: 'PlatformAdminBillingController_confirmPayment', body: () => ({ ...values, tenantId: invoice.tenantId, invoiceId: invoice.backendId || invoice.id, amount }), validate: () => { const errors = {}; if (!amount || amount <= 0) errors.amount = '收款金额必须大于 0'; if (amount > invoice.balance) errors.amount = `收款金额不可超过余额 ${money(invoice.balance)}`; if (!values.paymentNo) errors.paymentNo = '平台收款号不能为空'; if (!values.tradeNo) errors.tradeNo = '交易流水号不能为空'; if (!new FormData(form).get('confirm')) errors.confirm = '请先确认已核对银行流水'; return Object.keys(errors).length ? errors : null; }, apply: () => { invoice.paid += amount; invoice.balance -= amount; invoice.status = invoice.balance === 0 ? '已支付' : '部分支付'; const tenant = tenantById(invoice.tenantId); tenant.balance = Math.max(0, tenant.balance - amount); tenant.billing = tenant.balance === 0 ? '正常' : '待付款'; if (tenant.status === '暂停' && tenant.balance === 0) tenant.status = '正常'; addAudit('invoice.payment.confirm', 'invoice', invoice.id, invoice.id, '高', `人工确认收款 ${money(amount)},交易流水仅保存脱敏摘要。`); }, success: '收款已确认,账单与租户余额已更新' }); + const invoice = invoiceById(state.modalData.invoiceId); const values = Object.fromEntries(new FormData(form)); const payment = paymentForInvoice(invoice); const amount = payment?.amount || invoice.balance; + simulateWrite({ key: 'payment', permission: 'platform:billing:payment', operationId: 'PlatformAdminBillingController_confirmPayment', body: () => ({ ...values, paymentId: payment?.id, amount }), validate: () => { const errors = {}; if (platformApi?.isEnabled() && !payment) errors.paymentId = '该账单没有可确认的待支付记录'; if (!amount || amount <= 0) errors.amount = '收款金额必须大于 0'; if (amount > invoice.balance) errors.amount = `收款金额不可超过余额 ${money(invoice.balance)}`; if (!values.tradeNo) errors.tradeNo = '交易流水号不能为空'; if (!new FormData(form).get('confirm')) errors.confirm = '请先确认已核对银行流水'; return Object.keys(errors).length ? errors : null; }, apply: () => { invoice.paid += amount; invoice.balance -= amount; invoice.status = invoice.balance === 0 ? '已支付' : '部分支付'; if (payment) payment.status = 'succeeded'; const tenant = tenantById(invoice.tenantId); tenant.balance = Math.max(0, tenant.balance - amount); tenant.billing = tenant.balance === 0 ? '正常' : '待付款'; if (tenant.status === '暂停' && tenant.balance === 0) tenant.status = '正常'; addAudit('invoice.payment.confirm', 'invoice', invoice.id, invoice.id, '高', `人工确认收款 ${money(amount)},交易流水仅保存脱敏摘要。`); }, success: '收款已确认,账单与租户余额已更新' }); } if (form.id === 'usageForm') { const values = Object.fromEntries(new FormData(form)); @@ -1299,9 +1498,25 @@ document.addEventListener('submit', event => { const existing = data.dunningChannels.find(item => item.id === values.id); simulateWrite({ key: 'dunning-channel', permission: 'platform:billing:notification', operationId: 'PlatformAdminDunningChannelsController_upsertChannel', body: () => ({ ...values, secretRef: existing?.secretRef, reminderTypes: existing?.reminderTypes, reminderChannels: existing?.reminderChannels, minReminderLevel: existing?.minReminderLevel, enabled: Boolean(new FormData(form).get('enabled')) }), validate: () => validateNotificationChannel(values), apply: () => { const next = { id: values.id || entityId('DUN'), code: values.code, name: values.name, provider: values.provider, endpoint: values.endpoint, endpointRedacted: false, secretRef: existing?.secretRef || '', retry: Number(values.retry), enabled: Boolean(new FormData(form).get('enabled')) }; if (existing) Object.assign(existing, next); else data.dunningChannels.unshift(next); addAudit('dunning.channel.upsert', 'notification_channel', next.id, next.name, '高', `保存催缴渠道 ${next.code},secret 保持只写。`); }, success: '催缴通知渠道已保存' }); } - if (form.id === 'grantForm') { + if (form.id === 'bankForm') { const values = Object.fromEntries(new FormData(form)); - simulateWrite({ key: 'grant', permission: 'platform:question_bank:grant', operationId: 'PlatformAdminQuestionBanksController_upsertGrant', body: () => ({ ...values, localEdit: Boolean(new FormData(form).get('localEdit')) }), validate: () => { const errors = {}; const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const splitScope = value => String(value || '').split(/[、,,]/).map(item => item.trim()).filter(Boolean); if (values.scope === '指定套餐' && !values.planCode) errors.planCode = '指定套餐范围必须选择套餐'; if (values.scope === '指定租户' && !values.tenantId) errors.tenantId = '指定租户范围必须选择租户'; if (values.scope === '套餐 + 租户' && (!values.planCode || !values.tenantId)) errors.planCode = '混合范围必须同时选择套餐与租户'; if (platformApi?.isEnabled() && splitScope(values.regionIds).some(value => !uuid.test(value))) errors.regionIds = '地区范围必须为 UUID,多个值用逗号分隔'; if (platformApi?.isEnabled() && splitScope(values.subjectIds).some(value => !uuid.test(value))) errors.subjectIds = '科目范围必须为 UUID,多个值用逗号分隔'; if (values.expiresAt && values.expiresAt <= values.startsAt) errors.expiresAt = '到期日期必须晚于开始日期'; return Object.keys(errors).length ? errors : null; }, apply: () => { const existing = data.grants.find(item => item.id === values.id); const splitScope = value => String(value || '').split(/[、,,]/).map(item => item.trim()).filter(Boolean); const next = { id: values.id || entityId('GRANT'), bankId: values.bankId, scope: values.scope, allowedPlanCodes: values.planCode ? [values.planCode] : [], allowedTenantIds: values.tenantId ? [values.tenantId] : [], allowedRegionIds: splitScope(values.regionIds), allowedSubjectIds: splitScope(values.subjectIds), status: existing?.status || '启用', startsAt: values.startsAt, expiresAt: values.expiresAt, localEdit: Boolean(new FormData(form).get('localEdit')) }; if (existing) Object.assign(existing, next); else data.grants.unshift(next); addAudit('bank.grant.update', 'grant', next.id, bankById(next.bankId).name, '高', `保存授权范围 ${next.scope};地区 ${next.allowedRegionIds.join('、') || '不限'};科目 ${next.allowedSubjectIds.join('、') || '不限'}。`); }, success: '公共题库授权范围已保存' }); + simulateWrite({ key: 'question-bank', permission: 'platform:question-bank:manage', operationId: 'PlatformQuestionBanksController_upsertBank', body: () => ({ id: values.id || null, name: values.name, metadata: values.version ? { version: values.version } : {} }), validate: () => !values.name?.trim() ? { name: '题库名称不能为空' } : null, apply: payload => { const item = payload ? platformApi.adapters.bank(payload, bankById(payload.id)) : { id: values.id || entityId('QB'), name: values.name, version: values.version || '-', status: '正常', source: '平台自建', catalog: '平台公共题库', nodes: 0, questions: 0 }; const existing = bankById(item.id); if (existing) Object.assign(existing, item); else data.banks.unshift(item); state.selectedBank = item.id; state.selectedBankNode = ''; return item; }, success: '公共题库已保存' }); + } + if (form.id === 'bankNodeForm') { + const values = Object.fromEntries(new FormData(form)); + simulateWrite({ key: 'question-bank-node', permission: 'platform:question-bank:manage', operationId: 'PlatformQuestionBanksController_upsertNode', body: () => ({ id: values.id || null, questionBankId: state.selectedBank, parentId: values.parentId || null, nodeKey: null, name: values.name, nodeType: values.nodeType, sortOrder: Number(values.sortOrder || 0), isSelectable: true, metadata: {} }), validate: () => !values.name?.trim() ? { name: '节点名称不能为空' } : null, apply: payload => { const item = payload ? adaptBankNode(payload) : { id: values.id || entityId('NODE'), questionBankId: state.selectedBank, parentId: values.parentId || null, name: values.name, nodeType: values.nodeType, sortOrder: Number(values.sortOrder || 0), depth: values.parentId ? Number(bankNodeById(values.parentId)?.depth || 0) + 1 : 0, isActive: true, questionCount: 0 }; const existing = bankNodeById(item.id); if (existing) Object.assign(existing, item); else data.bankNodes.push(item); state.selectedBankNode = item.id; return item; }, success: '内容节点已保存' }); + } + if (form.id === 'bankNodeBatchForm') { + const values = Object.fromEntries(new FormData(form)); const names = String(values.names || '').split(/\r?\n/).map(item => item.trim()).filter(Boolean); + simulateWrite({ key: 'question-bank-node-batch', permission: 'platform:question-bank:manage', operationId: 'PlatformQuestionBanksController_batchCreateNodes', body: () => ({ questionBankId: state.selectedBank, parentId: values.parentId || null, nodeType: values.nodeType, names }), validate: () => !names.length ? { names: '至少填写一个章节或试卷名称' } : names.length > 100 ? { names: '一次最多创建 100 个节点' } : null, apply: payload => { const items = apiItems(payload); if (items.length) data.bankNodes.push(...items.map(adaptBankNode)); return items.length || names.length; }, success: count => `已创建 ${count} 个内容节点` }); + } + if (form.id === 'bankQuestionForm') { + const values = Object.fromEntries(new FormData(form)); const options = parseJsonText(values.options, []); const tags = String(values.tags || '').split(/[、,,]/).map(item => item.trim()).filter(Boolean); + simulateWrite({ key: 'question-bank-question', permission: 'platform:question-bank:manage', operationId: 'PlatformQuestionBanksController_upsertQuestion', body: () => ({ id: values.id || null, questionBankId: state.selectedBank, contentNodeId: values.contentNodeId, legacyId: values.legacyId || null, type: values.type, typeLabel: questionTypeLabel(values.type), difficulty: Number(values.difficulty || 3), tags, content: values.content, options, correctOptionIndex: null, correctOptionIndices: [], answerText: values.answerText || null, explanation: values.explanation || null, subQuestions: [], codeLang: null, codeTemplate: null, mediaUrl: values.mediaUrl || null, status: 'published', examMarkers: {}, sourceHash: null }), validate: () => { const errors = {}; if (!values.contentNodeId) errors.contentNodeId = '请选择章节或试卷'; if (!values.content?.trim()) errors.content = '题干不能为空'; if (options === null || !Array.isArray(options)) errors.options = '选项必须是 JSON 数组'; return Object.keys(errors).length ? errors : null; }, apply: payload => { const item = payload ? adaptBankQuestion(payload) : { ...values, id: values.id || entityId('Q'), questionBankId: state.selectedBank, contentNodeId: values.contentNodeId, options, tags, versionNo: 1, status: 'published', statusLabel: '已发布' }; const existing = data.bankQuestions.find(row => row.id === item.id); if (existing) Object.assign(existing, item); else data.bankQuestions.unshift(item); return item; }, success: '题目已保存并生成新版本', close: false }); + } + if (form.id === 'bankBatchImportForm' || form.id === 'bankStructuredImportForm') { + const values = Object.fromEntries(new FormData(form)); const payload = parseJsonText(values.payload, null); const execute = event.submitter?.dataset.importMode === 'execute'; const format = form.id === 'bankStructuredImportForm' ? 'structured-v2' : 'simple'; + simulateWrite({ key: `question-bank-import-${format}`, permission: 'platform:question-bank:manage', operationId: execute ? 'PlatformQuestionBanksController_executeImport' : 'PlatformQuestionBanksController_previewImport', body: () => ({ questionBankId: state.selectedBank, contentNodeId: values.contentNodeId || null, format, sourceName: values.sourceName || (format === 'structured-v2' ? '结构化题库文件' : '普通题目批量导入'), payload }), validate: () => { const errors = {}; if (format === 'simple' && !values.contentNodeId) errors.contentNodeId = '普通批量导入必须选择章节或试卷'; if (payload === null) errors.payload = '请输入有效的 JSON'; return Object.keys(errors).length ? errors : null; }, apply: result => { data.bankImportResult = result || { detail: { job: { dryRun: !execute, totalCount: Array.isArray(payload) ? payload.length : 0, validCount: Array.isArray(payload) ? payload.length : 0, errorCount: 0 }, issues: [] }, insertedQuestionCount: execute && Array.isArray(payload) ? payload.length : 0, updatedQuestionCount: 0, skippedQuestionCount: 0, createdNodeCount: 0 }; return data.bankImportResult; }, success: execute ? '题库导入已执行' : '导入预检已完成', close: false }); } if (form.id === 'crmConfigForm') { const values = Object.fromEntries(new FormData(form)); @@ -1318,8 +1533,8 @@ document.addEventListener('submit', event => { simulateWrite({ key: 'payment-app', permission: 'platform:payment:write', operationId, body: () => values, validate: () => { const errors = {}; if (values.appScope === 'tenant' && !values.tenantId) errors.tenantId = '租户代管必须选择租户'; if (!values.appName) errors.appName = '应用名称不能为空'; return Object.keys(errors).length ? errors : null; }, apply: payload => { const item = payload ? (values.appScope === 'tenant' ? platformApi.adapters.tenantPaymentApp(payload) : platformApi.adapters.platformPaymentApp(payload)) : { ...values, id: values.id || entityId('PAYAPP'), status: values.status }; const index = data.paymentApps.findIndex(row => row.id === item.id); if (index >= 0) data.paymentApps[index] = item; else data.paymentApps.unshift(item); addAudit('payment.app.upsert', 'payment_app', item.id, item.appName, '高', '保存支付应用配置。'); }, success: '支付应用已保存' }); } if (form.id === 'staffForm') { - const values = Object.fromEntries(new FormData(form)); const permissions = new FormData(form).getAll('permissions'); - simulateWrite({ key: 'staff', permission: 'platform:staff:write', operationId: 'PlatformAdminOverviewController_upsertStaff', body: () => ({ ...values, permissions }), validate: () => { const errors = {}; ['authUserId', 'username', 'name', 'email'].forEach(name => { if (!values[name]) errors[name] = `${name} 不能为空`; }); if (platformApi?.isEnabled() && values.authUserId && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(values.authUserId)) errors.authUserId = '真实模式必须填写已存在的 Supabase Auth 用户 UUID'; if (!platformApi?.isEnabled() && values.authUserId && !values.authUserId.startsWith('auth-')) errors.authUserId = 'Mock Auth 用户不存在,请使用 auth- 前缀'; if (!permissions.length) errors.permissions = '至少授予一项平台权限'; return Object.keys(errors).length ? errors : null; }, apply: () => { const existing = data.staff.find(item => item.id === values.id); const next = { id: values.id || entityId('STAFF'), authUserId: values.authUserId, username: values.username, name: values.name, email: values.email, phone: values.phone || '-', role: values.role, permissions, status: existing?.status || '正常', lastActive: existing?.lastActive || '尚未登录' }; if (existing) Object.assign(existing, next); else data.staff.unshift(next); addAudit('staff.upsert', 'staff', next.id, next.name, '高', `保存平台员工及 ${permissions.length} 项有效权限。`); }, success: '平台员工与有效权限已保存' }); + const values = Object.fromEntries(new FormData(form)); const roleIds = new FormData(form).getAll('roleIds'); + simulateWrite({ key: 'staff', permission: 'platform:staff:write', operationId: 'PlatformAdminOverviewController_upsertStaff', body: () => ({ ...values, roleIds }), validate: () => { const errors = {}; if (!values.name) errors.name = '姓名不能为空'; if (!values.email) errors.email = '邮箱不能为空'; if (values.phone && !/^1\d{10}$/.test(values.phone)) errors.phone = '请输入 11 位手机号'; if (!roleIds.length) errors.roleIds = '至少选择一个平台角色'; return Object.keys(errors).length ? errors : null; }, apply: payload => { const existing = data.staff.find(item => item.id === values.userId); const assignedRoles = data.platformRoles.filter(role => roleIds.includes(role.id)); const fallback = { id: values.userId || entityId('STAFF'), userId: values.userId || '', name: values.name, email: values.email, phone: values.phone || existing?.phone || '-', roleIds, roleCodes: assignedRoles.map(role => role.code), role: assignedRoles.map(role => role.name).join('、'), permissions: [...new Set(assignedRoles.flatMap(role => role.permissionCodes))], status: existing?.status || '正常', lastActive: existing?.lastActive || '尚未登录' }; const next = payload ? { ...fallback, ...platformApi.adapters.staff(payload), roleIds, roleCodes: fallback.roleCodes, role: fallback.role, permissions: fallback.permissions } : fallback; if (existing) Object.assign(existing, next); else data.staff.unshift(next); addAudit('staff.upsert', 'staff', next.id, next.name, '高', `保存平台员工及 ${assignedRoles.length} 个角色。`); }, success: '平台员工与角色已保存' }); } if (form.id === 'auditChannelForm') { const values = Object.fromEntries(new FormData(form)); @@ -1342,11 +1557,13 @@ document.addEventListener('click', event => { const notificationDetail = event.target.closest('[data-notification-detail]'); if (notificationDetail) { openModal('notificationDetail', { eventId: notificationDetail.dataset.notificationDetail }); return; } const tf = event.target.closest('[data-tenant-filter]'); if (tf) { state.tenantFilter = tf.dataset.tenantFilter; syncNavigationState(); render(); return; } const billingTab = event.target.closest('[data-billing-tab]'); if (billingTab) { state.billingTab = billingTab.dataset.billingTab; syncNavigationState(); render(); return; } - const bankTab = event.target.closest('[data-bank-tab]'); if (bankTab) { state.bankTab = bankTab.dataset.bankTab; syncNavigationState(); render(); return; } + const bankTab = event.target.closest('[data-bank-tab]'); if (bankTab) { state.bankTab = bankTab.dataset.bankTab; syncNavigationState(); render(); if (['structure', 'manage'].includes(state.bankTab)) loadBankWorkspace(); return; } const crmTab = event.target.closest('[data-crm-tab]'); if (crmTab) { state.crmTab = crmTab.dataset.crmTab; syncNavigationState(); render(); return; } const smsTab = event.target.closest('[data-sms-tab]'); if (smsTab) { state.smsTab = smsTab.dataset.smsTab; syncNavigationState(); render(); return; } const paymentTab = event.target.closest('[data-payment-tab]'); if (paymentTab) { state.paymentTab = paymentTab.dataset.paymentTab; syncNavigationState(); render(); return; } - const bankSelect = event.target.closest('[data-bank-select]'); if (bankSelect) { state.selectedBank = bankSelect.dataset.bankSelect; syncNavigationState(); render(); return; } + const saasFinanceTab = event.target.closest('[data-saas-finance-tab]'); if (saasFinanceTab) { state.saasFinanceTab = saasFinanceTab.dataset.saasFinanceTab; syncNavigationState(); render(); return; } + const bankSelect = event.target.closest('[data-bank-select]'); if (bankSelect) { state.selectedBank = bankSelect.dataset.bankSelect; state.selectedBankNode = ''; syncNavigationState(); render(); loadBankWorkspace(); return; } + const bankNodeSelect = event.target.closest('[data-bank-node-select]'); if (bankNodeSelect) { state.selectedBankNode = bankNodeSelect.dataset.bankNodeSelect; syncNavigationState(); render(); return; } const auditSeverity = event.target.closest('[data-audit-severity]'); if (auditSeverity) { state.auditSeverity = auditSeverity.dataset.auditSeverity; syncNavigationState(); render(); return; } const alertStatus = event.target.closest('[data-alert-status]'); if (alertStatus) { state.alertStatus = alertStatus.dataset.alertStatus; syncNavigationState(); render(); return; } const actionElement = event.target.closest('[data-action]'); const action = actionElement?.dataset.action; if (!action) return; @@ -1367,6 +1584,19 @@ document.addEventListener('click', event => { if (action === 'reset-mock') { if (requireDesktop('重置平台 Mock 数据')) return; confirmAction({ title: '重置平台 Mock 数据', description: '恢复到 v1.1 初始演示数据。', impact: '所有本地创建的租户、账单、任务、授权、员工修改和告警状态将被清除。', confirmLabel: '确认重置' }, () => { localStorage.removeItem(STORAGE_KEY); data = clone(seed); state.selectedTenant = data.tenants[0].id; saveData(); render(); showToast('平台 Mock 数据已重置'); }); return; } if (action === 'open-task-center') { openModal('tasks'); return; } if (action === 'create-tenant') { if (requireDesktop('创建租户')) return; openModal('createTenant'); return; } + if (action === 'new-saas-feature') { openModal('saasFeature'); return; } + if (action === 'new-saas-offering') { openModal('saasOffering'); return; } + if (action === 'new-saas-version') { if (!(data.saasCatalog?.offerings || []).length) { showToast('请先创建 SaaS 商品', 'error'); return; } openModal('saasVersion'); return; } + if (action === 'new-platform-role') { openModal('platformRole'); return; } + if (action === 'edit-platform-role') { openModal('platformRole', { roleId: actionElement.dataset.roleId }); return; } + if (action === 'new-payment-channel') { if (!data.paymentApps.some(item => item.appScope === 'platform')) { showToast('请先创建平台支付应用', 'error'); return; } openModal('paymentChannel'); return; } + if (action === 'edit-payment-channel') { openModal('paymentChannel', { channelId: actionElement.dataset.channelId }); return; } + if (action === 'disable-payment-channel') { const channelId = actionElement.dataset.channelId; if (!platformApi?.isEnabled()) { const channel = data.paymentChannels.find(item => item.id === channelId); if (channel) channel.status = 'disabled'; render(); showToast('支付渠道已停用'); return; } platformApi.request('PlatformAdminPaymentSettingsController_disableChannel', { pathParams: { channelId }, body: {} }).then(payload => { const channel = data.paymentChannels.find(item => item.id === channelId); if (channel) Object.assign(channel, payload); render(); showToast('支付渠道已停用'); }).catch(error => showToast(backendErrorText(error, '停用支付渠道失败'), 'error')); return; } + if (action === 'new-sms-template') { if (!data.smsChannels.length) { showToast('请先创建租户短信渠道', 'error'); return; } openModal('smsTemplate'); return; } + if (action === 'edit-sms-template') { openModal('smsTemplate', { templateId: actionElement.dataset.templateId }); return; } + if (['submit-sms-template', 'disable-sms-template'].includes(action)) { const templateId = actionElement.dataset.templateId; const operationId = action === 'submit-sms-template' ? 'PlatformAdminSmsController_submitTemplateReview' : 'PlatformAdminSmsController_disableTemplate'; if (!platformApi?.isEnabled()) { const template = data.smsTemplates.find(item => item.id === templateId); if (template) { if (action === 'submit-sms-template') template.status = '审核中'; else template.templateStatus = '未启用'; } render(); showToast(action === 'submit-sms-template' ? '短信模板已提交审核' : '短信模板已停用'); return; } platformApi.request(operationId, { pathParams: { templateId }, body: {} }).then(payload => { const template = data.smsTemplates.find(item => item.id === templateId); if (template) Object.assign(template, platformApi.adapters.smsTemplate(payload)); render(); showToast(action === 'submit-sms-template' ? '短信模板已提交审核' : '短信模板已停用'); }).catch(error => showToast(backendErrorText(error, '短信模板操作失败'), 'error')); return; } + if (['publish-saas-version', 'clone-saas-version', 'retire-saas-version'].includes(action)) { const operationId = action === 'publish-saas-version' ? 'PlatformSaasController_publishVersion' : action === 'clone-saas-version' ? 'PlatformSaasController_cloneVersion' : 'PlatformSaasController_retireVersion'; const versionId = actionElement.dataset.versionId; if (!platformApi?.isEnabled()) { const version = data.saasCatalog.versions.find(item => item.id === versionId); if (version) version.status = action === 'retire-saas-version' ? 'retired' : action === 'publish-saas-version' ? 'published' : version.status; render(); showToast('套餐版本状态已更新'); return; } platformApi.request(operationId, { pathParams: { versionId }, body: {} }).then(() => hydrateFromBackend({ silent: true })).then(() => showToast('套餐版本状态已更新')).catch(error => showToast(backendErrorText(error, '套餐版本操作失败'), 'error')); return; } + if (action === 'recheck-domain') { const domain = data.domains.find(item => item.id === actionElement.dataset.domainId); if (!domain) return; if (!platformApi?.isEnabled()) { showToast('真实 API 模式才能重新检查域名', 'error'); return; } platformApi.request('PlatformAdminTenantsController_recheckDomain', { pathParams: { domainId: domain.id }, body: {} }).then(payload => { Object.assign(domain, payload); render(); showToast('域名 DNS/TLS 状态已重新检查'); }).catch(error => showToast(backendErrorText(error, '域名检查失败'), 'error')); return; } if (action === 'prepare-tenant-status') { if (requireDesktop('变更租户生命周期')) return; openModal('tenantStatus', { tenantId: actionElement.dataset.tenantId || state.selectedTenant }); return; } if (action === 'open-tenant-portal') { const tenant = tenantById(state.selectedTenant); let portalWindow = null; try { portalWindow = window.open(TENANT_PORTAL_ORIGIN, '_blank'); if (portalWindow) portalWindow.opener = null; } catch {} showToast(portalWindow ? `已在新标签打开独立租户端 · ${tenant?.name || '当前租户'}` : '已请求打开独立租户端;如未出现,请允许本站弹出窗口', portalWindow ? 'success' : 'error'); return; } if (action === 'edit-billing-profile') { if (requireDesktop('编辑租户账务资料')) return; navigate('billingProfile'); return; } @@ -1403,37 +1633,44 @@ document.addEventListener('click', event => { if (action === 'confirm-overage-invoices') { if (requireDesktop('批量生成超额账单')) return; const selectedKeys = Array.from(overlayRoot.querySelectorAll('[name="candidate"]:checked')).map(input => input.value); if (!selectedKeys.length) { showToast('请至少选择一条超额账单候选', 'error'); return; } const candidates = data.usage.filter(item => item.period === state.usagePeriod && selectedKeys.includes(`${item.tenantId}:${item.period}`) && item.estimated > 0 && !data.invoices.some(invoice => invoice.note === `超额 ${item.period}` && invoice.tenantId === item.tenantId)); closeModal(); const task = addTask(`生成 ${candidates.length} 张超额账单`, '用量与超额计费', () => { candidates.forEach(item => { const invoice = { id: entityId('INV'), tenantId: item.tenantId, type: '用量超额费', period: item.period, total: item.estimated, paid: 0, balance: item.estimated, due: '2026-08-15', status: '待付款', note: `超额 ${item.period}` }; data.invoices.unshift(invoice); const tenant = tenantById(item.tenantId); tenant.balance += invoice.balance; tenant.billing = '待付款'; }); addAudit('usage.invoice.batch.create', 'invoice_batch', task.id, task.id, '高', `生成 ${candidates.length} 张用量超额账单。`); }); showToast(`超额账单任务 ${task.id} 已创建`); return; } if (action === 'new-dunning-channel') { if (requireDesktop('新建催缴渠道')) return; openModal('dunningChannel'); return; } if (action === 'edit-dunning-channel') { if (requireDesktop('编辑催缴渠道')) return; openModal('dunningChannel', { channelId: actionElement.dataset.channelId }); return; } - if (action === 'toggle-dunning-channel') { if (requireDesktop('变更催缴渠道状态')) return; if (!canWrite('platform:billing:notification')) return; const channel = data.dunningChannels.find(item => item.id === actionElement.dataset.channelId); if (!channel) return; if (platformApi?.isEnabled() && channel.endpointRedacted) { openModal('dunningChannel', { channelId: channel.id }); showToast('后端不回显完整 Webhook;请重新输入完整 HTTPS URL 后保存状态', 'error'); return; } const nextEnabled = !channel.enabled; const apply = () => { channel.enabled = nextEnabled; addAudit('dunning.channel.status', 'notification_channel', channel.id, channel.name, '高', `催缴渠道${channel.enabled ? '启用' : '停用'}。`); saveData(); render(); showToast('渠道状态已更新'); }; if (platformApi?.isEnabled()) executeBackendMutation('PlatformAdminDunningChannelsController_upsertChannel', { ...channel, enabled: nextEnabled }, apply); else apply(); return; } - if (action === 'new-grant') { if (requireDesktop('新建公共题库授权')) return; openModal('grant'); return; } - if (action === 'apply-bank-search') { runContractRead({ key: 'bank-query', actionId: 'act.p-09.01', operationId: 'PlatformAdminQuestionBanksController_banks', title: '题库搜索', suppliedRequestId: actionElement.dataset.contractRequestId, liveRequest: () => platformApi.request('PlatformAdminQuestionBanksController_banks', { query: { q: state.bankSearch || undefined, status: 'active', includeTenantBanks: state.bankIncludeTenant, limit: 200 } }), apply: payload => { const items = apiItems(payload); if (items.length) data.banks = items.map(item => platformApi.adapters.bank(item, bankById(item.id))); return `${data.banks.filter(bank => !state.bankSearch.trim() || [bank.name, bank.catalog, bank.source, bank.version].some(value => String(value).toLowerCase().includes(state.bankSearch.trim().toLowerCase()))).length} 个匹配题库`; } }); return; } - if (action === 'edit-grant') { if (requireDesktop('编辑公共题库授权')) return; openModal('grant', { grantId: actionElement.dataset.grantId }); return; } - if (action === 'toggle-grant') { if (requireDesktop('变更公共题库授权状态')) return; if (!canWrite('platform:question_bank:grant')) return; const grant = data.grants.find(item => item.id === actionElement.dataset.grantId); if (!grant) return; const next = grant.status === '启用' ? '已禁用' : '启用'; const apply = () => { grant.status = next; addAudit('bank.grant.status', 'grant', grant.id, bankById(grant.bankId)?.name || grant.bankId, '高', `授权规则状态更新为 ${next},租户快照保留。`); saveData(); render(); showToast('授权状态已更新'); }; const execute = () => platformApi?.isEnabled() ? executeBackendMutation('PlatformAdminQuestionBanksController_upsertGrant', { ...grant, status: next }, apply) : apply(); if (next === '已禁用') confirmAction({ title: '禁用公共题库授权', description: `${bankById(grant.bankId)?.name || grant.bankId} · ${grant.id}`, impact: '后续同步将停止,但租户已经采用的内容快照不会被删除。', confirmLabel: '确认禁用' }, execute); else execute(); return; } - if (action === 'preview-bank-sync') { state.bankTab = 'sync'; if (platformApi?.isEnabled()) { runContractRead({ key: 'bank-sync', actionId: 'act.p-09.05', operationId: 'PlatformAdminQuestionBanksController_syncStatus', title: '采用同步状态', suppliedRequestId: actionElement.dataset.contractRequestId, liveRequest: () => platformApi.request('PlatformAdminQuestionBanksController_syncStatus', { query: { limit: 200 } }), apply: payload => { const items = apiItems(payload); if (items.length) data.bankSync = items.map(platformApi.adapters.sync); return `${data.bankSync.length} 条采用记录,${data.bankSync.filter(item => item.status === '同步失败').length} 条失败`; } }); return; } const task = addContractTask({ actionId: 'act.p-09.05', operationId: 'PlatformAdminQuestionBanksController_syncStatus', label: '检查租户题库采用与同步状态', resultTarget: '公共题库 · 采用与同步', suppliedRequestId: actionElement.dataset.contractRequestId, execute: taskItem => { taskItem.rows = data.bankSync.length; taskItem.failedRows = data.bankSync.filter(item => item.status === '同步失败').length; } }); render(); showToast(`同步状态检查任务 ${task.id} 已创建`); return; } - if (action === 'refresh-bank-sync') { if (state.modal === 'syncDetail') closeModal({ force: true }); runContractRead({ key: 'bank-sync', operationId: 'PlatformAdminQuestionBanksController_syncStatus', title: '采用同步状态', liveRequest: () => platformApi.request('PlatformAdminQuestionBanksController_syncStatus', { query: { limit: 200 } }), apply: payload => { const items = apiItems(payload); if (items.length) data.bankSync = items.map(platformApi.adapters.sync); return `${data.bankSync.length} 条采用记录,${data.bankSync.filter(item => item.status === '同步失败').length} 条失败`; } }); return; } - if (action === 'sync-detail') { openModal('syncDetail', { syncKey: actionElement.dataset.syncKey }); return; } + if (action === 'toggle-dunning-channel') { if (requireDesktop('变更催缴渠道状态')) return; if (!canWrite('platform:billing:notification')) return; const channel = data.dunningChannels.find(item => item.id === actionElement.dataset.channelId); if (!channel) return; const nextEnabled = !channel.enabled; const apply = () => { channel.enabled = nextEnabled; addAudit('dunning.channel.status', 'notification_channel', channel.id, channel.name, '高', `催缴渠道${channel.enabled ? '启用' : '停用'}。`); saveData(); render(); showToast('渠道状态已更新'); }; if (!platformApi?.isEnabled()) { apply(); return; } if (!nextEnabled) { executeBackendMutation('PlatformAdminDunningChannelsController_disableChannel', { channelId: channel.id, reason: '平台管理员停用催缴渠道' }, apply); return; } if (channel.endpointRedacted) { openModal('dunningChannel', { channelId: channel.id }); showToast('重新启用前请填写完整的 HTTPS 通知地址', 'error'); return; } executeBackendMutation('PlatformAdminDunningChannelsController_upsertChannel', { ...channel, enabled: true }, apply); return; } + if (action === 'new-bank') { if (requireDesktop('新建公共题库')) return; openModal('bank'); return; } + if (action === 'edit-bank') { if (requireDesktop('编辑公共题库')) return; openModal('bank', { bankId: state.selectedBank }); return; } + if (action === 'archive-bank') { const bank = bankById(state.selectedBank); if (!bank) return; confirmAction({ title: '归档公共题库', description: bank.name, impact: '仅当题库中的题目和内容节点均已归档时才能归档,历史版本不会删除。', confirmLabel: '确认归档' }, () => executeBackendMutation('PlatformQuestionBanksController_archiveBank', {}, payload => { Object.assign(bank, payload ? platformApi.adapters.bank(payload, bank) : { status: '已归档' }); render(); showToast('公共题库已归档'); }, { refresh: false, pathParams: { bankId: bank.id } })); return; } + if (action === 'new-bank-node') { if (requireDesktop('新建内容节点')) return; openModal('bankNode'); return; } + if (action === 'edit-bank-node') { if (requireDesktop('编辑内容节点')) return; openModal('bankNode', { nodeId: state.selectedBankNode }); return; } + if (action === 'batch-bank-nodes') { if (requireDesktop('批量创建章节或试卷')) return; openModal('batchBankNodes'); return; } + if (action === 'shift-bank-node') { const node = bankNodeById(state.selectedBankNode); if (!node) return; const sortOrder = Number(node.sortOrder || 0) + Number(actionElement.dataset.sortDelta || 0); executeBackendMutation('PlatformQuestionBanksController_upsertNode', { id: node.id, questionBankId: node.questionBankId, parentId: node.parentId || null, nodeKey: node.nodeKey || null, name: node.name, nodeType: node.nodeType, sortOrder, isSelectable: node.isSelectable !== false, metadata: node.metadata || {} }, payload => { Object.assign(node, payload ? adaptBankNode(payload) : { sortOrder }); data.bankNodes.sort((left, right) => Number(left.sortOrder || 0) - Number(right.sortOrder || 0)); render(); showToast('内容节点顺序已保存'); }, { refresh: false }); return; } + if (action === 'apply-bank-search') { runContractRead({ key: 'bank-query', actionId: 'act.p-09.01', operationId: 'PlatformQuestionBanksController_getBanks', title: '公共题库查询', suppliedRequestId: actionElement.dataset.contractRequestId, liveRequest: () => platformApi.request('PlatformQuestionBanksController_getBanks', { query: { keyword: state.bankSearch || undefined, status: 'all' } }), apply: payload => { const items = apiItems(payload); data.banks = items.map(item => platformApi.adapters.bank(item, bankById(item.id))); if (!bankById(state.selectedBank)) state.selectedBank = data.banks[0]?.id || ''; return `${data.banks.length} 个匹配题库`; } }); return; } + if (action === 'load-bank-questions' || action === 'bank-question-page') { if (action === 'bank-question-page') state.bankQuestionPage = Number(actionElement.dataset.page || 1); const query = { questionBankId: state.selectedBank, contentNodeId: state.selectedBankNode || undefined, keyword: document.querySelector('#bankQuestionSearch')?.value || undefined, type: document.querySelector('#bankQuestionType')?.value || undefined, status: document.querySelector('#bankQuestionStatus')?.value || undefined, page: state.bankQuestionPage, pageSize: 20 }; if (!platformApi?.isEnabled()) { render(); return; } platformApi.request('PlatformQuestionBanksController_getQuestions', { query }).then(payload => { data.bankQuestions = apiItems(payload).map(adaptBankQuestion); state.bankQuestionTotal = Number(payload.total || data.bankQuestions.length); render(); }).catch(error => showToast(backendErrorText(error, '题目查询失败'), 'error')); return; } + if (action === 'edit-bank-question') { const question = data.bankQuestions.find(item => item.id === actionElement.dataset.questionId); if (!question) return; state.bankTab = 'single'; state.selectedBankNode = question.contentNodeId; render(); requestAnimationFrame(() => { const form = document.querySelector('#bankQuestionForm'); if (!form) return; form.elements.id.value = question.id; form.elements.contentNodeId.value = question.contentNodeId; form.elements.type.value = question.type; form.elements.difficulty.value = question.difficulty || 3; form.elements.legacyId.value = question.legacyId || ''; form.elements.content.value = question.content || ''; form.elements.options.value = JSON.stringify(question.options || [], null, 2); form.elements.answerText.value = question.answerText || ''; form.elements.explanation.value = question.explanation || ''; form.elements.mediaUrl.value = question.mediaUrl || ''; form.elements.tags.value = Array.isArray(question.tags) ? question.tags.join('、') : ''; }); return; } + if (action === 'archive-bank-question') { const questionId = actionElement.dataset.questionId; const apply = () => { const question = data.bankQuestions.find(item => item.id === questionId); if (question) { question.status = 'archived'; question.statusLabel = '已归档'; } render(); showToast('题目已归档'); }; confirmAction({ title: '归档题目', description: '归档后题目历史版本仍会保留。', impact: '该题目不会再进入公共题库正常查询结果。', confirmLabel: '确认归档' }, () => executeBackendMutation('PlatformQuestionBanksController_archiveQuestions', { questionIds: [questionId] }, apply, { refresh: false })); return; } + if (action === 'archive-bank-node') { const node = bankNodeById(state.selectedBankNode); if (!node) return; confirmAction({ title: '归档内容节点', description: node.name, impact: '仅当节点没有启用的下级节点和未归档题目时才能归档。', confirmLabel: '确认归档' }, () => executeBackendMutation('PlatformQuestionBanksController_archiveNode', {}, () => { node.isActive = false; render(); showToast('内容节点已归档'); }, { refresh: false, pathParams: { nodeId: node.id } })); return; } + if (action === 'upload-question-image') { const input = document.querySelector('#bankQuestionImage'); const file = input?.files?.[0]; if (!file) { showToast('请先选择题目图片', 'error'); return; } if (!platformApi?.isEnabled()) { document.querySelector('#bankQuestionForm').elements.mediaUrl.value = `https://static.example.test/questions/${encodeURIComponent(file.name)}`; showToast('图片地址已填入'); return; } actionElement.disabled = true; platformApi.request('PlatformQuestionBanksController_signAssetUpload', { body: { contentNodeId: state.selectedBankNode || null, fileName: file.name, mimeType: file.type || 'application/octet-stream', fileSizeBytes: file.size, title: file.name, metadata: { usage: 'question' } } }).then(async signed => { await fetch(signed.upload.url, { method: signed.upload.method || 'PUT', headers: signed.upload.headers || {}, body: file }); const confirmed = await platformApi.request('PlatformQuestionBanksController_confirmAssetUpload', { body: { assetId: signed.item.id, mimeType: file.type, fileSizeBytes: file.size } }); document.querySelector('#bankQuestionForm').elements.mediaUrl.value = confirmed.item.cdnUrl || String(signed.upload.url).split('?')[0]; markDirty('page'); showToast('题目图片已上传并填入地址'); }).catch(error => showToast(backendErrorText(error, '题目图片上传失败'), 'error')).finally(() => { actionElement.disabled = false; }); return; } + if (action === 'preview-question') { const form = document.querySelector('#bankQuestionForm'); showToast(form?.elements.content.value?.trim() ? `题目预览:${form.elements.content.value.trim().slice(0, 80)}` : '请先填写题干', form?.elements.content.value?.trim() ? 'success' : 'error'); return; } if (action === 'new-crm-config' || action === 'edit-crm-config') { if (!canWrite('platform:crm:write')) return; openModal('crmConfig', { crmId: actionElement.dataset.crmId || '' }); return; } if (action === 'crm-rule-detail') { showToast('CRM 分配策略保存在接入配置的 assignmentConfig 中'); return; } if (action === 'toggle-crm-config') { if (!canWrite('platform:crm:write')) return; const config = data.crmConfigs.find(item => item.id === actionElement.dataset.crmId); if (!config) return; const nextEnabled = !config.enabled; const apply = payload => { const next = payload ? platformApi.adapters.crmConfig(payload) : { ...config, enabled: nextEnabled, status: nextEnabled ? '正常' : '未启用' }; Object.assign(config, next); addAudit('crm.config.status', 'crm_config', config.id, tenantById(config.tenantId)?.name || config.id, '中', `CRM 接入${config.enabled ? '启用' : '停用'}。`); saveData(); render(); showToast('CRM 接入状态已更新'); }; if (platformApi?.isEnabled()) executeBackendMutation('PlatformAdminCrmController_upsertConfig', { ...config, enabled: nextEnabled }, apply); else apply(); return; } if (action === 'retry-crm-lead') { if (!canWrite('platform:crm:write')) return; const lead = data.crmLeads.find(item => item.id === actionElement.dataset.leadId); if (!lead) return; const apply = payload => { const next = payload ? platformApi.adapters.crmLead(payload) : { ...lead, status: '重试中', attempts: lead.attempts + 1, lastError: '' }; Object.assign(lead, next); addAudit('crm.lead.retry', 'crm_lead', lead.id, lead.student, '中', `CRM 线索 ${lead.id} 已重试推送。`); saveData(); render(); showToast('线索已进入重试队列'); }; if (platformApi?.isEnabled()) executeBackendMutation('PlatformAdminCrmController_retryLead', lead, apply); else apply(); return; } if (action === 'new-sms-channel' || action === 'edit-sms-channel') { if (!canWrite('platform:sms:write')) return; openModal('smsChannel', { smsId: actionElement.dataset.smsId || '' }); return; } - if (action === 'toggle-sms-channel') { if (!canWrite('platform:sms:write')) return; const channel = data.smsChannels.find(item => item.id === actionElement.dataset.smsId); if (!channel) return; const nextStatus = channel.enabled ? '未启用' : '正常'; const apply = payload => { const next = payload ? platformApi.adapters.smsChannel(payload) : { ...channel, enabled: nextStatus === '正常', status: nextStatus }; Object.assign(channel, next); addAudit('sms.channel.status', 'sms_channel', channel.id, tenantById(channel.tenantId)?.name || channel.id, '中', `短信渠道${channel.enabled ? '启用' : '停用'}。`); saveData(); render(); showToast('短信渠道状态已更新'); }; if (platformApi?.isEnabled()) executeBackendMutation('PlatformAdminSmsController_upsertChannel', { ...channel, status: nextStatus }, apply); else apply(); return; } + if (action === 'toggle-sms-channel') { if (!canWrite('platform:sms:write')) return; const channel = data.smsChannels.find(item => item.id === actionElement.dataset.smsId); if (!channel) return; const nextStatus = channel.enabled ? '未启用' : '正常'; const apply = payload => { const next = payload ? platformApi.adapters.smsChannel(payload) : { ...channel, enabled: nextStatus === '正常', status: nextStatus }; Object.assign(channel, next); addAudit('sms.channel.status', 'sms_channel', channel.id, tenantById(channel.tenantId)?.name || channel.id, '中', `短信渠道${channel.enabled ? '启用' : '停用'}。`); saveData(); render(); showToast('短信渠道状态已更新'); }; if (!platformApi?.isEnabled()) { apply(); return; } if (channel.enabled) { executeBackendMutation('PlatformAdminSmsController_disableChannel', {}, apply, { refresh: false, pathParams: { channelId: channel.id } }); return; } executeBackendMutation('PlatformAdminSmsController_upsertChannel', { ...channel, status: nextStatus }, apply); return; } if (action === 'new-payment-app' || action === 'edit-payment-app') { if (!canWrite('platform:payment:write')) return; openModal('paymentApp', { paymentId: actionElement.dataset.paymentId || '' }); return; } if (action === 'payment-rule-detail') { showToast('支付风控规则用于证书提醒、回调重试和人工确认保护'); return; } if (action === 'toggle-payment-app') { if (!canWrite('platform:payment:write')) return; const paymentApp = data.paymentApps.find(item => item.id === actionElement.dataset.paymentId); if (!paymentApp) return; const disabling = paymentApp.status !== '未启用'; const nextStatus = disabling ? '未启用' : '正常'; const operationId = paymentApp.appScope === 'tenant' ? 'PlatformAdminTenantPaymentSettingsController_upsertApp' : 'PlatformAdminPaymentSettingsController_upsertApp'; const apply = payload => { const next = payload ? (paymentApp.appScope === 'tenant' ? platformApi.adapters.tenantPaymentApp(payload) : platformApi.adapters.platformPaymentApp(payload)) : { ...paymentApp, status: nextStatus }; Object.assign(paymentApp, next); addAudit('payment.app.status', 'payment_app', paymentApp.id, paymentApp.appName, '高', `支付应用${disabling ? '停用' : '启用'}。`); saveData(); render(); showToast('支付应用状态已更新'); }; if (platformApi?.isEnabled()) executeBackendMutation(operationId, { ...paymentApp, status: nextStatus }, apply); else apply(); return; } - if (action === 'new-staff') { if (requireDesktop('新建平台员工与授权')) return; openModal('staff', { roleKey: actionElement.dataset.roleKey || '' }); return; } + if (action === 'new-staff') { if (requireDesktop('新建平台员工与授权')) return; openModal('staff', { roleId: actionElement.dataset.roleId || '' }); return; } if (action === 'edit-staff') { if (requireDesktop('编辑平台员工与授权')) return; openModal('staff', { staffId: actionElement.dataset.staffId }); return; } - if (action === 'toggle-staff') { if (requireDesktop('变更平台员工状态')) return; if (!canWrite('platform:staff:status')) return; const person = data.staff.find(item => item.id === actionElement.dataset.staffId); if (!person) return; const disabling = person.status === '正常'; if (disabling && person.role === '超级管理员' && data.staff.filter(item => item.role === '超级管理员' && item.status === '正常').length === 1) { showToast('不能禁用最后一名超级管理员', 'error'); return; } const apply = () => { person.status = disabling ? '已停用' : '正常'; addAudit('staff.status.update', 'staff', person.id, person.name, '高', `${disabling ? '停用并撤销会话' : '启用'}平台员工。`); saveData(); render(); showToast(`员工已${disabling ? '停用并撤销会话' : '启用'}`); }; const execute = () => platformApi?.isEnabled() ? executeBackendMutation('PlatformAdminOverviewController_updateStaffStatus', { staffId: person.id, status: disabling ? 'disabled' : 'active', reason: disabling ? '平台控制台停用员工' : '平台控制台启用员工', revokeSessions: disabling }, apply) : apply(); if (disabling) confirmAction({ title: '停用平台员工', description: `${person.name} · ${person.role}`, impact: '该员工的全部平台会话将立即撤销,后续无法登录平台端;租户端权限不在本域名内处理。', confirmLabel: '停用并撤销会话', confirmActionName: 'execute-staff-disable' }, execute); else execute(); return; } - if (action === 'select-permission-group') { const form = overlayRoot.querySelector('#staffForm'); const role = fieldValue(form, 'role'); const recommended = role === '平台财务' ? ['platform:billing:read', 'platform:billing:write', 'platform:billing:payment', 'platform:billing:dunning', 'platform:billing:notification', 'platform:usage:read', 'platform:usage:write', 'platform:payment:read', 'platform:payment:write'] : role === '安全审计' ? ['platform:audit:read', 'platform:audit:export', 'platform:audit:alert', 'platform:audit:notification'] : role === '题库运营' ? ['platform:question_bank:read', 'platform:question_bank:grant', 'platform:question_bank:ops'] : role === '租户运营' ? ['platform:tenant:read', 'platform:tenant:write', 'platform:tenant:status', 'platform:tenant:billing_profile', 'platform:crm:read', 'platform:crm:write', 'platform:sms:read', 'platform:sms:write'] : Array.from(form.querySelectorAll('[name="permissions"]')).map(input => input.value); form.querySelectorAll('[name="permissions"]').forEach(input => { input.checked = recommended.includes(input.value); }); return; } - if (action === 'clear-staff-form') { const form = overlayRoot.querySelector('#staffForm'); if (!form) return; form.querySelectorAll('input:not([type="hidden"]):not([type="checkbox"])').forEach(input => { input.value = ''; }); form.querySelectorAll('input[type="checkbox"]').forEach(input => { input.checked = false; }); form.elements.role.value = '租户运营'; state.modalData.dirty = true; form.querySelector('[name="authUserId"]')?.focus(); return; } + if (action === 'toggle-staff') { if (requireDesktop('变更平台员工状态')) return; if (!canWrite('platform:staff:status')) return; const person = data.staff.find(item => item.id === actionElement.dataset.staffId); if (!person) return; const disabling = person.status === '正常'; const isSuperAdmin = person.roleCodes?.includes('platform_super_admin'); if (disabling && isSuperAdmin && data.staff.filter(item => item.roleCodes?.includes('platform_super_admin') && item.status === '正常').length === 1) { showToast('不能禁用最后一名超级管理员', 'error'); return; } const apply = () => { person.status = disabling ? '已停用' : '正常'; addAudit('staff.status.update', 'staff', person.id, person.name, '高', `${disabling ? '停用并撤销会话' : '启用'}平台员工。`); saveData(); render(); showToast(`员工已${disabling ? '停用并撤销会话' : '启用'}`); }; const execute = () => platformApi?.isEnabled() ? executeBackendMutation('PlatformAdminOverviewController_updateStaffStatus', { userId: person.userId || person.id, status: disabling ? 'disabled' : 'active', reason: disabling ? '平台控制台停用员工' : '平台控制台启用员工' }, apply) : apply(); if (disabling) confirmAction({ title: '停用平台员工', description: `${person.name} · ${person.role}`, impact: '该员工的全部平台会话将立即撤销,后续无法登录平台端;租户端权限不在本域名内处理。', confirmLabel: '停用并撤销会话', confirmActionName: 'execute-staff-disable' }, execute); else execute(); return; } + if (action === 'select-permission-group') { const form = overlayRoot.querySelector('#staffForm'); const role = fieldValue(form, 'role'); const recommended = role === '平台财务' ? ['platform:billing:read', 'platform:billing:write', 'platform:billing:payment', 'platform:billing:dunning', 'platform:billing:notification', 'platform:usage:read', 'platform:usage:write', 'platform:payment:read', 'platform:payment:write'] : role === '安全审计' ? ['platform:audit:read', 'platform:audit:export', 'platform:audit:alert', 'platform:audit:notification'] : role === '题库运营' ? ['platform:dashboard:view', 'platform:question-bank:manage'] : role === '租户运营' ? ['platform:tenant:read', 'platform:tenant:write', 'platform:tenant:status', 'platform:tenant:billing_profile', 'platform:crm:read', 'platform:crm:write', 'platform:sms:read', 'platform:sms:write'] : Array.from(form.querySelectorAll('[name="permissions"]')).map(input => input.value); form.querySelectorAll('[name="permissions"]').forEach(input => { input.checked = recommended.includes(input.value); }); return; } + if (action === 'clear-staff-form') { const form = overlayRoot.querySelector('#staffForm'); if (!form) return; form.querySelectorAll('input:not([type="hidden"]):not([type="checkbox"])').forEach(input => { input.value = ''; }); form.querySelectorAll('input[type="checkbox"]').forEach(input => { input.checked = false; }); state.modalData.dirty = true; form.querySelector('[name="name"]')?.focus(); return; } if (action === 'apply-staff-search') { render(); return; } if (action === 'export-audit') { if (!hasPermission('platform:audit:export')) { showToast('缺少导出权限:platform:audit:export', 'error'); return; } const format = actionElement.dataset.format || 'csv'; if (platformApi?.isEnabled()) { platformApi.request('PlatformAdminAuditController_exportLogs', { query: { format, q: state.auditSearch || undefined, limit: 500 } }).then(payload => { const item = platformApi.downloadExport(payload); showToast(`审计文件已下载 · ${item.rowCount ?? item.rows ?? 0} 行 · ${item.sha256 || item.hash || 'hash 见响应'}`); }).catch(error => showToast(backendErrorText(error, '审计导出失败'), 'error')); return; } const task = addTask(`导出平台审计日志 ${format.toUpperCase()}`, '任务中心下载文件', taskItem => { taskItem.file = `platform-audit-${Date.now()}.${format}`; taskItem.hash = `sha256:${Math.random().toString(36).slice(2).padEnd(32, '0')}`; taskItem.rows = data.auditLogs.length; }); showToast(`导出任务 ${task.id} 已创建`); return; } if (action === 'jump-related-alert') { state.alertStatus = 'all'; state.selectedAlert = data.alerts.find(alert => data.auditLogs.some(log => log.targetId === alert.targetId || log.targetId === alert.id))?.id || ''; navigate('alerts'); if (state.selectedAlert) openModal('alertDetail', { alertId: state.selectedAlert }); return; } - if (action === 'jump-audit-target') { const log = data.auditLogs.find(item => item.id === actionElement.dataset.auditId); closeModal(); if (log.targetType === 'tenant') { state.selectedTenant = log.targetId; navigate('tenantDetail'); } else if (log.targetType === 'invoice') { state.selectedInvoice = log.targetId; navigate('billing'); } else if (log.targetType === 'question_bank' || log.targetType === 'grant') navigate('bank'); else navigate('alerts'); return; } + if (action === 'jump-audit-target') { const log = data.auditLogs.find(item => item.id === actionElement.dataset.auditId); closeModal(); if (log.targetType === 'tenant') { state.selectedTenant = log.targetId; navigate('tenantDetail'); } else if (log.targetType === 'invoice') { state.selectedInvoice = log.targetId; navigate('billing'); } else if (log.targetType === 'question_bank') navigate('bank'); else navigate('alerts'); return; } if (action === 'transition-alert') { if (!canWrite('platform:audit:alert')) return; const alert = data.alerts.find(item => item.id === actionElement.dataset.alertId); const next = actionElement.dataset.nextStatus; if (!alert) return; if (!['open', 'acknowledged'].includes(alert.status)) { showToast('已结束告警禁止重新打开或再次处理', 'error'); return; } if (alert.status === 'open' && !['acknowledged', 'resolved', 'ignored'].includes(next)) return; if (alert.status === 'acknowledged' && !['resolved', 'ignored'].includes(next)) return; const note = document.querySelector('#alertNote')?.value.trim() || ''; const apply = () => { alert.status = next; alert.note = note; alert.updatedAt = '刚刚'; addAudit('audit.alert.status.update', 'audit_alert', alert.id, alert.title, '高', `告警状态更新为 ${alertStatusLabel(next)}${note ? `,备注:${note}` : ''}。`); clearDirty('modal'); saveData(); closeModal({ force: true }); render(); showToast(`告警已${alertStatusLabel(next)}`); }; if (platformApi?.isEnabled()) executeBackendMutation('PlatformAdminAuditController_updateAlert', { alertId: alert.id, status: next, note }, apply); else apply(); return; } if (action === 'new-audit-channel') { if (requireDesktop('新建审计通知渠道')) return; openModal('auditChannel'); return; } if (action === 'edit-audit-channel') { if (requireDesktop('编辑审计通知渠道')) return; openModal('auditChannel', { channelId: actionElement.dataset.channelId }); return; } if (action === 'toggle-audit-channel') { if (requireDesktop('变更审计通知渠道状态')) return; if (!canWrite('platform:audit:notification')) return; const channel = data.auditChannels.find(item => item.id === actionElement.dataset.channelId); if (!channel) return; if (platformApi?.isEnabled() && channel.endpointRedacted) { openModal('auditChannel', { channelId: channel.id }); showToast('后端不回显完整 Webhook;请重新输入完整 HTTPS URL 后保存状态', 'error'); return; } const nextEnabled = !channel.enabled; const apply = () => { channel.enabled = nextEnabled; addAudit('audit.channel.status', 'notification_channel', channel.id, channel.name, '高', `审计告警渠道${channel.enabled ? '启用' : '停用'}。`); saveData(); render(); showToast('审计通知渠道状态已更新'); }; if (platformApi?.isEnabled()) executeBackendMutation('PlatformAdminAuditController_upsertChannel', { ...channel, enabled: nextEnabled }, apply); else apply(); return; } - if (action === 'retry-notification') { if (platformApi?.isEnabled()) { showToast('后端未提供通知事件重试接口,真实模式禁止伪造成功状态', 'error'); return; } const notification = [...(data.notificationEvents || []), ...(data.dunningEvents || [])].find(item => item.id === actionElement.dataset.eventId); if (!notification) return; notification.status = '重试中'; notification.attempts += 1; notification.updatedAt = `${todayTime()} CST`; saveData(); render(); setTimeout(() => { notification.status = '成功'; notification.error = ''; saveData(); render(); showToast('通知重试成功'); }, 800); return; } + if (action === 'retry-notification') { const eventId = actionElement.dataset.eventId; const dunningEvent = (data.dunningEvents || []).find(item => item.id === eventId); const notification = dunningEvent || (data.notificationEvents || []).find(item => item.id === eventId); if (!notification) return; if (platformApi?.isEnabled()) { if (!dunningEvent) { showToast('审计通知暂无后端重试接口', 'error'); return; } platformApi.request('PlatformAdminDunningEventsController_retryEvent', { body: { eventId, reason: '平台管理员手工重试' } }).then(payload => { Object.assign(dunningEvent, platformApi.adapters.event(payload, 'dunning')); closeModal({ force: true }); render(); showToast('催缴通知事件已重新标记待发送'); }).catch(error => showToast(backendErrorText(error, '催缴通知重试失败'), 'error')); return; } notification.status = '重试中'; notification.attempts += 1; notification.updatedAt = `${todayTime()} CST`; saveData(); render(); setTimeout(() => { notification.status = '成功'; notification.error = ''; saveData(); render(); showToast('通知重试成功'); }, 800); return; } if (action === 'retry-task') { const task = data.tasks.find(item => item.id === actionElement.dataset.taskId); task.status = 'processing'; task.error = ''; task.updatedAt = todayTime(); saveData(); renderOverlay(); setTimeout(() => { task.status = 'completed'; task.updatedAt = todayTime(); saveData(); renderOverlay(); updateCounters(); showToast('任务重试完成'); }, 900); return; } }); @@ -1458,12 +1695,8 @@ document.addEventListener('change', event => { if (event.target.id === 'invoiceStatusFilter') { state.invoiceStatus = event.target.value; syncNavigationState(); } if (event.target.id === 'usagePeriodFilter') { state.usagePeriod = event.target.value; syncNavigationState(); render(); } if (event.target.id === 'auditRangeFilter') { state.auditRange = event.target.value; syncNavigationState(); render(); } - if (event.target.id === 'includeTenantBanks') { - state.bankIncludeTenant = event.target.checked; - syncNavigationState(); - render(); - if (platformApi?.isEnabled()) hydrateFromBackend({ silent: true }); - } + if (event.target.id === 'bankSelector') { state.selectedBank = event.target.value; state.selectedBankNode = ''; state.bankQuestionPage = 1; syncNavigationState(); render(); loadBankWorkspace(); } + if (event.target.id === 'bankStructuredFile') { const file = event.target.files?.[0]; if (!file) return; file.text().then(text => { const target = document.querySelector('#bankStructuredPayload'); if (target) { target.value = text; markDirty('page'); } }).catch(() => showToast('无法读取所选 JSON 文件', 'error')); } }); menuButton.addEventListener('click', toggleMobileMenu); document.querySelector('#sidebarBackdrop').addEventListener('click', closeMobileMenu); diff --git a/Tiku.Api/wwwroot/platform-admin/index.html b/Tiku.Api/wwwroot/platform-admin/index.html index fe50e8c..b639380 100644 --- a/Tiku.Api/wwwroot/platform-admin/index.html +++ b/Tiku.Api/wwwroot/platform-admin/index.html @@ -8,11 +8,11 @@ - - - - - + + + + + diff --git a/Tiku.Api/wwwroot/platform-admin/platform-api.js b/Tiku.Api/wwwroot/platform-admin/platform-api.js index ba66951..30fa44e 100644 --- a/Tiku.Api/wwwroot/platform-admin/platform-api.js +++ b/Tiku.Api/wwwroot/platform-admin/platform-api.js @@ -52,9 +52,10 @@ return String(token || '').trim(); } - function buildUrl(path, query = {}) { + function buildUrl(path, query = {}, pathParams = {}) { const baseUrl = normalizeBaseUrl(runtime.apiBaseUrl); - const url = new URL(`${baseUrl}${path.startsWith('/') ? path : `/${path}`}`, window.location.href); + const resolvedPath = Object.entries(pathParams).reduce((value, [key, replacement]) => value.replace(`{${key}}`, encodeURIComponent(String(replacement))), path); + const url = new URL(`${baseUrl}${resolvedPath.startsWith('/') ? resolvedPath : `/${resolvedPath}`}`, window.location.href); Object.entries(query).forEach(([key, value]) => { if (value === undefined || value === null || value === '') return; url.searchParams.set(key, Array.isArray(value) ? value.join(',') : String(value)); @@ -124,7 +125,7 @@ else options.signal.addEventListener('abort', () => controller.abort(), { once: true }); } try { - const response = await fetch(buildUrl(operation.path, options.query), { + const response = await fetch(buildUrl(operation.path, options.query, options.pathParams), { method: operation.method, headers: { ...extraHeaders, @@ -196,9 +197,6 @@ invoiceTypeFromApi: { subscription: '订阅服务费', usage: '用量超额费', usage_overage: '用量超额费', manual: '人工账单', adjustment: '人工调整' }, severityToApi: { 低: 'low', 中: 'medium', 高: 'high', 严重: 'critical' }, severityFromApi: { low: '低', medium: '中', high: '高', critical: '严重' }, - grantScopeToApi: { 全部活跃租户: 'all_active_tenants', 指定套餐: 'plans', 指定租户: 'tenants', '套餐 + 租户': 'mixed' }, - grantScopeFromApi: { all_active_tenants: '全部活跃租户', plans: '指定套餐', tenants: '指定租户', mixed: '套餐 + 租户' }, - grantStatusFromApi: { active: '启用', disabled: '已禁用', expired: '已过期' }, providerToApi: { Webhook: 'generic', SMTP: 'generic', 邮件: 'generic', 短信: 'generic', 钉钉: 'dingtalk', 飞书: 'feishu', 企微: 'wecom' }, providerFromApi: { generic: 'Webhook', dingtalk: '钉钉', feishu: '飞书', wecom: '企微' }, eventStatusFromApi: { pending: '待处理', processing: '发送中', sent: '成功', retrying: '重试中', failed: '失败', discarded: '已丢弃' }, @@ -211,6 +209,28 @@ paymentStatusToApi: { 正常: 'active', 未启用: 'disabled', 测试中: 'testing' }, }; + const platformPermissionLabels = { + 'platform:dashboard:view': '查看平台经营概览', + 'platform:tenant:manage': '管理平台租户', + 'platform:staff:manage': '管理平台员工', + 'platform:role:manage': '管理平台角色与权限', + 'platform:question-bank:manage': '运营平台公共题库', + 'platform:audit:view': '查看平台审计与告警', + 'platform:billing:notification': '管理账务催缴通知', + 'platform:saas-catalog:manage': '管理 SaaS 商品与版本', + 'platform:saas-billing:manage': '管理 SaaS 订单与账务', + 'platform:crm:read': '查看租户 CRM 配置', + 'platform:crm:write': '管理租户 CRM 配置', + 'platform:sms:read': '查看租户短信服务', + 'platform:sms:write': '管理租户短信服务', + 'platform:payment:read': '查看平台支付设置', + 'platform:payment:write': '管理平台支付设置', + }; + + const platformRoleLabels = { + platform_super_admin: { name: '超级管理员', description: '拥有全部平台管理权限' }, + }; + function mapValue(map, value, fallback = value) { return Object.prototype.hasOwnProperty.call(map, value) ? map[value] : fallback; } @@ -277,18 +297,39 @@ } const bodyBuilders = { + PlatformSaasController_upsertFeature(input) { + return { id: input.id || undefined, code: input.code, name: input.name, category: input.category, description: input.description || undefined, referencePriceCents: yuanToCents(input.referencePrice), currency: 'CNY', status: input.status || 'active', sortOrder: Number(input.sortOrder || 100) }; + }, + PlatformSaasController_upsertOffering(input) { + return { id: input.id || undefined, code: input.code, name: input.name, type: input.type || 'base_plan', status: input.status || 'active', description: input.description || undefined, sortOrder: Number(input.sortOrder || 100) }; + }, + PlatformSaasController_upsertFeatureLimit(input) { + return { id: input.id || undefined, metricCode: input.metricCode, featureCode: input.featureCode, name: input.name, unit: input.unit || 'count', kind: input.kind || 'current', warningPercent: Number(input.warningPercent || 80), isHardLimit: input.isHardLimit !== false }; + }, + PlatformSaasController_upsertVersion(input) { + return { id: input.id || undefined, offeringId: input.offeringId, billingCycle: input.billingCycle || 'yearly', originalAmountCents: yuanToCents(input.originalAmount), amountCents: yuanToCents(input.amount), currency: 'CNY', effectiveAt: input.effectiveAt ? new Date(input.effectiveAt).toISOString() : undefined, featureCodes: splitList(input.featureCodes), limits: {}, metadata: {} }; + }, + PlatformBackofficeController_upsertRole(input) { + return { id: input.id || undefined, code: input.code, name: input.name, status: input.status || 'active', description: input.description || undefined }; + }, + PlatformBackofficeController_replaceRoleBindings(input) { + return { permissionCodes: input.permissionCodes || [], menuCodes: input.menuCodes || [] }; + }, + PlatformSaasController_upsertTenantFeatureOverride(input) { + return { tenantId: input.tenantId, featureCode: input.featureCode, mode: input.mode, expiresAt: input.expiresAt ? new Date(input.expiresAt).toISOString() : undefined, reason: input.reason }; + }, PlatformAdminTenantsController_create(input) { return { slug: input.slug, name: input.name, legalName: input.legalName || undefined, - brandName: input.brandName || undefined, - shortName: input.shortName || undefined, - primaryHost: input.primaryHost || undefined, - planCode: input.planCode || undefined, status: mapValue(dictionaries.tenantStatusToApi, input.status, input.status), billingStatus: mapValue(dictionaries.billingStatusToApi, input.billingStatus, input.billingStatus), - amountCents: yuanToCents(input.amount), + metadata: input.metadata || {}, + ownerEmail: input.ownerEmail || undefined, + ownerPhone: input.ownerPhone || undefined, + ownerName: input.ownerName, + temporaryPassword: input.temporaryPassword, }; }, PlatformAdminTenantsController_status(input) { @@ -399,52 +440,28 @@ metadata: { source: 'platform_console_manual' }, }; }, - PlatformAdminQuestionBanksController_upsertGrant(input) { - return { - id: input.id || undefined, - sourceQuestionBankId: input.bankId || input.sourceQuestionBankId, - grantScope: mapValue(dictionaries.grantScopeToApi, input.scope, input.grantScope), - allowedPlanCodes: input.allowedPlanCodes || (input.planCode ? [input.planCode] : []), - allowedTenantIds: input.allowedTenantIds || (input.tenantId ? [input.tenantId] : []), - allowedRegionIds: input.allowedRegionIds || splitList(input.regionIds), - allowedSubjectIds: input.allowedSubjectIds || splitList(input.subjectIds), - status: input.status === '已禁用' || input.status === 'disabled' - ? 'disabled' - : input.status === '已过期' || input.status === 'expired' - ? 'expired' - : 'active', - startsAt: isoStartOfDay(input.startsAt), - expiresAt: isoStartOfDay(input.expiresAt), - metadata: { localEdit: Boolean(input.localEdit) }, - }; - }, PlatformAdminOverviewController_upsertStaff(input) { - const permissionList = input.permissions || []; return { - id: input.id || undefined, - authUserId: input.authUserId, - username: input.username, + userId: input.userId || undefined, email: input.email || undefined, phone: input.phone || undefined, name: input.name, status: input.status === '已停用' ? 'disabled' : 'active', - platformPermissions: Object.fromEntries(permissionList.map(permission => [permission, true])), - metadata: { displayRole: input.role || '平台管理员' }, + roleIds: input.roleIds || [], }; }, PlatformAdminOverviewController_updateStaffStatus(input) { return { - staffId: input.staffId, + userId: input.userId || input.staffId, status: input.status === '正常' || input.status === 'active' ? 'active' : 'disabled', reason: input.reason || undefined, - revokeSessions: input.revokeSessions !== false, }; }, PlatformAdminAuditController_updateAlert(input) { return { alertId: input.alertId, status: input.status, - note: input.note || undefined, + resolutionNote: input.resolutionNote || input.note || undefined, }; }, PlatformAdminCrmController_upsertConfig(input) { @@ -536,6 +553,74 @@ metadata: { source: 'platform_console' }, }; }, + PlatformQuestionBanksController_upsertBank(input) { + return { + id: input.id || undefined, + name: input.name, + metadata: input.metadata || {}, + }; + }, + PlatformQuestionBanksController_upsertNode(input) { + return { + id: input.id || undefined, + questionBankId: input.questionBankId, + parentId: input.parentId || undefined, + nodeKey: input.nodeKey || undefined, + name: input.name, + nodeType: input.nodeType, + sortOrder: Number(input.sortOrder || 0), + isSelectable: input.isSelectable !== false, + metadata: input.metadata || {}, + }; + }, + PlatformQuestionBanksController_batchCreateNodes(input) { + return { + questionBankId: input.questionBankId, + parentId: input.parentId || undefined, + nodeType: input.nodeType, + names: Array.isArray(input.names) ? input.names : splitList(input.names), + }; + }, + PlatformQuestionBanksController_upsertQuestion(input) { + return { + id: input.id || undefined, + questionBankId: input.questionBankId, + contentNodeId: input.contentNodeId, + legacyId: input.legacyId || undefined, + type: input.type, + typeLabel: input.typeLabel || undefined, + difficulty: input.difficulty == null ? undefined : Number(input.difficulty), + tags: input.tags || [], + content: input.content, + options: input.options || [], + correctOptionIndex: input.correctOptionIndex ?? undefined, + correctOptionIndices: input.correctOptionIndices || [], + answerText: input.answerText || undefined, + explanation: input.explanation || undefined, + subQuestions: input.subQuestions || [], + codeLang: input.codeLang || undefined, + codeTemplate: input.codeTemplate || undefined, + mediaUrl: input.mediaUrl || undefined, + status: input.status || 'published', + examMarkers: input.examMarkers || {}, + sourceHash: input.sourceHash || undefined, + }; + }, + PlatformQuestionBanksController_archiveQuestions(input) { + return { questionIds: input.questionIds || [] }; + }, + PlatformQuestionBanksController_previewImport(input) { + return { + questionBankId: input.questionBankId, + contentNodeId: input.contentNodeId || undefined, + format: input.format, + sourceName: input.sourceName || undefined, + payload: input.payload, + }; + }, + PlatformQuestionBanksController_executeImport(input) { + return bodyBuilders.PlatformQuestionBanksController_previewImport(input); + }, }; function notificationBody(input) { @@ -699,6 +784,7 @@ return { id: item.invoiceNo || item.id, backendId: item.id, + orderId: item.orderId || '', tenantId: item.tenantId, type: mapValue(dictionaries.invoiceTypeFromApi, item.invoiceType, item.invoiceType || '-'), period: item.billingPeriodStart ? monthOnly(item.billingPeriodStart) : monthOnly(item.issuedAt || item.dueDate), @@ -710,6 +796,18 @@ note: item.note || '', }; }, + billingPayment(item) { + return { + id: item.id, + orderId: item.orderId, + tenantId: item.tenantId, + paymentNo: item.paymentNo || '', + status: lowerEnum(item.status), + amount: centsToYuan(item.amountCents), + providerTradeNo: item.providerTradeNo || '', + paidAt: item.paidAt || '', + }; + }, reminder(item) { return { id: item.id, @@ -728,52 +826,26 @@ ...current, id: item.id, name: item.name, - catalog: item.regionName || item.tenantName || '公共题库', - source: item.sourceScope === 'platform' ? '平台公共题库' : '租户自建', + contentEntryId: item.contentEntryId || '', + catalog: '平台公共题库', + source: '平台自建', + nodes: Number(item.nodeCount || 0), questions: Number(item.questionCount || 0), version: item.metadata?.version || item.metadata?.contentVersion || '-', - status: item.status === 'active' ? '稳定版' : item.status || '-', - }; - }, - grant(item) { - return { - id: item.id, - bankId: item.sourceQuestionBankId, - scope: mapValue(dictionaries.grantScopeFromApi, item.grantScope, item.grantScope || '-'), - allowedPlanCodes: item.allowedPlanCodes || [], - allowedTenantIds: item.allowedTenantIds || [], - allowedRegionIds: item.allowedRegionIds || [], - allowedSubjectIds: item.allowedSubjectIds || [], - status: mapValue(dictionaries.grantStatusFromApi, item.status, item.status || '-'), - startsAt: dateOnly(item.startsAt), - expiresAt: dateOnly(item.expiresAt), - localEdit: Boolean(item.metadata?.localEdit), - }; - }, - sync(item) { - const progress = item.syncStatus === 'synced' - ? 100 - : Number(item.metadata?.lastSync?.progress || item.lastSyncCounts?.progress || 0); - return { - tenantId: item.tenantId, - bankId: item.sourceQuestionBankId, - current: item.metadata?.lastSync?.sourceVersion || '-', - target: item.metadata?.lastSync?.targetVersion || '-', - progress, - conflicts: Number(item.conflictCount || 0), - status: ({ pending: '同步中', synced: '已完成', failed: '同步失败' })[item.syncStatus] || item.syncStatus || item.status || '-', + status: lowerEnum(item.status) === 'active' ? '正常' : lowerEnum(item.status) === 'archived' ? '已归档' : item.status || '-', }; }, staff(item) { - const permissions = item.roleCodes?.includes('platform_super_admin') ? ['*'] : []; + const roleCodes = item.roleCodes || []; + const permissions = roleCodes.includes('platform_super_admin') ? ['*'] : []; return { id: item.userId || item.id, - authUserId: item.userId || item.authUserId || '', - username: item.email || '', + userId: item.userId || item.id, name: item.name || item.email || '平台员工', email: item.email || '', phone: item.phoneMasked || item.phone || '-', - role: permissions.includes('*') ? '超级管理员' : item.rawProfile?.displayRole || '平台管理员', + roleCodes, + role: permissions.includes('*') ? '超级管理员' : roleCodes.join('、') || '未分配角色', permissions, status: item.status === 'active' ? '正常' : '已停用', lastActive: dateTime(item.updatedAt), @@ -1027,16 +1099,19 @@ } const bootstrapDescriptors = [ + ['backofficeBootstrap', 'PlatformBackofficeController_bootstrap', {}], ['overview', 'PlatformAdminOverviewController_overview', {}], + ['domains', 'PlatformAdminTenantsController_domains', { query: { limit: 200 } }], ['plans', 'PlatformAdminOverviewController_plans', {}], ['tenants', 'PlatformAdminTenantsController_list', { query: { limit: 200 } }], ['subscriptions', 'PlatformAdminBillingController_subscriptions', { query: { limit: 200 } }], + ['saasOrders', 'PlatformAdminBillingController_orders', { query: { limit: 200 } }], + ['saasRefunds', 'PlatformAdminBillingController_refunds', { query: { limit: 200 } }], ['invoices', 'PlatformAdminBillingController_invoices', { query: { limit: 200 } }], + ['billingPayments', 'PlatformAdminBillingController_payments', { query: { limit: 200 } }], ['usage', 'PlatformAdminBillingController_usage', { query: { limit: 200 } }], ['reminders', 'PlatformAdminBillingController_reminders', { query: { limit: 100 } }], - ['banks', 'PlatformAdminQuestionBanksController_banks', { query: { limit: 200 } }], - ['grants', 'PlatformAdminQuestionBanksController_grants', { query: { limit: 200 } }], - ['bankSync', 'PlatformAdminQuestionBanksController_syncStatus', { query: { limit: 200 } }], + ['banks', 'PlatformQuestionBanksController_getBanks', { query: { status: 'all' } }], ['staff', 'PlatformAdminOverviewController_staff', { query: { limit: 200 } }], ['dunningChannels', 'PlatformAdminDunningChannelsController_channels', { query: { limit: 100 } }], ['dunningEvents', 'PlatformAdminDunningEventsController_events', { query: { limit: 100 } }], @@ -1044,10 +1119,13 @@ ['alerts', 'PlatformAdminAuditController_alerts', { query: { limit: 100 } }], ['crmConfigs', 'PlatformAdminCrmController_configs', { query: { limit: 200 } }], ['crmLeads', 'PlatformAdminCrmController_leads', { query: { limit: 200 } }], + ['crmLogs', 'PlatformAdminCrmController_logs', { query: { limit: 200 } }], ['smsChannels', 'PlatformAdminSmsController_channels', { query: { limit: 200 } }], ['smsTemplates', 'PlatformAdminSmsController_templates', { query: { limit: 200 } }], ['smsLogs', 'PlatformAdminSmsController_logs', { query: { limit: 200 } }], ['platformPaymentApps', 'PlatformAdminPaymentSettingsController_apps', { query: { limit: 200 } }], + ['paymentChannels', 'PlatformAdminPaymentSettingsController_channels', { query: { limit: 200 } }], + ['rebateSummary', 'PlatformAdminPaymentSettingsController_rebateSummary', {}], ['tenantPaymentApps', 'PlatformAdminTenantPaymentSettingsController_apps', { query: { limit: 200 } }], ['platformPaymentEvents', 'PlatformAdminPaymentSettingsController_events', { query: { limit: 100 } }], ['tenantPaymentEvents', 'PlatformAdminTenantPaymentSettingsController_events', { query: { limit: 100 } }], @@ -1056,7 +1134,6 @@ async function bootstrap(options = {}) { const descriptors = bootstrapDescriptors.map(([key, operationId, requestOptions]) => { const nextOptions = { ...requestOptions, query: { ...(requestOptions.query || {}) } }; - if (key === 'banks') nextOptions.query.includeTenantBanks = Boolean(options.includeTenantBanks); return [key, operationId, nextOptions]; }); const results = await Promise.allSettled( @@ -1074,7 +1151,26 @@ function adaptBootstrap(payloads, currentData = {}) { const patch = {}; + const backoffice = payloads.backofficeBootstrap || {}; + const permissionItems = Array.isArray(backoffice.permissions) ? backoffice.permissions : []; + const roleItems = Array.isArray(backoffice.roles) ? backoffice.roles : []; + if (permissionItems.length) { + patch.platformPermissions = Object.fromEntries(permissionItems.map(item => [ + item.code, + platformPermissionLabels[item.code] || item.name || item.code, + ])); + } + if (roleItems.length) { + patch.platformRoles = roleItems + .filter(item => String(item.status || '').toLowerCase() === 'active') + .map(item => { + const localized = platformRoleLabels[item.code] || {}; + return { id: item.id, code: item.code, name: localized.name || item.name || item.code, description: localized.description || item.description || '', permissionCodes: item.permissionCodes || [], menuCodes: item.menuCodes || [] }; + }); + } if (payloads.overview) patch.apiOverview = itemOf(payloads.overview); + if (payloads.domains) patch.domains = itemsOf(payloads.domains); + if (payloads.plans) patch.saasCatalog = payloads.plans; if (payloads.permissions) patch.apiPermissions = itemOf(payloads.permissions); const planPayload = itemsOf(payloads.plans, 'versions').length ? catalogPlans(payloads.plans) : itemsOf(payloads.plans); if (planPayload.length) { @@ -1087,11 +1183,25 @@ patch.tenants = tenants.map(item => adapters.tenant(item, currentById(currentData.tenants, item.id), planMap)); } const staff = itemsOf(payloads.staff); - if (staff.length) patch.staff = staff.map(adapters.staff); + if (staff.length) { + const roles = patch.platformRoles || currentData.platformRoles || []; + patch.staff = staff.map(item => { + const adapted = adapters.staff(item); + const assignedRoles = roles.filter(role => adapted.roleCodes.includes(role.code)); + adapted.roleIds = assignedRoles.map(role => role.id); + adapted.role = assignedRoles.map(role => role.name).join('、') || adapted.role; + adapted.permissions = adapted.permissions.includes('*') ? adapted.permissions : [...new Set(assignedRoles.flatMap(role => role.permissionCodes))]; + return adapted; + }); + } const invoices = itemsOf(payloads.invoices); if (invoices.length) patch.invoices = invoices.map(adapters.invoice); + const billingPayments = itemsOf(payloads.billingPayments); + if (billingPayments.length) patch.billingPayments = billingPayments.map(adapters.billingPayment); const subscriptions = itemsOf(payloads.subscriptions); if (subscriptions.length) patch.subscriptions = subscriptions.map(adapters.subscription); + if (payloads.saasOrders) patch.saasOrders = itemsOf(payloads.saasOrders); + if (payloads.saasRefunds) patch.saasRefunds = itemsOf(payloads.saasRefunds); const usage = itemsOf(payloads.usage); if (usage.length) patch.usage = aggregateUsage(usage); if (Array.isArray(payloads.usageCandidates?.items) && patch.usage) { @@ -1113,10 +1223,6 @@ if (banks.length) { patch.banks = banks.map(item => adapters.bank(item, currentById(currentData.banks, item.id))); } - const grants = itemsOf(payloads.grants); - if (grants.length) patch.grants = grants.map(adapters.grant); - const bankSync = itemsOf(payloads.bankSync); - if (bankSync.length) patch.bankSync = bankSync.map(adapters.sync); const auditLogs = itemsOf(payloads.auditLogs); if (auditLogs.length) patch.auditLogs = auditLogs.map(adapters.audit); const alertRules = itemsOf(payloads.alertRules); @@ -1127,6 +1233,7 @@ if (crmConfigs.length) patch.crmConfigs = crmConfigs.map(adapters.crmConfig); const crmLeads = itemsOf(payloads.crmLeads); if (crmLeads.length) patch.crmLeads = crmLeads.map(adapters.crmLead); + if (payloads.crmLogs) patch.crmLogs = itemsOf(payloads.crmLogs); const smsChannels = itemsOf(payloads.smsChannels); if (smsChannels.length) patch.smsChannels = smsChannels.map(adapters.smsChannel); const smsTemplates = itemsOf(payloads.smsTemplates); @@ -1136,6 +1243,8 @@ const platformPaymentApps = itemsOf(payloads.platformPaymentApps).map(adapters.platformPaymentApp); const tenantPaymentApps = itemsOf(payloads.tenantPaymentApps).map(adapters.tenantPaymentApp); if (platformPaymentApps.length || tenantPaymentApps.length) patch.paymentApps = [...platformPaymentApps, ...tenantPaymentApps]; + if (payloads.paymentChannels) patch.paymentChannels = itemsOf(payloads.paymentChannels); + if (payloads.rebateSummary) patch.rebateSummary = itemOf(payloads.rebateSummary); const paymentEvents = [ ...itemsOf(payloads.platformPaymentEvents).map(adapters.platformPaymentEvent), ...itemsOf(payloads.tenantPaymentEvents).map(adapters.platformPaymentEvent), diff --git a/Tiku.Api/wwwroot/platform-admin/spec-contract.js b/Tiku.Api/wwwroot/platform-admin/spec-contract.js index 5bea722..b0500d3 100644 --- a/Tiku.Api/wwwroot/platform-admin/spec-contract.js +++ b/Tiku.Api/wwwroot/platform-admin/spec-contract.js @@ -40,11 +40,11 @@ ['P-08', 'act.p-08.04', '查询提醒', 'command', 'native', 'read', '[data-billing-tab="reminders"]', 'PlatformAdminBillingController_reminders', 'allow'], ['P-08', 'act.p-08.05', '新增/编辑/启停渠道', 'command', 'native', 'mutation', '[data-action="new-dunning-channel"],[data-action="edit-dunning-channel"],[data-action="toggle-dunning-channel"]', 'PlatformAdminDunningChannelsController_upsertChannel', 'block'], ['P-08', 'act.p-08.06', '查看通知事件', 'command', 'contract', 'read', '[data-notification-detail]', 'PlatformAdminDunningEventsController_events', 'allow'], - ['P-09', 'act.p-09.01', '搜索', 'command', 'contract', 'read', '[data-action="apply-bank-search"]', 'PlatformAdminQuestionBanksController_banks', 'allow'], - ['P-09', 'act.p-09.02', '含租户库切换', 'command', 'native', 'read', '#includeTenantBanks', 'PlatformAdminQuestionBanksController_banks', 'allow'], - ['P-09', 'act.p-09.03', '新建/编辑/禁用授权', 'destructive', 'native', 'mutation', '[data-action="new-grant"],[data-action="edit-grant"],[data-action="toggle-grant"]', 'PlatformAdminQuestionBanksController_upsertGrant', 'block'], - ['P-09', 'act.p-09.04', '选择题库', 'command', 'native', 'read', '[data-bank-select]', 'PlatformAdminQuestionBanksController_banks', 'allow'], - ['P-09', 'act.p-09.05', '查看租户采用/同步', 'async', 'contract', 'task', '[data-action="preview-bank-sync"]', 'PlatformAdminQuestionBanksController_syncStatus', 'allow'], + ['P-09', 'act.p-09.01', '查询与选择题库', 'command', 'native', 'read', '[data-action="apply-bank-search"],[data-bank-select]', 'PlatformQuestionBanksController_getBanks', 'allow'], + ['P-09', 'act.p-09.02', '维护题库与内容结构', 'command', 'native', 'mutation', '[data-action="new-bank"],[data-action="edit-bank"],[data-action="new-bank-node"],[data-action="batch-bank-nodes"]', 'PlatformQuestionBanksController_upsertNode', 'block'], + ['P-09', 'act.p-09.03', '单题录入与题目管理', 'command', 'native', 'mutation', '#bankQuestionForm,[data-action="edit-bank-question"],[data-action="archive-bank-question"]', 'PlatformQuestionBanksController_upsertQuestion', 'block'], + ['P-09', 'act.p-09.04', '普通题目批量导入', 'async', 'native', 'task', '#bankBatchImportForm', 'PlatformQuestionBanksController_executeImport', 'block'], + ['P-09', 'act.p-09.05', '结构化题库文件导入', 'async', 'native', 'task', '#bankStructuredImportForm', 'PlatformQuestionBanksController_executeImport', 'block'], ['P-10', 'act.p-10.01', '切换 CRM 视图', 'command', 'native', 'read', '[data-crm-tab]', 'PlatformAdminCrmController_configs', 'allow'], ['P-10', 'act.p-10.02', '新建/编辑 CRM 接入', 'command', 'native', 'mutation', '[data-action="new-crm-config"],[data-action="edit-crm-config"]', 'PlatformAdminCrmController_upsertConfig', 'block'], ['P-10', 'act.p-10.03', '启停 CRM 接入', 'destructive', 'native', 'mutation', '[data-action="toggle-crm-config"]', 'PlatformAdminCrmController_upsertConfig', 'block'], @@ -73,17 +73,30 @@ ].map(([pageId, id, label, kind, mode, effect, selector, operationId, mobile]) => ({ pageId, id, label, kind, mode, effect, selector, operationIds: [operationId], mobile, destructive: kind === 'destructive', implemented: true })); const operationRows = [ + ['P-13', 'PlatformBackofficeController_bootstrap', 'GET', '/api/backoffice/platform/bootstrap'], ['P-01', 'PlatformAdminOverviewController_overview', 'GET', '/api/platform-admin/overview'], ['P-01', 'PlatformAdminAuditController_alerts', 'GET', '/api/platform-admin/audit-alerts'], ['P-01', 'PlatformAdminAuditController_logs', 'GET', '/api/platform-admin/audit-logs'], ['P-02', 'PlatformAdminTenantsController_list', 'GET', '/api/platform-admin/tenants'], ['P-02', 'PlatformAdminTenantsController_create', 'POST', '/api/platform-admin/tenants'], + ['P-02A', 'PlatformAdminTenantsController_domains', 'GET', '/api/platform-admin/domains'], + ['P-02A', 'PlatformAdminTenantsController_recheckDomain', 'POST', '/api/platform-admin/domains/{domainId}/recheck'], ['P-03', 'PlatformAdminTenantsController_detail', 'GET', '/api/platform-admin/tenants/detail'], ['P-03', 'PlatformAdminTenantsController_status', 'PATCH', '/api/platform-admin/tenants/status'], ['P-03', 'PlatformAdminTenantsController_billingProfile', 'PUT', '/api/platform-admin/tenants/billing-profile'], ['P-03', 'PlatformAdminBillingController_createSubscription', 'POST', '/api/platform-admin/saas/subscriptions'], ['P-04', 'PlatformAdminTenantsController_billingProfile', 'PUT', '/api/platform-admin/tenants/billing-profile'], ['P-05', 'PlatformAdminOverviewController_plans', 'GET', '/api/platform-admin/saas/catalog'], + ['P-05A', 'PlatformSaasController_upsertFeature', 'PUT', '/api/platform-admin/saas/features'], + ['P-05A', 'PlatformSaasController_upsertFeatureLimit', 'PUT', '/api/platform-admin/saas/feature-limits'], + ['P-05A', 'PlatformSaasController_upsertOffering', 'PUT', '/api/platform-admin/saas/offerings'], + ['P-05A', 'PlatformSaasController_upsertVersion', 'PUT', '/api/platform-admin/saas/offering-versions'], + ['P-05A', 'PlatformSaasController_publishVersion', 'POST', '/api/platform-admin/saas/offering-versions/{versionId}/publish'], + ['P-05A', 'PlatformSaasController_cloneVersion', 'POST', '/api/platform-admin/saas/offering-versions/{versionId}/clone'], + ['P-05A', 'PlatformSaasController_retireVersion', 'POST', '/api/platform-admin/saas/offering-versions/{versionId}/retire'], + ['P-05B', 'PlatformAdminBillingController_orders', 'GET', '/api/platform-admin/saas/orders'], + ['P-05B', 'PlatformAdminBillingController_refunds', 'GET', '/api/platform-admin/saas/refunds'], + ['P-05A', 'PlatformSaasController_upsertTenantFeatureOverride', 'PUT', '/api/platform-admin/saas/tenant-feature-overrides'], ['P-05', 'PlatformAdminBillingController_subscriptions', 'GET', '/api/platform-admin/saas/subscriptions'], ['P-05', 'PlatformAdminBillingController_createSubscription', 'POST', '/api/platform-admin/saas/subscriptions'], ['P-06', 'PlatformAdminBillingController_invoices', 'GET', '/api/platform-admin/saas/invoices'], @@ -96,31 +109,51 @@ ['P-07', 'PlatformAdminBillingController_usageCandidates', 'GET', '/api/platform-admin/saas/invoices/usage-overage-candidates'], ['P-07', 'PlatformAdminBillingController_fromUsage', 'POST', '/api/platform-admin/saas/invoices/from-usage-overage'], ['P-08', 'PlatformAdminBillingController_confirmPayment', 'POST', '/api/platform-admin/saas/payments/manual/confirm'], + ['P-08', 'PlatformAdminBillingController_payments', 'GET', '/api/platform-admin/saas/payments'], ['P-08', 'PlatformAdminBillingController_processOverdue', 'POST', '/api/platform-admin/saas/invoices/process-overdue'], ['P-08', 'PlatformAdminBillingController_reminders', 'GET', '/api/platform-admin/saas/invoices/reminders'], ['P-08', 'PlatformAdminDunningChannelsController_channels', 'GET', '/api/platform-admin/saas/dunning/channels'], ['P-08', 'PlatformAdminDunningChannelsController_upsertChannel', 'PUT', '/api/platform-admin/saas/dunning/channels'], + ['P-08', 'PlatformAdminDunningChannelsController_disableChannel', 'POST', '/api/platform-admin/saas/dunning/channels/disable'], ['P-08', 'PlatformAdminDunningEventsController_events', 'GET', '/api/platform-admin/saas/dunning/events'], - ['P-09', 'PlatformAdminQuestionBanksController_banks', 'GET', '/api/platform-admin/question-banks'], - ['P-09', 'PlatformAdminQuestionBanksController_grants', 'GET', '/api/platform-admin/question-bank-grants'], - ['P-09', 'PlatformAdminQuestionBanksController_upsertGrant', 'PUT', '/api/platform-admin/question-bank-grants'], - ['P-09', 'PlatformAdminQuestionBanksController_syncStatus', 'GET', '/api/platform-admin/question-bank-sync-status'], + ['P-08', 'PlatformAdminDunningEventsController_eventDetail', 'GET', '/api/platform-admin/saas/dunning/events/detail'], + ['P-08', 'PlatformAdminDunningEventsController_retryEvent', 'POST', '/api/platform-admin/saas/dunning/events/retry'], + ['P-09', 'PlatformQuestionBanksController_getBanks', 'GET', '/api/platform-admin/question-banks'], + ['P-09', 'PlatformQuestionBanksController_upsertBank', 'PUT', '/api/platform-admin/question-banks'], + ['P-09', 'PlatformQuestionBanksController_archiveBank', 'POST', '/api/platform-admin/question-banks/{bankId}/archive'], + ['P-09', 'PlatformQuestionBanksController_getNodes', 'GET', '/api/platform-admin/question-banks/{bankId}/nodes'], + ['P-09', 'PlatformQuestionBanksController_upsertNode', 'PUT', '/api/platform-admin/question-banks/nodes'], + ['P-09', 'PlatformQuestionBanksController_batchCreateNodes', 'POST', '/api/platform-admin/question-banks/nodes/batch'], + ['P-09', 'PlatformQuestionBanksController_archiveNode', 'POST', '/api/platform-admin/question-banks/nodes/{nodeId}/archive'], + ['P-09', 'PlatformQuestionBanksController_getQuestions', 'GET', '/api/platform-admin/question-banks/questions'], + ['P-09', 'PlatformQuestionBanksController_upsertQuestion', 'PUT', '/api/platform-admin/question-banks/questions'], + ['P-09', 'PlatformQuestionBanksController_archiveQuestions', 'POST', '/api/platform-admin/question-banks/questions/archive'], + ['P-09', 'PlatformQuestionBanksController_previewImport', 'POST', '/api/platform-admin/question-banks/imports/preview'], + ['P-09', 'PlatformQuestionBanksController_executeImport', 'POST', '/api/platform-admin/question-banks/imports'], + ['P-09', 'PlatformQuestionBanksController_getImport', 'GET', '/api/platform-admin/question-banks/imports/{jobId}'], + ['P-09', 'PlatformQuestionBanksController_signAssetUpload', 'POST', '/api/platform-admin/question-banks/assets/upload-sign'], + ['P-09', 'PlatformQuestionBanksController_confirmAssetUpload', 'POST', '/api/platform-admin/question-banks/assets/upload-confirm'], ['P-10', 'PlatformAdminCrmController_configs', 'GET', '/api/platform-admin/tenant-capabilities/crm/configs'], ['P-10', 'PlatformAdminCrmController_upsertConfig', 'PUT', '/api/platform-admin/tenant-capabilities/crm/configs'], ['P-10', 'PlatformAdminCrmController_leads', 'GET', '/api/platform-admin/tenant-capabilities/crm/leads'], ['P-10', 'PlatformAdminCrmController_retryLead', 'POST', '/api/platform-admin/tenant-capabilities/crm/leads/retry'], ['P-10', 'PlatformAdminCrmController_logs', 'GET', '/api/platform-admin/tenant-capabilities/crm/logs'], ['P-11', 'PlatformAdminSmsController_channels', 'GET', '/api/platform-admin/tenant-capabilities/sms/channels'], + ['P-11', 'PlatformAdminSmsController_disableChannel', 'POST', '/api/platform-admin/tenant-capabilities/sms/channels/{channelId}/disable'], ['P-11', 'PlatformAdminSmsController_templates', 'GET', '/api/platform-admin/tenant-capabilities/sms/templates'], + ['P-11', 'PlatformAdminSmsController_submitTemplateReview', 'POST', '/api/platform-admin/tenant-capabilities/sms/templates/{templateId}/submit-review'], + ['P-11', 'PlatformAdminSmsController_disableTemplate', 'POST', '/api/platform-admin/tenant-capabilities/sms/templates/{templateId}/disable'], ['P-11', 'PlatformAdminSmsController_logs', 'GET', '/api/platform-admin/tenant-capabilities/sms/logs'], ['P-11', 'PlatformAdminSmsController_upsertChannel', 'PUT', '/api/platform-admin/tenant-capabilities/sms/channels'], ['P-11', 'PlatformAdminSmsController_upsertTemplate', 'PUT', '/api/platform-admin/tenant-capabilities/sms/templates'], ['P-12', 'PlatformAdminPaymentSettingsController_apps', 'GET', '/api/platform-admin/payment-settings/apps'], ['P-12', 'PlatformAdminPaymentSettingsController_channels', 'GET', '/api/platform-admin/payment-settings/channels'], + ['P-12', 'PlatformAdminPaymentSettingsController_upsertChannel', 'PUT', '/api/platform-admin/payment-settings/channels'], + ['P-12', 'PlatformAdminPaymentSettingsController_disableChannel', 'POST', '/api/platform-admin/payment-settings/channels/{channelId}/disable'], + ['P-12', 'PlatformAdminPaymentSettingsController_rebateSummary', 'GET', '/api/platform-admin/payment-settings/rebates/summary'], ['P-12', 'PlatformAdminPaymentSettingsController_events', 'GET', '/api/platform-admin/payment-settings/events'], ['P-12', 'PlatformAdminPaymentSettingsController_rebates', 'GET', '/api/platform-admin/payment-settings/rebates/summary'], ['P-12', 'PlatformAdminPaymentSettingsController_upsertApp', 'PUT', '/api/platform-admin/payment-settings/apps'], - ['P-12', 'PlatformAdminPaymentSettingsController_upsertChannel', 'PUT', '/api/platform-admin/payment-settings/channels'], ['P-12', 'PlatformAdminTenantPaymentSettingsController_apps', 'GET', '/api/platform-admin/tenant-capabilities/payments/apps'], ['P-12', 'PlatformAdminTenantPaymentSettingsController_events', 'GET', '/api/platform-admin/tenant-capabilities/payments/events'], ['P-12', 'PlatformAdminTenantPaymentSettingsController_upsertApp', 'PUT', '/api/platform-admin/tenant-capabilities/payments/apps'], @@ -128,6 +161,8 @@ ['P-13', 'PlatformAdminOverviewController_staff', 'GET', '/api/platform-admin/staff'], ['P-13', 'PlatformAdminOverviewController_upsertStaff', 'PUT', '/api/platform-admin/staff'], ['P-13', 'PlatformAdminOverviewController_updateStaffStatus', 'PATCH', '/api/platform-admin/staff/status'], + ['P-13A', 'PlatformBackofficeController_upsertRole', 'POST', '/api/backoffice/platform/roles'], + ['P-13A', 'PlatformBackofficeController_replaceRoleBindings', 'PUT', '/api/backoffice/platform/roles/{roleId}/bindings'], ['P-14', 'PlatformAdminAuditController_logs', 'GET', '/api/platform-admin/audit-logs'], ['P-14', 'PlatformAdminAuditController_exportLogs', 'GET', '/api/platform-admin/audit-logs/export'], ['P-15', 'PlatformAdminAuditController_rules', 'GET', '/api/platform-admin/audit-alert-rules'], @@ -157,9 +192,7 @@ ['nav.p-14.p-15', 'P-14', 'P-15', '[data-action="jump-related-alert"]'], ].map(([id, fromPageId, toPageId, selector]) => ({ id, fromPageId, toPageId, selector, implemented: true })); - const supportingReads = [ - { pageId: 'P-09', selector: '[data-action="sync-detail"],[data-action="refresh-bank-sync"]', operationId: 'PlatformAdminQuestionBanksController_syncStatus' }, - ]; + const supportingReads = []; const trace = []; const decoratedActionIds = new Set(); diff --git a/Tiku.Application/PlatformAdmin/PlatformQuestionBankModels.cs b/Tiku.Application/PlatformAdmin/PlatformQuestionBankModels.cs new file mode 100644 index 0000000..aeaa3dc --- /dev/null +++ b/Tiku.Application/PlatformAdmin/PlatformQuestionBankModels.cs @@ -0,0 +1,155 @@ +using System.Text.Json; +using Tiku.Application.Assets; +using Tiku.Domain.Content; +using Tiku.Domain.QuestionBanks; + +namespace Tiku.Application.PlatformAdmin; + +public sealed record PlatformQuestionBankFilter( + Guid? QuestionBankId = null, + Guid? ContentNodeId = null, + string? Keyword = null, + string? Type = null, + int? Difficulty = null, + string? Status = null, + int Page = 1, + int PageSize = 20); + +public sealed record PlatformQuestionBankItem( + Guid Id, + Guid? ContentEntryId, + string Name, + QuestionBankStatus Status, + int NodeCount, + int QuestionCount, + JsonElement Metadata, + DateTimeOffset CreatedAt, + DateTimeOffset? UpdatedAt); + +public sealed record UpsertPlatformQuestionBankCommand( + Guid? Id, + string Name, + JsonElement Metadata); + +public sealed record PlatformQuestionBankNodeItem( + Guid Id, + Guid QuestionBankId, + Guid EntryId, + Guid? ParentId, + string? NodeKey, + string Name, + ContentNodeType NodeType, + int Depth, + int SortOrder, + bool IsActive, + bool IsSelectable, + bool IsLeaf, + int QuestionCount, + JsonElement Metadata); + +public sealed record UpsertPlatformQuestionBankNodeCommand( + Guid? Id, + Guid QuestionBankId, + Guid? ParentId, + string? NodeKey, + string Name, + ContentNodeType NodeType, + int SortOrder, + bool IsSelectable, + JsonElement Metadata); + +public sealed record BatchCreatePlatformQuestionBankNodesCommand( + Guid QuestionBankId, + Guid? ParentId, + ContentNodeType NodeType, + IReadOnlyCollection Names); + +public sealed record PlatformQuestionItem( + Guid Id, + Guid? VersionId, + Guid QuestionBankId, + Guid EntryId, + Guid ContentNodeId, + string? LegacyId, + string Type, + string? TypeLabel, + int? Difficulty, + JsonElement Tags, + string? Content, + JsonElement Options, + int? CorrectOptionIndex, + JsonElement CorrectOptionIndices, + string? AnswerText, + string? Explanation, + JsonElement SubQuestions, + string? CodeLang, + string? CodeTemplate, + string? MediaUrl, + QuestionStatus Status, + int VersionNo, + DateTimeOffset CreatedAt, + DateTimeOffset? UpdatedAt); + +public sealed record PlatformQuestionPage( + IReadOnlyCollection Items, + int Total, + int Page, + int PageSize); + +public sealed record UpsertPlatformQuestionCommand( + Guid? Id, + Guid QuestionBankId, + Guid ContentNodeId, + string? LegacyId, + string? Type, + string? TypeLabel, + int? Difficulty, + JsonElement Tags, + string? Content, + JsonElement Options, + int? CorrectOptionIndex, + JsonElement CorrectOptionIndices, + string? AnswerText, + string? Explanation, + JsonElement SubQuestions, + string? CodeLang, + string? CodeTemplate, + string? MediaUrl, + string? Status, + JsonElement ExamMarkers, + string? SourceHash); + +public sealed record ArchivePlatformQuestionsCommand(IReadOnlyCollection QuestionIds); + +public sealed record PlatformQuestionImportCommand( + Guid QuestionBankId, + Guid? ContentNodeId, + string Format, + string? SourceName, + JsonElement Payload); + +public sealed record PlatformQuestionImportResult( + ContentImportJobDetail Detail, + int CreatedNodeCount, + int InsertedQuestionCount, + int UpdatedQuestionCount, + int SkippedQuestionCount); + +public interface IPlatformQuestionBankService +{ + Task> GetBanksAsync(PlatformAdminActor actor, PlatformQuestionBankFilter filter, CancellationToken cancellationToken = default); + Task UpsertBankAsync(PlatformAdminActor actor, UpsertPlatformQuestionBankCommand command, CancellationToken cancellationToken = default); + Task ArchiveBankAsync(PlatformAdminActor actor, Guid bankId, CancellationToken cancellationToken = default); + Task> GetNodesAsync(PlatformAdminActor actor, Guid bankId, CancellationToken cancellationToken = default); + Task UpsertNodeAsync(PlatformAdminActor actor, UpsertPlatformQuestionBankNodeCommand command, CancellationToken cancellationToken = default); + Task> BatchCreateNodesAsync(PlatformAdminActor actor, BatchCreatePlatformQuestionBankNodesCommand command, CancellationToken cancellationToken = default); + Task ArchiveNodeAsync(PlatformAdminActor actor, Guid nodeId, CancellationToken cancellationToken = default); + Task GetQuestionsAsync(PlatformAdminActor actor, PlatformQuestionBankFilter filter, CancellationToken cancellationToken = default); + Task UpsertQuestionAsync(PlatformAdminActor actor, UpsertPlatformQuestionCommand command, CancellationToken cancellationToken = default); + Task ArchiveQuestionsAsync(PlatformAdminActor actor, ArchivePlatformQuestionsCommand command, CancellationToken cancellationToken = default); + Task PreviewImportAsync(PlatformAdminActor actor, PlatformQuestionImportCommand command, CancellationToken cancellationToken = default); + Task ExecuteImportAsync(PlatformAdminActor actor, PlatformQuestionImportCommand command, CancellationToken cancellationToken = default); + Task GetImportAsync(PlatformAdminActor actor, Guid jobId, CancellationToken cancellationToken = default); + Task SignQuestionAssetUploadAsync(PlatformAdminActor actor, AssetUploadSignCommand command, CancellationToken cancellationToken = default); + Task ConfirmQuestionAssetUploadAsync(PlatformAdminActor actor, AssetUploadConfirmCommand command, CancellationToken cancellationToken = default); +} diff --git a/Tiku.Domain/QuestionBanks/QuestionEntities.cs b/Tiku.Domain/QuestionBanks/QuestionEntities.cs index 6fba92d..e0b3982 100644 --- a/Tiku.Domain/QuestionBanks/QuestionEntities.cs +++ b/Tiku.Domain/QuestionBanks/QuestionEntities.cs @@ -6,6 +6,7 @@ namespace Tiku.Domain.QuestionBanks; public sealed class QuestionBank : AuditableTenantEntity { public Guid? RegionId { get; set; } + public Guid? ContentEntryId { get; set; } public string Name { get; set; } = string.Empty; public QuestionBankStatus Status { get; set; } = QuestionBankStatus.Active; public JsonElement Metadata { get; set; } = JsonDefaults.Object(); diff --git a/Tiku.Infrastructure/Bootstrap/BuiltinBackofficeCatalogSeeder.cs b/Tiku.Infrastructure/Bootstrap/BuiltinBackofficeCatalogSeeder.cs index 93a9be2..815f0cb 100644 --- a/Tiku.Infrastructure/Bootstrap/BuiltinBackofficeCatalogSeeder.cs +++ b/Tiku.Infrastructure/Bootstrap/BuiltinBackofficeCatalogSeeder.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Tiku.Application.Security; +using Tiku.Domain.Common; using Tiku.Domain.Operations; using Tiku.Domain.Platform; using Tiku.Domain.Tenancy; @@ -124,6 +125,21 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext) public async Task SeedAsync(CancellationToken cancellationToken = default) { + var platformOwnedTenant = await dbContext.Tenants + .SingleOrDefaultAsync(item => item.Mode == TenantMode.PlatformOwned, cancellationToken); + if (platformOwnedTenant is null) + { + dbContext.Tenants.Add(new Tenant + { + Slug = "platform-public-content", + Name = "平台公共内容", + Status = TenantStatus.Active, + Mode = TenantMode.PlatformOwned, + BillingStatus = BillingStatus.Active, + Metadata = JsonDefaults.Object() + }); + } + var featureCodes = Features.Select(item => item.Code).ToArray(); var existingFeatureCodes = await dbContext.SaasFeatures.AsNoTracking() .Where(item => featureCodes.Contains(item.Code)) diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs index 41ac24a..5fc7c24 100644 --- a/Tiku.Infrastructure/DependencyInjection.cs +++ b/Tiku.Infrastructure/DependencyInjection.cs @@ -133,6 +133,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/Tiku.Infrastructure/Persistence/Configurations/QuestionConfigurations.cs b/Tiku.Infrastructure/Persistence/Configurations/QuestionConfigurations.cs index e0eb187..3f21b7b 100644 --- a/Tiku.Infrastructure/Persistence/Configurations/QuestionConfigurations.cs +++ b/Tiku.Infrastructure/Persistence/Configurations/QuestionConfigurations.cs @@ -21,6 +21,10 @@ internal sealed class QuestionBankConfiguration : IEntityTypeConfiguration new { entity.TenantId, entity.RegionId }) .HasPrincipalKey(entity => new { entity.TenantId, entity.Id }) .OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany() + .HasForeignKey(entity => new { entity.TenantId, entity.ContentEntryId }) + .HasPrincipalKey(entity => new { entity.TenantId, entity.Id }) + .OnDelete(DeleteBehavior.Restrict); } } diff --git a/Tiku.Infrastructure/Persistence/Migrations/20260730034205_AddPlatformQuestionBankWorkspace.Designer.cs b/Tiku.Infrastructure/Persistence/Migrations/20260730034205_AddPlatformQuestionBankWorkspace.Designer.cs new file mode 100644 index 0000000..aa7d74f --- /dev/null +++ b/Tiku.Infrastructure/Persistence/Migrations/20260730034205_AddPlatformQuestionBankWorkspace.Designer.cs @@ -0,0 +1,20087 @@ +// +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("20260730034205_AddPlatformQuestionBankWorkspace")] + partial class AddPlatformQuestionBankWorkspace + { + /// + 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.HasPostgresExtension(modelBuilder, "pg_trgm"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone") + .HasColumnName("consumed"); + + b.Property("ConsumerId") + .HasColumnType("uuid") + .HasColumnName("consumer_id"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone") + .HasColumnName("delivered"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_time"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint") + .HasColumnName("last_sequence_number"); + + b.Property("LockId") + .HasColumnType("uuid") + .HasColumnName("lock_id"); + + b.Property("MessageId") + .HasColumnType("uuid") + .HasColumnName("message_id"); + + b.Property("ReceiveCount") + .HasColumnType("integer") + .HasColumnName("receive_count"); + + b.Property("Received") + .HasColumnType("timestamp with time zone") + .HasColumnName("received"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_inbox_state"); + + b.HasAlternateKey("MessageId", "ConsumerId") + .HasName("ak_inbox_state_message_id_consumer_id"); + + b.HasIndex("Delivered") + .HasDatabaseName("ix_inbox_state_delivered"); + + b.ToTable("inbox_state", (string)null); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("sequence_number"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text") + .HasColumnName("body"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("content_type"); + + b.Property("ConversationId") + .HasColumnType("uuid") + .HasColumnName("conversation_id"); + + b.Property("CorrelationId") + .HasColumnType("uuid") + .HasColumnName("correlation_id"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("destination_address"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("enqueue_time"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_time"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("fault_address"); + + b.Property("Headers") + .HasColumnType("text") + .HasColumnName("headers"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid") + .HasColumnName("inbox_consumer_id"); + + b.Property("InboxMessageId") + .HasColumnType("uuid") + .HasColumnName("inbox_message_id"); + + b.Property("InitiatorId") + .HasColumnType("uuid") + .HasColumnName("initiator_id"); + + b.Property("MessageId") + .HasColumnType("uuid") + .HasColumnName("message_id"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("message_type"); + + b.Property("OutboxId") + .HasColumnType("uuid") + .HasColumnName("outbox_id"); + + b.Property("Properties") + .HasColumnType("text") + .HasColumnName("properties"); + + b.Property("RequestId") + .HasColumnType("uuid") + .HasColumnName("request_id"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("response_address"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("sent_time"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("source_address"); + + b.HasKey("SequenceNumber") + .HasName("pk_outbox_message"); + + b.HasIndex("EnqueueTime") + .HasDatabaseName("ix_outbox_message_enqueue_time"); + + b.HasIndex("ExpirationTime") + .HasDatabaseName("ix_outbox_message_expiration_time"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique() + .HasDatabaseName("ix_outbox_message_outbox_id_sequence_number"); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique() + .HasDatabaseName("ix_outbox_message_inbox_message_id_inbox_consumer_id_sequence_~"); + + b.ToTable("outbox_message", (string)null); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("outbox_id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone") + .HasColumnName("delivered"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint") + .HasColumnName("last_sequence_number"); + + b.Property("LockId") + .HasColumnType("uuid") + .HasColumnName("lock_id"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("row_version"); + + b.HasKey("OutboxId") + .HasName("pk_outbox_state"); + + b.HasIndex("Created") + .HasDatabaseName("ix_outbox_state_created"); + + b.ToTable("outbox_state", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text") + .HasColumnName("friendly_name"); + + b.Property("Xml") + .HasColumnType("text") + .HasColumnName("xml"); + + b.HasKey("Id") + .HasName("pk_data_protection_keys"); + + b.ToTable("data_protection_keys", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claim_type"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claim_value"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_claims_user_id"); + + b.ToTable("user_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key"); + + b.Property("ProviderDisplayName") + .HasColumnType("text") + .HasColumnName("provider_display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pk_user_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_logins_user_id"); + + b.ToTable("user_logins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("login_provider"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pk_user_tokens"); + + b.ToTable("user_tokens", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CategoryType") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("category_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("NodeId") + .HasColumnType("uuid") + .HasColumnName("node_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("SubjectId") + .HasColumnType("uuid") + .HasColumnName("subject_id"); + + b.Property("SvipQuestionLimit") + .HasColumnType("integer") + .HasColumnName("svip_question_limit"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_categories"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_categories_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_categories_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "NodeId") + .HasDatabaseName("ix_categories_tenant_id_node_id"); + + b.HasIndex("TenantId", "SubjectId") + .HasDatabaseName("ix_categories_tenant_id_subject_id"); + + b.ToTable("categories", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.Major", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SchoolId") + .HasColumnType("uuid") + .HasColumnName("school_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("StudyTips") + .HasColumnType("text") + .HasColumnName("study_tips"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_majors"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_majors_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_majors_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_majors_tenant_id_region_id"); + + b.HasIndex("TenantId", "SchoolId") + .HasDatabaseName("ix_majors_tenant_id_school_id"); + + b.ToTable("majors", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.ModuleNode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("LegacyModuleId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_module_id"); + + b.Property("LegacyParentId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_parent_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("ModuleId") + .HasColumnType("uuid") + .HasColumnName("module_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("ParentId") + .HasColumnType("uuid") + .HasColumnName("parent_id"); + + b.Property("Path") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("path"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_module_nodes"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_module_nodes_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_module_nodes_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "ModuleId") + .HasDatabaseName("ix_module_nodes_tenant_id_module_id"); + + b.HasIndex("TenantId", "ParentId") + .HasDatabaseName("ix_module_nodes_tenant_id_parent_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_module_nodes_tenant_id_region_id"); + + b.ToTable("module_nodes", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.QuestionTaxonomyAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("IsPrimary") + .HasColumnType("boolean") + .HasColumnName("is_primary"); + + b.Property("QuestionId") + .HasColumnType("uuid") + .HasColumnName("question_id"); + + b.Property("TaxonomyNodeId") + .HasColumnType("uuid") + .HasColumnName("taxonomy_node_id"); + + b.Property("TaxonomyOwnerTenantId") + .HasColumnType("uuid") + .HasColumnName("taxonomy_owner_tenant_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id") + .HasName("pk_question_taxonomy_assignments"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_question_taxonomy_assignments_tenant_id_id"); + + b.HasIndex("TaxonomyOwnerTenantId", "TaxonomyNodeId") + .HasDatabaseName("ix_question_taxonomy_assignments_taxonomy_owner_tenant_id_taxo~"); + + b.HasIndex("TenantId", "QuestionId", "TaxonomyOwnerTenantId", "TaxonomyNodeId") + .IsUnique() + .HasDatabaseName("ix_question_taxonomy_assignments_tenant_id_question_id_taxonom~"); + + b.ToTable("question_taxonomy_assignments", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Code") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("code"); + + b.Property("Config") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("config") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("FullName") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("full_name"); + + b.Property("Icon") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("icon"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsHot") + .HasColumnType("boolean") + .HasColumnName("is_hot"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Pinyin") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("pinyin"); + + b.Property("ShortName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("short_name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_regions"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_regions_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_regions_tenant_id_legacy_id"); + + b.ToTable("regions", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.RegionModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Color") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("color"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Icon") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("icon"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsPrimarySchoolModule") + .HasColumnType("boolean") + .HasColumnName("is_primary_school_module"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("Route") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("route"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TextColor") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("text_color"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_region_modules"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_region_modules_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_region_modules_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_region_modules_tenant_id_region_id"); + + b.ToTable("region_modules", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.School", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("ModuleId") + .HasColumnType("uuid") + .HasColumnName("module_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("ProfessionalExamDate") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("professional_exam_date"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_schools"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_schools_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_schools_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "ModuleId") + .HasDatabaseName("ix_schools_tenant_id_module_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_schools_tenant_id_region_id"); + + b.ToTable("schools", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.ScorelineField", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("FieldKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("field_key"); + + b.Property("FieldName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("field_name"); + + b.Property("FieldType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("field_type"); + + b.Property("IsFilter") + .HasColumnType("boolean") + .HasColumnName("is_filter"); + + b.Property("IsRequired") + .HasColumnType("boolean") + .HasColumnName("is_required"); + + b.Property("IsTrend") + .HasColumnType("boolean") + .HasColumnName("is_trend"); + + b.Property("IsVisible") + .HasColumnType("boolean") + .HasColumnName("is_visible"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Options") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("options") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("Placeholder") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("placeholder"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Unit") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("unit"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_scoreline_fields"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_scoreline_fields_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_scoreline_fields_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "RegionId", "FieldKey") + .IsUnique() + .HasDatabaseName("ix_scoreline_fields_tenant_id_region_id_field_key"); + + b.HasIndex("TenantId", "RegionId", "IsFilter", "SortOrder") + .HasDatabaseName("ix_scoreline_fields_tenant_id_region_id_is_filter_sort_order"); + + b.ToTable("scoreline_fields", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.ScorelineRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("FieldValues") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("field_values") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("MajorId") + .HasColumnType("uuid") + .HasColumnName("major_id"); + + b.Property("MajorName") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("major_name"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SchoolId") + .HasColumnType("uuid") + .HasColumnName("school_id"); + + b.Property("SchoolName") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("school_name"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("Year") + .HasColumnType("integer") + .HasColumnName("year"); + + b.HasKey("Id") + .HasName("pk_scoreline_records"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_scoreline_records_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_scoreline_records_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "MajorId") + .HasDatabaseName("ix_scoreline_records_tenant_id_major_id"); + + b.HasIndex("TenantId", "SchoolId") + .HasDatabaseName("ix_scoreline_records_tenant_id_school_id"); + + b.HasIndex("TenantId", "Year") + .HasDatabaseName("ix_scoreline_records_tenant_id_year"); + + b.HasIndex("TenantId", "RegionId", "SchoolId", "MajorId", "Year") + .HasDatabaseName("ix_scoreline_records_tenant_id_region_id_school_id_major_id_ye~"); + + b.HasIndex("TenantId", "Year", "SchoolName", "MajorName", "Id") + .IsDescending(false, true, false, false, false) + .HasDatabaseName("ix_scoreline_records_tenant_id_year_school_name_major_name_id"); + + 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.Content.AiRecommendationReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ContextPayload") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("context_payload") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("ErrorMessage") + .HasColumnType("text") + .HasColumnName("error_message"); + + b.Property("GeneratedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("generated_at"); + + b.Property("InputPayload") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("input_payload") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Model") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("model"); + + b.Property("PromptVersion") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("prompt_version"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("provider"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("ResultPayload") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("result_payload") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_ai_recommendation_reports"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_ai_recommendation_reports_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_ai_recommendation_reports_user_id"); + + b.HasIndex("TenantId", "RegionId", "CreatedAt") + .HasDatabaseName("ix_ai_recommendation_reports_tenant_id_region_id_created_at"); + + b.HasIndex("TenantId", "UserId", "CreatedAt") + .HasDatabaseName("ix_ai_recommendation_reports_tenant_id_user_id_created_at"); + + b.ToTable("ai_recommendation_reports", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.AppAsset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AssetKey") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("asset_key"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("FileName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("file_name"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_app_assets"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_app_assets_tenant_id_id"); + + b.HasIndex("TenantId", "AssetKey") + .IsUnique() + .HasDatabaseName("ix_app_assets_tenant_id_asset_key"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_app_assets_tenant_id_legacy_id"); + + b.ToTable("app_assets", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentAsset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AccessRules") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("access_rules") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("AssetKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("asset_key"); + + b.Property("AssetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("asset_type"); + + b.Property("Bucket") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("bucket"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("category"); + + b.Property("CategoryId") + .HasColumnType("uuid") + .HasColumnName("category_id"); + + b.Property("CdnUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("cdn_url"); + + b.Property("ChecksumSha256") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("checksum_sha256"); + + b.Property("ContentNodeId") + .HasColumnType("uuid") + .HasColumnName("content_node_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("DownloadCount") + .HasColumnType("integer") + .HasColumnName("download_count"); + + b.Property("FileName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("file_name"); + + b.Property("FileSizeBytes") + .HasColumnType("bigint") + .HasColumnName("file_size_bytes"); + + b.Property("IsPublic") + .HasColumnType("boolean") + .HasColumnName("is_public"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("MimeType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("mime_type"); + + b.Property("ObjectKey") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("object_key"); + + b.Property("PreviewObjectKey") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("preview_object_key"); + + b.Property("PreviewStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("preview_status"); + + b.Property("PreviewUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("preview_url"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SecurityFlags") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("security_flags") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("SecurityScanProvider") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("security_scan_provider"); + + b.Property("SecurityScanStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasDefaultValue("not_required") + .HasColumnName("security_scan_status"); + + b.Property("SecurityScanSummary") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("security_scan_summary") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("SecurityScannedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("security_scanned_at"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("source"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("StorageProvider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("storage_provider"); + + b.Property("SubjectId") + .HasColumnType("uuid") + .HasColumnName("subject_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("UploadStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("upload_status"); + + b.Property("VerificationDetails") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("verification_details") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("verified_at"); + + b.Property("VerifiedBy") + .HasColumnType("uuid") + .HasColumnName("verified_by"); + + b.Property("VerifiedChecksumSha256") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("verified_checksum_sha256"); + + b.Property("VerifiedSizeBytes") + .HasColumnType("bigint") + .HasColumnName("verified_size_bytes"); + + b.Property("Visibility") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("visibility"); + + b.HasKey("Id") + .HasName("pk_content_assets"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_content_assets_tenant_id_id"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_content_assets_created_by"); + + b.HasIndex("UpdatedBy") + .HasDatabaseName("ix_content_assets_updated_by"); + + b.HasIndex("VerifiedBy") + .HasDatabaseName("ix_content_assets_verified_by"); + + b.HasIndex("TenantId", "CategoryId") + .HasDatabaseName("ix_content_assets_tenant_id_category_id"); + + b.HasIndex("TenantId", "ChecksumSha256") + .HasDatabaseName("ix_content_assets_tenant_id_checksum_sha256") + .HasFilter("checksum_sha256 is not null"); + + b.HasIndex("TenantId", "ContentNodeId") + .HasDatabaseName("ix_content_assets_tenant_id_content_node_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_content_assets_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "PreviewStatus") + .HasDatabaseName("ix_content_assets_tenant_id_preview_status") + .HasFilter("preview_status <> 'none'"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_content_assets_tenant_id_region_id"); + + b.HasIndex("TenantId", "SubjectId") + .HasDatabaseName("ix_content_assets_tenant_id_subject_id"); + + b.HasIndex("TenantId", "StorageProvider", "Bucket", "ObjectKey") + .HasDatabaseName("ix_content_assets_tenant_id_storage_provider_bucket_object_key"); + + b.HasIndex("TenantId", "UploadStatus", "Status", "UpdatedAt") + .HasDatabaseName("ix_content_assets_tenant_id_upload_status_status_updated_at"); + + b.HasIndex("TenantId", "SecurityScanStatus", "UploadStatus", "Status", "UpdatedAt") + .HasDatabaseName("ix_content_assets_tenant_id_security_scan_status_upload_status~"); + + b.HasIndex("TenantId", "Status", "Visibility", "AssetType", "RegionId", "SubjectId", "CategoryId", "SortOrder") + .HasDatabaseName("ix_content_assets_tenant_id_status_visibility_asset_type_regio~"); + + b.ToTable("content_assets", null, t => + { + t.HasCheckConstraint("ck_content_assets_download_count", "download_count >= 0"); + + t.HasCheckConstraint("ck_content_assets_file_size", "file_size_bytes is null or file_size_bytes >= 0"); + + t.HasCheckConstraint("ck_content_assets_verified_checksum", "verified_checksum_sha256 is null or verified_checksum_sha256 ~ '^[a-f0-9]{64}$'"); + + t.HasCheckConstraint("ck_content_assets_verified_size", "verified_size_bytes is null or verified_size_bytes >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentAssetAccessEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AccessType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("access_type"); + + b.Property("ActorRole") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("actor_role"); + + b.Property("AssetId") + .HasColumnType("uuid") + .HasColumnName("asset_id"); + + b.Property("AssetType") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("asset_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DenyCode") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("deny_code"); + + b.Property("Disposition") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("disposition"); + + b.Property("ExpiresInSeconds") + .HasColumnType("integer") + .HasColumnName("expires_in_seconds"); + + b.Property("IpAddress") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("ip_address"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Result") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("result"); + + b.Property("SignatureMode") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("signature_mode"); + + b.Property("StorageProvider") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("storage_provider"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UserAgent") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("Visibility") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("visibility"); + + b.HasKey("Id") + .HasName("pk_content_asset_access_events"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_content_asset_access_events_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_content_asset_access_events_user_id"); + + b.HasIndex("TenantId", "AssetId", "CreatedAt") + .HasDatabaseName("ix_content_asset_access_events_tenant_id_asset_id_created_at"); + + b.HasIndex("TenantId", "UserId", "CreatedAt") + .HasDatabaseName("ix_content_asset_access_events_tenant_id_user_id_created_at") + .HasFilter("user_id is not null"); + + b.HasIndex("TenantId", "AccessType", "Result", "CreatedAt") + .HasDatabaseName("ix_content_asset_access_events_tenant_id_access_type_result_cr~"); + + b.ToTable("content_asset_access_events", null, t => + { + t.HasCheckConstraint("ck_content_asset_access_events_expiry", "expires_in_seconds is null or expires_in_seconds > 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentAssetSecurityScanEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AssetId") + .HasColumnType("uuid") + .HasColumnName("asset_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Details") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("details") + .HasDefaultValueSql("'{}'::jsonb"); + + b.PrimitiveCollection("IssueCodes") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text[]") + .HasColumnName("issue_codes") + .HasDefaultValueSql("'{}'::text[]"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("provider"); + + b.Property("RiskLevel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("risk_level"); + + b.Property("ScanStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("scan_status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id") + .HasName("pk_content_asset_security_scan_events"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_content_asset_security_scan_events_tenant_id_id"); + + b.HasIndex("TenantId", "AssetId", "CreatedAt") + .HasDatabaseName("ix_content_asset_security_scan_events_tenant_id_asset_id_creat~"); + + b.HasIndex("TenantId", "ScanStatus", "RiskLevel", "CreatedAt") + .HasDatabaseName("ix_content_asset_security_scan_events_tenant_id_scan_status_ri~"); + + b.ToTable("content_asset_security_scan_events", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AccessRules") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("access_rules") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("description"); + + b.Property("EntryKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("entry_key"); + + b.Property("EntryType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("entry_type"); + + b.Property("Icon") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("icon"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LayoutConfig") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("layout_config") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("Route") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("route"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Visibility") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("visibility"); + + b.HasKey("Id") + .HasName("pk_content_entries"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_content_entries_tenant_id_id"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_content_entries_created_by"); + + b.HasIndex("UpdatedBy") + .HasDatabaseName("ix_content_entries_updated_by"); + + b.HasIndex("TenantId", "EntryKey") + .IsUnique() + .HasDatabaseName("ix_content_entries_tenant_id_entry_key"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_content_entries_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "RegionId", "EntryType", "IsActive", "SortOrder") + .HasDatabaseName("ix_content_entries_tenant_id_region_id_entry_type_is_active_so~"); + + b.ToTable("content_entries", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentImportIssue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Details") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("details") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("FieldPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("field_path"); + + b.Property("ItemId") + .HasColumnType("uuid") + .HasColumnName("item_id"); + + b.Property("JobId") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("message"); + + b.Property("RowNo") + .HasColumnType("integer") + .HasColumnName("row_no"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("severity"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_content_import_issues"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_content_import_issues_tenant_id_id"); + + b.HasIndex("TenantId", "ItemId") + .HasDatabaseName("ix_content_import_issues_tenant_id_item_id"); + + b.HasIndex("TenantId", "JobId", "Severity", "RowNo") + .HasDatabaseName("ix_content_import_issues_tenant_id_job_id_severity_row_no"); + + b.ToTable("content_import_issues", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentImportItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ContentHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("content_hash"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("ExternalId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("external_id"); + + b.Property("IssuesCount") + .HasColumnType("integer") + .HasColumnName("issues_count"); + + b.Property("JobId") + .HasColumnType("uuid") + .HasColumnName("job_id"); + + b.Property("NormalizedPayload") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("normalized_payload") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("RowNo") + .HasColumnType("integer") + .HasColumnName("row_no"); + + b.Property("SourcePayload") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("source_payload") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TargetId") + .HasColumnType("uuid") + .HasColumnName("target_id"); + + b.Property("TargetType") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("target_type"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_content_import_items"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_content_import_items_tenant_id_id"); + + b.HasIndex("TenantId", "JobId", "RowNo") + .IsUnique() + .HasDatabaseName("ix_content_import_items_tenant_id_job_id_row_no"); + + b.HasIndex("TenantId", "JobId", "Status", "RowNo") + .HasDatabaseName("ix_content_import_items_tenant_id_job_id_status_row_no"); + + b.ToTable("content_import_items", null, t => + { + t.HasCheckConstraint("ck_content_import_items_issues_count", "issues_count >= 0"); + + t.HasCheckConstraint("ck_content_import_items_row_no", "row_no >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentImportJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DryRun") + .HasColumnType("boolean") + .HasColumnName("dry_run"); + + b.Property("ErrorCount") + .HasColumnType("integer") + .HasColumnName("error_count"); + + b.Property("ErrorMessage") + .HasColumnType("text") + .HasColumnName("error_message"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("ImportType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("import_type"); + + b.Property("InsertedCount") + .HasColumnType("integer") + .HasColumnName("inserted_count"); + + b.Property("NormalizedPayload") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("normalized_payload") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("RawPayload") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("raw_payload") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("SkippedCount") + .HasColumnType("integer") + .HasColumnName("skipped_count"); + + b.Property("SourceFormat") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("source_format"); + + b.Property("SourceHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_hash"); + + b.Property("SourceName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("source_name"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("Summary") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("summary") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("TargetCategoryId") + .HasColumnType("uuid") + .HasColumnName("target_category_id"); + + b.Property("TargetContentNodeId") + .HasColumnType("uuid") + .HasColumnName("target_content_node_id"); + + b.Property("TargetQuestionBankId") + .HasColumnType("uuid") + .HasColumnName("target_question_bank_id"); + + b.Property("TargetRegionId") + .HasColumnType("uuid") + .HasColumnName("target_region_id"); + + b.Property("TargetSubjectId") + .HasColumnType("uuid") + .HasColumnName("target_subject_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TotalCount") + .HasColumnType("integer") + .HasColumnName("total_count"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedCount") + .HasColumnType("integer") + .HasColumnName("updated_count"); + + b.Property("ValidCount") + .HasColumnType("integer") + .HasColumnName("valid_count"); + + b.Property("WarningCount") + .HasColumnType("integer") + .HasColumnName("warning_count"); + + b.HasKey("Id") + .HasName("pk_content_import_jobs"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_content_import_jobs_tenant_id_id"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_content_import_jobs_created_by"); + + b.HasIndex("TenantId", "TargetCategoryId") + .HasDatabaseName("ix_content_import_jobs_tenant_id_target_category_id"); + + b.HasIndex("TenantId", "TargetContentNodeId") + .HasDatabaseName("ix_content_import_jobs_tenant_id_target_content_node_id"); + + b.HasIndex("TenantId", "TargetQuestionBankId") + .HasDatabaseName("ix_content_import_jobs_tenant_id_target_question_bank_id"); + + b.HasIndex("TenantId", "TargetRegionId") + .HasDatabaseName("ix_content_import_jobs_tenant_id_target_region_id"); + + b.HasIndex("TenantId", "TargetSubjectId") + .HasDatabaseName("ix_content_import_jobs_tenant_id_target_subject_id"); + + b.HasIndex("TenantId", "ImportType", "Status", "CreatedAt") + .HasDatabaseName("ix_content_import_jobs_tenant_id_import_type_status_created_at"); + + b.ToTable("content_import_jobs", null, t => + { + t.HasCheckConstraint("ck_content_import_jobs_counts", "total_count >= 0 and valid_count >= 0 and error_count >= 0 and warning_count >= 0 and inserted_count >= 0 and updated_count >= 0 and skipped_count >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentNode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AccessRules") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("access_rules") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("Depth") + .HasColumnType("integer") + .HasColumnName("depth"); + + b.Property("EntryId") + .HasColumnType("uuid") + .HasColumnName("entry_id"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsLeaf") + .HasColumnType("boolean") + .HasColumnName("is_leaf"); + + b.Property("IsSelectable") + .HasColumnType("boolean") + .HasColumnName("is_selectable"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("MarkerConfig") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("marker_config") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("MarkerType") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("marker_type"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("NodeKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("node_key"); + + b.Property("NodeType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("node_type"); + + b.Property("ParentId") + .HasColumnType("uuid") + .HasColumnName("parent_id"); + + b.Property("Path") + .HasColumnType("ltree") + .HasColumnName("path"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_content_nodes"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_content_nodes_tenant_id_id"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_content_nodes_created_by"); + + b.HasIndex("Path") + .HasDatabaseName("ix_content_nodes_path"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Path"), "gist"); + + b.HasIndex("UpdatedBy") + .HasDatabaseName("ix_content_nodes_updated_by"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_content_nodes_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "MarkerType") + .HasDatabaseName("ix_content_nodes_tenant_id_marker_type") + .HasFilter("marker_type is not null"); + + b.HasIndex("TenantId", "ParentId") + .HasDatabaseName("ix_content_nodes_tenant_id_parent_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_content_nodes_tenant_id_region_id"); + + b.HasIndex("TenantId", "EntryId", "NodeKey") + .IsUnique() + .HasDatabaseName("ix_content_nodes_tenant_id_entry_id_node_key"); + + b.HasIndex("TenantId", "EntryId", "ParentId", "SortOrder") + .HasDatabaseName("ix_content_nodes_tenant_id_entry_id_parent_id_sort_order"); + + b.ToTable("content_nodes", null, t => + { + t.HasCheckConstraint("ck_content_nodes_depth", "depth >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Content.HandbookChapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ContentNodeId") + .HasColumnType("uuid") + .HasColumnName("content_node_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EntryId") + .HasColumnType("uuid") + .HasColumnName("entry_id"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("SourceHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_hash"); + + b.Property("SubjectId") + .HasColumnType("uuid") + .HasColumnName("subject_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_handbook_chapters"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_handbook_chapters_tenant_id_id"); + + b.HasIndex("TenantId", "ContentNodeId") + .HasDatabaseName("ix_handbook_chapters_tenant_id_content_node_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_handbook_chapters_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "SubjectId") + .HasDatabaseName("ix_handbook_chapters_tenant_id_subject_id"); + + b.HasIndex("TenantId", "EntryId", "ContentNodeId", "SubjectId", "IsActive", "SortOrder") + .HasDatabaseName("ix_handbook_chapters_tenant_id_entry_id_content_node_id_subjec~"); + + b.ToTable("handbook_chapters", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.HandbookEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ChapterId") + .HasColumnType("uuid") + .HasColumnName("chapter_id"); + + b.Property("Content") + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("ContentNodeId") + .HasColumnType("uuid") + .HasColumnName("content_node_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("EntryId") + .HasColumnType("uuid") + .HasColumnName("entry_id"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("SourceHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_hash"); + + b.Property("Summary") + .HasColumnType("text") + .HasColumnName("summary"); + + b.Property("Tags") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("tags") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_handbook_entries"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_handbook_entries_tenant_id_id"); + + b.HasIndex("TenantId", "ChapterId") + .HasDatabaseName("ix_handbook_entries_tenant_id_chapter_id"); + + b.HasIndex("TenantId", "ContentNodeId") + .HasDatabaseName("ix_handbook_entries_tenant_id_content_node_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_handbook_entries_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "EntryId", "ContentNodeId", "ChapterId", "IsActive", "SortOrder") + .HasDatabaseName("ix_handbook_entries_tenant_id_entry_id_content_node_id_chapter~"); + + b.ToTable("handbook_entries", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.HandbookSubject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Color") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("color"); + + b.Property("ContentNodeId") + .HasColumnType("uuid") + .HasColumnName("content_node_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EntryId") + .HasColumnType("uuid") + .HasColumnName("entry_id"); + + b.Property("Icon") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("icon"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("MajorId") + .HasColumnType("uuid") + .HasColumnName("major_id"); + + b.Property("MajorLegacyIds") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("major_legacy_ids") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SchoolId") + .HasColumnType("uuid") + .HasColumnName("school_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("SourceHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_hash"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Type") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_handbook_subjects"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_handbook_subjects_tenant_id_id"); + + b.HasIndex("TenantId", "ContentNodeId") + .HasDatabaseName("ix_handbook_subjects_tenant_id_content_node_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_handbook_subjects_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "MajorId") + .HasDatabaseName("ix_handbook_subjects_tenant_id_major_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_handbook_subjects_tenant_id_region_id"); + + b.HasIndex("TenantId", "SchoolId") + .HasDatabaseName("ix_handbook_subjects_tenant_id_school_id"); + + b.HasIndex("TenantId", "EntryId", "ContentNodeId", "RegionId", "IsActive", "SortOrder") + .HasDatabaseName("ix_handbook_subjects_tenant_id_entry_id_content_node_id_region~"); + + b.ToTable("handbook_subjects", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ImageAsset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("category"); + + b.Property("CdnUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("cdn_url"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("FileName") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("file_name"); + + b.Property("IsPublic") + .HasColumnType("boolean") + .HasColumnName("is_public"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_images"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_images_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_images_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "Category", "IsPublic") + .HasDatabaseName("ix_images_tenant_id_category_is_public"); + + b.ToTable("images", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.PracticeBlueprint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AccessRules") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("access_rules") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("AssemblyType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("assembly_type"); + + b.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DurationMinutes") + .HasColumnType("integer") + .HasColumnName("duration_minutes"); + + b.Property("EntryId") + .HasColumnType("uuid") + .HasColumnName("entry_id"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("mode"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("NodeId") + .HasColumnType("uuid") + .HasColumnName("node_id"); + + b.Property("PassScore") + .HasPrecision(8, 2) + .HasColumnType("numeric(8,2)") + .HasColumnName("pass_score"); + + b.Property("QuestionLimit") + .HasColumnType("integer") + .HasColumnName("question_limit"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("Rules") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("rules") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Sections") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("sections") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TotalScore") + .HasPrecision(8, 2) + .HasColumnType("numeric(8,2)") + .HasColumnName("total_score"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_practice_blueprints"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_practice_blueprints_tenant_id_id"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_practice_blueprints_created_by"); + + b.HasIndex("UpdatedBy") + .HasDatabaseName("ix_practice_blueprints_updated_by"); + + b.HasIndex("TenantId", "CollectionId") + .HasDatabaseName("ix_practice_blueprints_tenant_id_collection_id"); + + b.HasIndex("TenantId", "EntryId") + .HasDatabaseName("ix_practice_blueprints_tenant_id_entry_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_practice_blueprints_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_practice_blueprints_tenant_id_region_id"); + + b.HasIndex("TenantId", "NodeId", "CollectionId", "Mode", "Status", "SortOrder") + .HasDatabaseName("ix_practice_blueprints_tenant_id_node_id_collection_id_mode_st~"); + + b.ToTable("practice_blueprints", null, t => + { + t.HasCheckConstraint("ck_practice_blueprints_duration", "duration_minutes is null or duration_minutes > 0"); + + t.HasCheckConstraint("ck_practice_blueprints_question_limit", "question_limit is null or question_limit > 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Content.QuestionCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AccessRules") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("access_rules") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("CategoryId") + .HasColumnType("uuid") + .HasColumnName("category_id"); + + b.Property("CollectionType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("collection_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("DurationMinutes") + .HasColumnType("integer") + .HasColumnName("duration_minutes"); + + b.Property("EntryId") + .HasColumnType("uuid") + .HasColumnName("entry_id"); + + b.Property("Filters") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("filters") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("NodeId") + .HasColumnType("uuid") + .HasColumnName("node_id"); + + b.Property("QuestionBankId") + .HasColumnType("uuid") + .HasColumnName("question_bank_id"); + + b.Property("QuestionCount") + .HasColumnType("integer") + .HasColumnName("question_count"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("source_type"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("SubjectId") + .HasColumnType("uuid") + .HasColumnName("subject_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TotalScore") + .HasPrecision(8, 2) + .HasColumnType("numeric(8,2)") + .HasColumnName("total_score"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_question_collections"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_question_collections_tenant_id_id"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_question_collections_created_by"); + + b.HasIndex("UpdatedBy") + .HasDatabaseName("ix_question_collections_updated_by"); + + b.HasIndex("TenantId", "CategoryId") + .HasDatabaseName("ix_question_collections_tenant_id_category_id"); + + b.HasIndex("TenantId", "EntryId") + .HasDatabaseName("ix_question_collections_tenant_id_entry_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_question_collections_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "QuestionBankId") + .HasDatabaseName("ix_question_collections_tenant_id_question_bank_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_question_collections_tenant_id_region_id"); + + b.HasIndex("TenantId", "SubjectId") + .HasDatabaseName("ix_question_collections_tenant_id_subject_id"); + + b.HasIndex("TenantId", "NodeId", "Status", "SortOrder") + .HasDatabaseName("ix_question_collections_tenant_id_node_id_status_sort_order"); + + b.ToTable("question_collections", null, t => + { + t.HasCheckConstraint("ck_question_collections_duration", "duration_minutes is null or duration_minutes > 0"); + + t.HasCheckConstraint("ck_question_collections_question_count", "question_count >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Content.QuestionCollectionItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("QuestionId") + .HasColumnType("uuid") + .HasColumnName("question_id"); + + b.Property("QuestionOwnerTenantId") + .HasColumnType("uuid") + .HasColumnName("question_owner_tenant_id"); + + b.Property("QuestionReferenceId") + .HasColumnType("uuid") + .HasColumnName("question_reference_id"); + + b.Property("Required") + .HasColumnType("boolean") + .HasColumnName("required"); + + b.Property("Score") + .HasPrecision(8, 2) + .HasColumnType("numeric(8,2)") + .HasColumnName("score"); + + b.Property("SectionKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("section_key"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_question_collection_items"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_question_collection_items_tenant_id_id"); + + b.HasIndex("QuestionOwnerTenantId", "QuestionId") + .HasDatabaseName("ix_question_collection_items_question_owner_tenant_id_question~"); + + b.HasIndex("TenantId", "QuestionReferenceId") + .HasDatabaseName("ix_question_collection_items_tenant_id_question_reference_id"); + + b.HasIndex("TenantId", "CollectionId", "QuestionReferenceId") + .IsUnique() + .HasDatabaseName("ix_question_collection_items_tenant_id_collection_id_question_~"); + + b.HasIndex("TenantId", "CollectionId", "SectionKey", "SortOrder") + .HasDatabaseName("ix_question_collection_items_tenant_id_collection_id_section_k~"); + + b.ToTable("question_collection_items", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.QuestionTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("SubjectId") + .HasColumnType("uuid") + .HasColumnName("subject_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Types") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("types") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_question_type_groups"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_question_type_groups_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_question_type_groups_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "SubjectId", "IsActive", "SortOrder") + .HasDatabaseName("ix_question_type_groups_tenant_id_subject_id_is_active_sort_or~"); + + b.ToTable("question_type_groups", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.QuestionVideo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("LegacyQuestionId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_question_id"); + + b.Property("LegacyVideoId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_video_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("QuestionId") + .HasColumnType("uuid") + .HasColumnName("question_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("VideoId") + .HasColumnType("uuid") + .HasColumnName("video_id"); + + b.Property("VideoType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("video_type"); + + b.HasKey("Id") + .HasName("pk_question_videos"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_question_videos_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_question_videos_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "VideoId") + .HasDatabaseName("ix_question_videos_tenant_id_video_id"); + + b.HasIndex("TenantId", "QuestionId", "VideoId", "VideoType") + .IsUnique() + .HasDatabaseName("ix_question_videos_tenant_id_question_id_video_id_video_type"); + + b.ToTable("question_videos", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.SubjectShare", b => + { + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("SourceSubjectId") + .HasColumnType("uuid") + .HasColumnName("source_subject_id"); + + b.Property("TargetSubjectId") + .HasColumnType("uuid") + .HasColumnName("target_subject_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("LegacyId") + .HasColumnType("text") + .HasColumnName("legacy_id"); + + b.Property("LegacySourceSubjectId") + .HasColumnType("text") + .HasColumnName("legacy_source_subject_id"); + + b.Property("LegacyTargetSubjectId") + .HasColumnType("text") + .HasColumnName("legacy_target_subject_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("TenantId", "SourceSubjectId", "TargetSubjectId") + .HasName("pk_subject_shares"); + + b.HasIndex("TenantId", "TargetSubjectId") + .HasDatabaseName("ix_subject_shares_tenant_id_target_subject_id"); + + b.ToTable("subject_shares", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.TenantQuestionBankPreference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Alias") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("alias"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("IsVisible") + .HasColumnType("boolean") + .HasColumnName("is_visible"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("NavigationLocation") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("navigation_location"); + + b.Property("QuestionBankId") + .HasColumnType("uuid") + .HasColumnName("question_bank_id"); + + b.Property("QuestionBankOwnerTenantId") + .HasColumnType("uuid") + .HasColumnName("question_bank_owner_tenant_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_tenant_question_bank_preferences"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_question_bank_preferences_tenant_id_id"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_tenant_question_bank_preferences_created_by"); + + b.HasIndex("UpdatedBy") + .HasDatabaseName("ix_tenant_question_bank_preferences_updated_by"); + + b.HasIndex("QuestionBankOwnerTenantId", "QuestionBankId") + .HasDatabaseName("ix_tenant_question_bank_preferences_question_bank_owner_tenant~"); + + b.HasIndex("TenantId", "QuestionBankOwnerTenantId", "QuestionBankId") + .IsUnique() + .HasDatabaseName("ix_tenant_question_bank_preferences_tenant_id_question_bank_ow~"); + + b.ToTable("tenant_question_bank_preferences", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.TenantQuestionReference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("QuestionId") + .HasColumnType("uuid") + .HasColumnName("question_id"); + + b.Property("QuestionOwnerTenantId") + .HasColumnType("uuid") + .HasColumnName("question_owner_tenant_id"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("source"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_tenant_question_references"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_question_references_tenant_id_id"); + + b.HasAlternateKey("TenantId", "QuestionOwnerTenantId", "QuestionId") + .HasName("ak_tenant_question_references_tenant_id_question_owner_tenant_~"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_tenant_question_references_created_by"); + + b.HasIndex("QuestionOwnerTenantId", "QuestionId") + .HasDatabaseName("ix_tenant_question_references_question_owner_tenant_id_questio~"); + + b.ToTable("tenant_question_references", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.UserWordFavorite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("FavoritedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("favorited_at"); + + b.Property("LegacyId") + .HasColumnType("text") + .HasColumnName("legacy_id"); + + b.Property("LegacyUserId") + .HasColumnType("text") + .HasColumnName("legacy_user_id"); + + b.Property("LegacyWordId") + .HasColumnType("text") + .HasColumnName("legacy_word_id"); + + b.Property("Note") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("note"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("WordId") + .HasColumnType("uuid") + .HasColumnName("word_id"); + + b.HasKey("Id") + .HasName("pk_user_word_favorites"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_user_word_favorites_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_word_favorites_user_id"); + + b.HasIndex("TenantId", "WordId") + .HasDatabaseName("ix_user_word_favorites_tenant_id_word_id"); + + b.HasIndex("TenantId", "UserId", "WordId") + .IsUnique() + .HasDatabaseName("ix_user_word_favorites_tenant_id_user_id_word_id"); + + b.ToTable("user_word_favorites", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.UserWordProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CorrectCount") + .HasColumnType("integer") + .HasColumnName("correct_count"); + + b.Property("CorrectStreak") + .HasColumnType("integer") + .HasColumnName("correct_streak"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DueLevel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("due_level"); + + b.Property("EaseFactor") + .ValueGeneratedOnAdd() + .HasPrecision(4, 2) + .HasColumnType("numeric(4,2)") + .HasDefaultValue(2.50m) + .HasColumnName("ease_factor"); + + b.Property("LastResult") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("last_result"); + + b.Property("LastReviewAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_review_at"); + + b.Property("LegacyId") + .HasColumnType("text") + .HasColumnName("legacy_id"); + + b.Property("LegacyUserId") + .HasColumnType("text") + .HasColumnName("legacy_user_id"); + + b.Property("LegacyWordId") + .HasColumnType("text") + .HasColumnName("legacy_word_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("NextReviewAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_review_at"); + + b.Property("ReviewCount") + .HasColumnType("integer") + .HasColumnName("review_count"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("WordId") + .HasColumnType("uuid") + .HasColumnName("word_id"); + + b.Property("WrongCount") + .HasColumnType("integer") + .HasColumnName("wrong_count"); + + b.HasKey("Id") + .HasName("pk_user_word_progress"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_user_word_progress_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_word_progress_user_id"); + + b.HasIndex("TenantId", "WordId") + .HasDatabaseName("ix_user_word_progress_tenant_id_word_id"); + + b.HasIndex("TenantId", "UserId", "WordId") + .IsUnique() + .HasDatabaseName("ix_user_word_progress_tenant_id_user_id_word_id"); + + b.HasIndex("TenantId", "UserId", "NextReviewAt", "Status", "DueLevel") + .HasDatabaseName("ix_user_word_progress_tenant_id_user_id_next_review_at_status_~"); + + b.ToTable("user_word_progress", null, t => + { + t.HasCheckConstraint("ck_user_word_progress_correct_streak", "correct_streak >= 0"); + + t.HasCheckConstraint("ck_user_word_progress_ease_factor", "ease_factor >= 1.30 and ease_factor <= 3.00"); + + t.HasCheckConstraint("ck_user_word_progress_review_count", "review_count >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Content.VideoExplanation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsGeneral") + .HasColumnType("boolean") + .HasColumnName("is_general"); + + b.Property("KnowledgeTags") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("knowledge_tags") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("LegacySubjectId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_subject_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("SourceHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_hash"); + + b.Property("SubjectId") + .HasColumnType("uuid") + .HasColumnName("subject_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("ThumbnailUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("thumbnail_url"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("VideoUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("video_url"); + + b.HasKey("Id") + .HasName("pk_video_explanations"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_video_explanations_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_video_explanations_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "SourceHash") + .HasDatabaseName("ix_video_explanations_tenant_id_source_hash") + .HasFilter("source_hash is not null"); + + b.HasIndex("TenantId", "SubjectId", "IsActive", "SortOrder") + .HasDatabaseName("ix_video_explanations_tenant_id_subject_id_is_active_sort_order"); + + b.ToTable("video_explanations", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.VideoPlaybackProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("IsCompleted") + .HasColumnType("boolean") + .HasColumnName("is_completed"); + + b.Property("LastPlayedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("last_played_at") + .HasDefaultValueSql("now()"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("PlayCount") + .HasColumnType("integer") + .HasColumnName("play_count"); + + b.Property("PositionSeconds") + .HasColumnType("integer") + .HasColumnName("position_seconds"); + + b.Property("QuestionId") + .HasColumnType("uuid") + .HasColumnName("question_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("VideoId") + .HasColumnType("uuid") + .HasColumnName("video_id"); + + b.Property("WatchedSeconds") + .HasColumnType("integer") + .HasColumnName("watched_seconds"); + + b.HasKey("Id") + .HasName("pk_video_playback_progress"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_video_playback_progress_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_video_playback_progress_user_id"); + + b.HasIndex("TenantId", "QuestionId") + .HasDatabaseName("ix_video_playback_progress_tenant_id_question_id"); + + b.HasIndex("TenantId", "VideoId") + .HasDatabaseName("ix_video_playback_progress_tenant_id_video_id"); + + b.HasIndex("TenantId", "UserId", "LastPlayedAt") + .HasDatabaseName("ix_video_playback_progress_tenant_id_user_id_last_played_at"); + + b.HasIndex("TenantId", "UserId", "VideoId") + .IsUnique() + .HasDatabaseName("ix_video_playback_progress_tenant_id_user_id_video_id") + .HasFilter("question_id is null"); + + b.HasIndex("TenantId", "UserId", "VideoId", "QuestionId") + .IsUnique() + .HasDatabaseName("ix_video_playback_progress_tenant_id_user_id_video_id_question~") + .HasFilter("question_id is not null"); + + b.ToTable("video_playback_progress", null, t => + { + t.HasCheckConstraint("ck_video_playback_progress_duration", "duration_seconds is null or duration_seconds >= 0"); + + t.HasCheckConstraint("ck_video_playback_progress_play_count", "play_count >= 0"); + + t.HasCheckConstraint("ck_video_playback_progress_position", "position_seconds >= 0"); + + t.HasCheckConstraint("ck_video_playback_progress_watched", "watched_seconds >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Content.VocabularyUnit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ContentNodeId") + .HasColumnType("uuid") + .HasColumnName("content_node_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EntryId") + .HasColumnType("uuid") + .HasColumnName("entry_id"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("name"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("SourceHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_hash"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("WordCount") + .HasColumnType("integer") + .HasColumnName("word_count"); + + b.HasKey("Id") + .HasName("pk_vocabulary_units"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_vocabulary_units_tenant_id_id"); + + b.HasIndex("TenantId", "ContentNodeId") + .HasDatabaseName("ix_vocabulary_units_tenant_id_content_node_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_vocabulary_units_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_vocabulary_units_tenant_id_region_id"); + + b.HasIndex("TenantId", "EntryId", "ContentNodeId", "RegionId", "IsActive", "SortOrder") + .HasDatabaseName("ix_vocabulary_units_tenant_id_entry_id_content_node_id_region_~"); + + b.ToTable("vocabulary_units", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Content.VocabularyWord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ContentNodeId") + .HasColumnType("uuid") + .HasColumnName("content_node_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Difficulty") + .HasColumnType("integer") + .HasColumnName("difficulty"); + + b.Property("EntryId") + .HasColumnType("uuid") + .HasColumnName("entry_id"); + + b.Property("Example") + .HasColumnType("text") + .HasColumnName("example"); + + b.Property("ExampleTranslation") + .HasColumnType("text") + .HasColumnName("example_translation"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Meaning") + .HasColumnType("text") + .HasColumnName("meaning"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Phonetic") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("phonetic"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("SourceHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_hash"); + + b.Property("Tags") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("tags") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UnitId") + .HasColumnType("uuid") + .HasColumnName("unit_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("Word") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("word"); + + b.HasKey("Id") + .HasName("pk_vocabulary_words"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_vocabulary_words_tenant_id_id"); + + b.HasIndex("TenantId", "ContentNodeId") + .HasDatabaseName("ix_vocabulary_words_tenant_id_content_node_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_vocabulary_words_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "UnitId", "IsActive", "SortOrder", "CreatedAt") + .HasDatabaseName("ix_vocabulary_words_tenant_id_unit_id_is_active_sort_order_cre~"); + + b.HasIndex("TenantId", "EntryId", "ContentNodeId", "UnitId", "IsActive", "SortOrder") + .HasDatabaseName("ix_vocabulary_words_tenant_id_entry_id_content_node_id_unit_id~"); + + b.ToTable("vocabulary_words", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.CommissionSettlement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CommissionAmountCents") + .HasColumnType("integer") + .HasColumnName("commission_amount_cents"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DefaultRate") + .HasPrecision(6, 4) + .HasColumnType("numeric(6,4)") + .HasColumnName("default_rate"); + + b.Property("EffectiveRate") + .HasPrecision(6, 4) + .HasColumnType("numeric(6,4)") + .HasColumnName("effective_rate"); + + b.Property("GeneratedBy") + .HasColumnType("uuid") + .HasColumnName("generated_by"); + + b.Property("GrossAmountCents") + .HasColumnType("integer") + .HasColumnName("gross_amount_cents"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("PaidAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("paid_at"); + + b.Property("PaidBy") + .HasColumnType("uuid") + .HasColumnName("paid_by"); + + b.Property("PaidUserCount") + .HasColumnType("integer") + .HasColumnName("paid_user_count"); + + b.Property("PaymentAccount") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("payment_account"); + + b.Property("PaymentMethod") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("payment_method"); + + b.Property("PeriodEnd") + .HasColumnType("date") + .HasColumnName("period_end"); + + b.Property("PeriodStart") + .HasColumnType("date") + .HasColumnName("period_start"); + + b.Property("ReferrerUserId") + .HasColumnType("uuid") + .HasColumnName("referrer_user_id"); + + b.Property("Remark") + .HasColumnType("text") + .HasColumnName("remark"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("reviewed_at"); + + b.Property("ReviewedBy") + .HasColumnType("uuid") + .HasColumnName("reviewed_by"); + + b.Property("SettlementNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("settlement_no"); + + b.Property("SourceCount") + .HasColumnType("integer") + .HasColumnName("source_count"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_commission_settlements"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_commission_settlements_tenant_id_id"); + + b.HasIndex("GeneratedBy") + .HasDatabaseName("ix_commission_settlements_generated_by"); + + b.HasIndex("PaidBy") + .HasDatabaseName("ix_commission_settlements_paid_by"); + + b.HasIndex("ReferrerUserId") + .HasDatabaseName("ix_commission_settlements_referrer_user_id"); + + b.HasIndex("ReviewedBy") + .HasDatabaseName("ix_commission_settlements_reviewed_by"); + + b.HasIndex("TenantId", "SettlementNo") + .IsUnique() + .HasDatabaseName("ix_commission_settlements_tenant_id_settlement_no"); + + b.HasIndex("TenantId", "ReferrerUserId", "PeriodStart") + .HasDatabaseName("ix_commission_settlements_referrer_start"); + + b.HasIndex("TenantId", "ReferrerUserId", "PeriodStart", "PeriodEnd") + .IsUnique() + .HasDatabaseName("ux_commission_settlements_referrer_period"); + + b.ToTable("commission_settlements", null, t => + { + t.HasCheckConstraint("ck_commission_settlements_amounts", "gross_amount_cents >= 0 and commission_amount_cents >= 0"); + + t.HasCheckConstraint("ck_commission_settlements_counts", "source_count >= 0 and paid_user_count >= 0"); + + t.HasCheckConstraint("ck_commission_settlements_period", "period_end >= period_start"); + + t.HasCheckConstraint("ck_commission_settlements_rates", "default_rate >= 0 and default_rate <= 1 and (effective_rate is null or (effective_rate >= 0 and effective_rate <= 1))"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.CommissionSettlementExportEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ContentSha256") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("content_sha256"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("ExportFormat") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("export_format"); + + b.Property("ExportedBy") + .HasColumnType("uuid") + .HasColumnName("exported_by"); + + b.Property("Filename") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("filename"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("RowCount") + .HasColumnType("integer") + .HasColumnName("row_count"); + + b.Property("SettlementId") + .HasColumnType("uuid") + .HasColumnName("settlement_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id") + .HasName("pk_commission_settlement_export_events"); + + b.HasIndex("ExportedBy") + .HasDatabaseName("ix_commission_settlement_export_events_exported_by"); + + b.HasIndex("TenantId", "SettlementId", "CreatedAt") + .HasDatabaseName("ix_commission_settlement_export_events_tenant_id_settlement_id~"); + + b.ToTable("commission_settlement_export_events", null, t => + { + t.HasCheckConstraint("ck_commission_export_events_rows", "row_count >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.CommissionSettlementItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AttributionType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("attribution_type"); + + b.Property("CommissionAmountCents") + .HasColumnType("integer") + .HasColumnName("commission_amount_cents"); + + b.Property("CommissionRate") + .HasPrecision(6, 4) + .HasColumnType("numeric(6,4)") + .HasColumnName("commission_rate"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("GrossAmountCents") + .HasColumnType("integer") + .HasColumnName("gross_amount_cents"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("RateSource") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("rate_source"); + + b.Property("ReferrerUserId") + .HasColumnType("uuid") + .HasColumnName("referrer_user_id"); + + b.Property("SettlementId") + .HasColumnType("uuid") + .HasColumnName("settlement_id"); + + b.Property("SourceId") + .HasColumnType("uuid") + .HasColumnName("source_id"); + + b.Property("SourceNo") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("source_no"); + + b.Property("SourcePaidAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("source_paid_at"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("source_type"); + + b.Property("StudentUserId") + .HasColumnType("uuid") + .HasColumnName("student_user_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_commission_settlement_items"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_commission_settlement_items_tenant_id_id"); + + b.HasIndex("ReferrerUserId") + .HasDatabaseName("ix_commission_settlement_items_referrer_user_id"); + + b.HasIndex("StudentUserId") + .HasDatabaseName("ix_commission_settlement_items_student_user_id"); + + b.HasIndex("TenantId", "SettlementId") + .HasDatabaseName("ix_commission_settlement_items_tenant_id_settlement_id"); + + b.HasIndex("TenantId", "ReferrerUserId", "SourcePaidAt") + .HasDatabaseName("ix_commission_settlement_items_tenant_id_referrer_user_id_sour~"); + + b.HasIndex("TenantId", "SourceType", "SourceId") + .IsUnique() + .HasDatabaseName("ix_commission_settlement_items_tenant_id_source_type_source_id"); + + b.ToTable("commission_settlement_items", null, t => + { + t.HasCheckConstraint("ck_commission_settlement_items_amounts", "gross_amount_cents >= 0 and commission_amount_cents >= 0"); + + t.HasCheckConstraint("ck_commission_settlement_items_rate", "commission_rate >= 0 and commission_rate <= 1"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.CommissionSettlementProof", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AmountCents") + .HasColumnType("integer") + .HasColumnName("amount_cents"); + + b.Property("AssetId") + .HasColumnType("uuid") + .HasColumnName("asset_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ExternalUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("external_url"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("PaidAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("paid_at"); + + b.Property("PaymentAccount") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("payment_account"); + + b.Property("PaymentMethod") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("payment_method"); + + b.Property("ProofType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("proof_type"); + + b.Property("ReviewNote") + .HasColumnType("text") + .HasColumnName("review_note"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("reviewed_at"); + + b.Property("ReviewedBy") + .HasColumnType("uuid") + .HasColumnName("reviewed_by"); + + b.Property("SettlementId") + .HasColumnType("uuid") + .HasColumnName("settlement_id"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("SubmittedBy") + .HasColumnType("uuid") + .HasColumnName("submitted_by"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_commission_settlement_proofs"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_commission_settlement_proofs_tenant_id_id"); + + b.HasIndex("ReviewedBy") + .HasDatabaseName("ix_commission_settlement_proofs_reviewed_by"); + + b.HasIndex("SubmittedBy") + .HasDatabaseName("ix_commission_settlement_proofs_submitted_by"); + + b.HasIndex("TenantId", "AssetId") + .HasDatabaseName("ix_commission_settlement_proofs_tenant_id_asset_id"); + + b.HasIndex("TenantId", "SettlementId", "Status", "CreatedAt") + .HasDatabaseName("ix_commission_settlement_proofs_tenant_id_settlement_id_status~"); + + b.ToTable("commission_settlement_proofs", null, t => + { + t.HasCheckConstraint("ck_commission_settlement_proofs_amount", "amount_cents is null or amount_cents >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.CrmConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AssignmentConfig") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("assignment_config") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("AssignmentCursor") + .HasColumnType("integer") + .HasColumnName("assignment_cursor"); + + b.Property("AssignmentMode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("assignment_mode"); + + b.Property("AssignmentPool") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("assignment_pool") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DelaySeconds") + .HasColumnType("integer") + .HasColumnName("delay_seconds"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasColumnName("enabled"); + + b.Property("ExamType") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("exam_type"); + + b.Property("FormName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("form_name"); + + b.Property("SecretRef") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("secret_ref"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer") + .HasColumnName("timeout_seconds"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("Url") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_crm_config"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_crm_config_tenant_id_id"); + + b.HasIndex("TenantId") + .IsUnique() + .HasDatabaseName("ix_crm_config_tenant_id"); + + b.HasIndex("TenantId", "AssignmentMode") + .HasDatabaseName("ix_crm_config_tenant_id_assignment_mode"); + + b.ToTable("crm_config", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.CrmWebhookLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Attempt") + .HasColumnType("integer") + .HasColumnName("attempt"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("ErrorMessage") + .HasColumnType("text") + .HasColumnName("error_message"); + + b.Property("HttpCode") + .HasColumnType("integer") + .HasColumnName("http_code"); + + b.Property("LeadId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("lead_id"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Outcome") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("outcome"); + + b.Property("RecordId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("record_id"); + + b.Property("RequestBody") + .HasColumnType("text") + .HasColumnName("request_body"); + + b.Property("RequestPayload") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("request_payload") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("ResponseSummary") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("response_summary"); + + b.Property("SignedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("signed_at"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_crm_webhook_log"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_crm_webhook_log_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_crm_webhook_log_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "RecordId", "CreatedAt") + .HasDatabaseName("ix_crm_webhook_log_tenant_id_record_id_created_at"); + + b.ToTable("crm_webhook_log", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.CrmWebhookQueueItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("IdempotencyKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("idempotency_key"); + + b.Property("LastAttemptAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_attempt_at"); + + b.Property("LastError") + .HasColumnType("text") + .HasColumnName("last_error"); + + b.Property("LastHttpCode") + .HasColumnType("integer") + .HasColumnName("last_http_code"); + + b.Property("LastResponseSummary") + .HasColumnType("text") + .HasColumnName("last_response_summary"); + + b.Property("LeadId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("lead_id"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("NextAttemptAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_attempt_at"); + + b.Property("Payload") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("payload") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Provider") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("provider"); + + b.Property("RecordId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("record_id"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("scheduled_at"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("sent_at"); + + b.Property("Source") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("source"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TargetUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("target_url"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_crm_webhook_queue"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_crm_webhook_queue_tenant_id_id"); + + b.HasIndex("TenantId", "IdempotencyKey") + .IsUnique() + .HasDatabaseName("ix_crm_webhook_queue_tenant_id_idempotency_key") + .HasFilter("idempotency_key is not null"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_crm_webhook_queue_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "RecordId") + .IsUnique() + .HasDatabaseName("ix_crm_webhook_queue_tenant_id_record_id") + .HasFilter("record_id is not null"); + + b.HasIndex("TenantId", "RecordId", "CreatedAt") + .HasDatabaseName("ix_crm_webhook_queue_tenant_id_record_id_created_at") + .HasFilter("record_id is not null"); + + b.HasIndex("TenantId", "Source", "CreatedAt") + .HasDatabaseName("ix_crm_webhook_queue_tenant_id_source_created_at") + .HasFilter("source = 'tenant.student.crm_push'"); + + b.HasIndex("TenantId", "Status", "ScheduledAt", "NextAttemptAt") + .HasDatabaseName("ix_crm_webhook_queue_tenant_id_status_scheduled_at_next_attemp~"); + + b.ToTable("crm_webhook_queue", null, t => + { + t.HasCheckConstraint("ck_crm_webhook_queue_attempts", "attempts >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.ReferralCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Channel") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("channel"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("LandingPath") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("landing_path"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_referral_codes"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_referral_codes_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_referral_codes_user_id"); + + b.HasIndex("TenantId", "Code") + .IsUnique() + .HasDatabaseName("ix_referral_codes_tenant_id_code"); + + b.HasIndex("TenantId", "UserId") + .IsUnique() + .HasDatabaseName("ix_referral_codes_tenant_id_user_id"); + + b.HasIndex("TenantId", "Code", "Status") + .HasDatabaseName("ix_referral_codes_tenant_id_code_status"); + + b.ToTable("referral_codes", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.ReferralLead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AssignedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("assigned_at"); + + b.Property("AssignedBy") + .HasColumnType("uuid") + .HasColumnName("assigned_by"); + + b.Property("AssignedToUserId") + .HasColumnType("uuid") + .HasColumnName("assigned_to_user_id"); + + b.Property("AssignmentMode") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("assignment_mode"); + + b.Property("BindType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("bind_type"); + + b.Property("BoundAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("bound_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("FirstTrackId") + .HasColumnType("uuid") + .HasColumnName("first_track_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("ProtectedUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("protected_until"); + + b.Property("RefCode") + .HasMaxLength(100) + .HasColumnType("citext") + .HasColumnName("ref_code"); + + b.Property("ReferrerUserId") + .HasColumnType("uuid") + .HasColumnName("referrer_user_id"); + + b.Property("Source") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("source"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("StudentUserId") + .HasColumnType("uuid") + .HasColumnName("student_user_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_referral_leads"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_referral_leads_tenant_id_id"); + + b.HasIndex("AssignedBy") + .HasDatabaseName("ix_referral_leads_assigned_by"); + + b.HasIndex("AssignedToUserId") + .HasDatabaseName("ix_referral_leads_assigned_to_user_id"); + + b.HasIndex("ReferrerUserId") + .HasDatabaseName("ix_referral_leads_referrer_user_id"); + + b.HasIndex("StudentUserId") + .HasDatabaseName("ix_referral_leads_student_user_id"); + + b.HasIndex("TenantId", "FirstTrackId") + .HasDatabaseName("ix_referral_leads_tenant_id_first_track_id"); + + b.HasIndex("TenantId", "StudentUserId") + .IsUnique() + .HasDatabaseName("ix_referral_leads_tenant_id_student_user_id"); + + b.HasIndex("TenantId", "ReferrerUserId", "BoundAt") + .HasDatabaseName("ix_referral_leads_tenant_id_referrer_user_id_bound_at"); + + b.HasIndex("TenantId", "AssignedToUserId", "Status", "BoundAt") + .HasDatabaseName("ix_referral_leads_tenant_id_assigned_to_user_id_status_bound_at"); + + b.ToTable("referral_leads", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.ReferralQrcode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("ErrorMessage") + .HasColumnType("text") + .HasColumnName("error_message"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Page") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("page"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("provider"); + + b.Property("QrcodeUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("qrcode_url"); + + b.Property("RefCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext") + .HasColumnName("ref_code"); + + b.Property("Scene") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("scene"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_referral_qrcodes"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_referral_qrcodes_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_referral_qrcodes_user_id"); + + b.HasIndex("TenantId", "RefCode", "Status") + .HasDatabaseName("ix_referral_qrcodes_tenant_id_ref_code_status"); + + b.HasIndex("TenantId", "Provider", "Scene", "Page") + .IsUnique() + .HasDatabaseName("ix_referral_qrcodes_tenant_id_provider_scene_page"); + + b.ToTable("referral_qrcodes", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.ReferralTeamEdge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("LeaderUserId") + .HasColumnType("uuid") + .HasColumnName("leader_user_id"); + + b.Property("MemberUserId") + .HasColumnType("uuid") + .HasColumnName("member_user_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("RelationType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("relation_type"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_referral_team_edges"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_referral_team_edges_tenant_id_id"); + + b.HasIndex("LeaderUserId") + .HasDatabaseName("ix_referral_team_edges_leader_user_id"); + + b.HasIndex("MemberUserId") + .HasDatabaseName("ix_referral_team_edges_member_user_id"); + + b.HasIndex("TenantId", "LeaderUserId", "Status") + .HasDatabaseName("ix_referral_team_edges_tenant_id_leader_user_id_status"); + + b.HasIndex("TenantId", "MemberUserId", "RelationType") + .IsUnique() + .HasDatabaseName("ix_referral_team_edges_tenant_id_member_user_id_relation_type"); + + b.ToTable("referral_team_edges", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.ReferralTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("event_type"); + + b.Property("IpAddress") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("ip_address"); + + b.Property("LeadId") + .HasColumnType("uuid") + .HasColumnName("lead_id"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("RefCode") + .HasMaxLength(100) + .HasColumnType("citext") + .HasColumnName("ref_code"); + + b.Property("ReferrerUserId") + .HasColumnType("uuid") + .HasColumnName("referrer_user_id"); + + b.Property("Source") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("source"); + + b.Property("TargetUserId") + .HasColumnType("uuid") + .HasColumnName("target_user_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserAgent") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("user_agent"); + + b.HasKey("Id") + .HasName("pk_referral_tracks"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_referral_tracks_tenant_id_id"); + + b.HasIndex("ReferrerUserId") + .HasDatabaseName("ix_referral_tracks_referrer_user_id"); + + b.HasIndex("TargetUserId") + .HasDatabaseName("ix_referral_tracks_target_user_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_referral_tracks_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "LeadId", "CreatedAt") + .HasDatabaseName("ix_referral_tracks_tenant_id_lead_id_created_at"); + + b.HasIndex("TenantId", "RefCode", "EventType", "CreatedAt") + .HasDatabaseName("ix_referral_tracks_tenant_id_ref_code_event_type_created_at"); + + b.ToTable("referral_tracks", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.TenantCommissionSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Config") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("config") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DefaultRate") + .HasPrecision(6, 4) + .HasColumnType("numeric(6,4)") + .HasColumnName("default_rate"); + + b.Property("MinSettlementCents") + .HasColumnType("integer") + .HasColumnName("min_settlement_cents"); + + b.Property("SettlementCycle") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("settlement_cycle"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_tenant_commission_settings"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_commission_settings_tenant_id_id"); + + b.HasIndex("TenantId") + .IsUnique() + .HasDatabaseName("ix_tenant_commission_settings_tenant_id"); + + b.HasIndex("UpdatedBy") + .HasDatabaseName("ix_tenant_commission_settings_updated_by"); + + b.ToTable("tenant_commission_settings", null, t => + { + t.HasCheckConstraint("ck_tenant_commission_settings_default_rate", "default_rate >= 0 and default_rate <= 1"); + + t.HasCheckConstraint("ck_tenant_commission_settings_min_settlement", "min_settlement_cents >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Identity.StudentProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AvatarPreset") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasDefaultValue("male") + .HasColumnName("avatar_preset"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("LastCheckInDate") + .HasColumnType("date") + .HasColumnName("last_check_in_date"); + + b.Property("LegacyUserId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_user_id"); + + b.Property("MasteredWordsCount") + .HasColumnType("integer") + .HasColumnName("mastered_words_count"); + + b.Property("ModuleSelections") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("module_selections") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Progress") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("progress") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("QuestionsAnsweredToday") + .HasColumnType("integer") + .HasColumnName("questions_answered_today"); + + b.Property("RecentActivities") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("recent_activities") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SelectedMajorId") + .HasColumnType("uuid") + .HasColumnName("selected_major_id"); + + b.Property("SelectedSchoolId") + .HasColumnType("uuid") + .HasColumnName("selected_school_id"); + + b.Property("Stats") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("stats") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_student_profiles"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_student_profiles_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_student_profiles_user_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_student_profiles_tenant_id_region_id"); + + b.HasIndex("TenantId", "SelectedMajorId") + .HasDatabaseName("ix_student_profiles_tenant_id_selected_major_id"); + + b.HasIndex("TenantId", "SelectedSchoolId") + .HasDatabaseName("ix_student_profiles_tenant_id_selected_school_id"); + + b.HasIndex("TenantId", "UserId") + .IsUnique() + .HasDatabaseName("ix_student_profiles_tenant_id_user_id"); + + b.ToTable("student_profiles", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AccessFailedCount") + .HasColumnType("integer") + .HasColumnName("access_failed_count"); + + b.Property("AvatarUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("avatar_url"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("concurrency_stamp"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Email") + .HasMaxLength(320) + .HasColumnType("citext") + .HasColumnName("email"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean") + .HasColumnName("email_confirmed"); + + b.Property("ForcePasswordChange") + .HasColumnType("boolean") + .HasColumnName("force_password_change"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_at"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean") + .HasColumnName("lockout_enabled"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockout_end"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("NormalizedEmail") + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("normalized_email"); + + b.Property("NormalizedUserName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("normalized_user_name"); + + b.Property("PasswordHash") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("password_hash"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phone"); + + b.Property("PhoneNumber") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phone_number"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean") + .HasColumnName("phone_number_confirmed"); + + b.Property("PrimaryRole") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("primary_role"); + + b.Property("RawProfile") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("raw_profile") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Score") + .HasColumnType("integer") + .HasColumnName("score"); + + b.Property("SecurityStamp") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("security_stamp"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean") + .HasColumnName("two_factor_enabled"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("LegacyId") + .IsUnique() + .HasDatabaseName("ix_users_legacy_id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("email_index"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("user_name_index"); + + b.HasIndex("Phone") + .IsUnique() + .HasDatabaseName("ix_users_phone"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Identity.UserIdentity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Email") + .HasMaxLength(320) + .HasColumnType("citext") + .HasColumnName("email"); + + b.Property("OpenId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("open_id"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phone"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("provider"); + + b.Property("ProviderSubject") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("provider_subject"); + + b.Property("UnionId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("union_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_identities"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_identities_user_id"); + + b.HasIndex("Provider", "ProviderSubject") + .IsUnique() + .HasDatabaseName("ix_user_identities_provider_provider_subject"); + + b.ToTable("user_identities", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Import.PocketBaseImportIssue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CollectionName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("collection_name"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("FieldPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("field_path"); + + b.Property("IssueCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("issue_code"); + + b.Property("LegacyId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("legacy_id"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("RawValueSample") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("raw_value_sample"); + + b.Property("RunId") + .HasColumnType("uuid") + .HasColumnName("run_id"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("severity"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id") + .HasName("pk_pb_import_issues"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_pb_import_issues_tenant_id_id"); + + b.HasIndex("TenantId", "RunId") + .HasDatabaseName("ix_pb_import_issues_tenant_id_run_id"); + + b.HasIndex("RunId", "Severity", "CollectionName") + .HasDatabaseName("ix_pb_import_issues_run_id_severity_collection_name"); + + b.HasIndex("TenantId", "CollectionName", "LegacyId") + .HasDatabaseName("ix_pb_import_issues_tenant_id_collection_name_legacy_id"); + + b.ToTable("pb_import_issues", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Import.PocketBaseImportRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("SourceKind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("source_kind"); + + b.Property("SourceName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("source_name"); + + b.Property("StartedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at") + .HasDefaultValueSql("now()"); + + b.Property("Stats") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("stats") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id") + .HasName("pk_pb_import_runs"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_pb_import_runs_tenant_id_id"); + + b.HasIndex("TenantId", "Status", "StartedAt") + .HasDatabaseName("ix_pb_import_runs_tenant_id_status_started_at"); + + b.ToTable("pb_import_runs", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Import.PocketBaseRawRecord", b => + { + b.Property("RunId") + .HasColumnType("uuid") + .HasColumnName("run_id"); + + b.Property("CollectionName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("collection_name"); + + b.Property("LegacyId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("legacy_id"); + + b.Property("Errors") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("errors") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("ImportedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("imported_at") + .HasDefaultValueSql("now()"); + + b.Property("Normalized") + .HasColumnType("boolean") + .HasColumnName("normalized"); + + b.Property("Record") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("record") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("RunId", "CollectionName", "LegacyId") + .HasName("pk_pb_raw_records"); + + b.HasIndex("TenantId", "RunId") + .HasDatabaseName("ix_pb_raw_records_tenant_id_run_id"); + + b.HasIndex("TenantId", "CollectionName", "LegacyId") + .HasDatabaseName("ix_pb_raw_records_tenant_id_collection_name_legacy_id"); + + b.ToTable("pb_raw_records", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.AnswerRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AnswerText") + .HasColumnType("text") + .HasColumnName("answer_text"); + + b.Property("AnsweredAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("answered_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("IsCorrect") + .HasColumnType("boolean") + .HasColumnName("is_correct"); + + b.Property("LegacyCategoryId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_category_id"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("LegacyQuestionId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_question_id"); + + b.Property("PracticeSessionId") + .HasColumnType("uuid") + .HasColumnName("practice_session_id"); + + b.Property("SelectedOptions") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("selected_options") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("SessionQuestionId") + .HasColumnType("uuid") + .HasColumnName("session_question_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_answer_records"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_answer_records_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_answer_records_user_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_answer_records_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "PracticeSessionId", "SessionQuestionId") + .HasDatabaseName("ix_answer_records_tenant_id_practice_session_id_session_questi~"); + + b.HasIndex("TenantId", "UserId", "PracticeSessionId") + .HasDatabaseName("ix_answer_records_tenant_id_user_id_practice_session_id"); + + b.ToTable("answer_records", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.DashboardDailyStat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ActiveUsers") + .HasColumnType("integer") + .HasColumnName("active_users"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("LegacyRegionId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_region_id"); + + b.Property("NewOrders") + .HasColumnType("integer") + .HasColumnName("new_orders"); + + b.Property("NewQuestions") + .HasColumnType("integer") + .HasColumnName("new_questions"); + + b.Property("NewRevenueCents") + .HasColumnType("integer") + .HasColumnName("new_revenue_cents"); + + b.Property("NewUsers") + .HasColumnType("integer") + .HasColumnName("new_users"); + + b.Property("RebuiltAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("rebuilt_at"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("StatDate") + .HasColumnType("date") + .HasColumnName("stat_date"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_dashboard_daily_stats"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_dashboard_daily_stats_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_dashboard_daily_stats_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_dashboard_daily_stats_tenant_id_region_id"); + + b.HasIndex("TenantId", "StatDate", "RegionId") + .IsUnique() + .HasDatabaseName("ix_dashboard_daily_stats_tenant_id_stat_date_region_id"); + + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("TenantId", "StatDate", "RegionId"), false); + + b.ToTable("dashboard_daily_stats", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.ExamDate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ExamAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("exam_at"); + + b.Property("ExamName") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("exam_name"); + + b.Property("ExamType") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("exam_type"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SchoolId") + .HasColumnType("uuid") + .HasColumnName("school_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_exam_dates"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_exam_dates_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_exam_dates_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_exam_dates_tenant_id_region_id"); + + b.HasIndex("TenantId", "SchoolId", "ExamAt") + .HasDatabaseName("ix_exam_dates_tenant_id_school_id_exam_at"); + + b.ToTable("exam_dates", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.FavoriteQuestion", b => + { + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("QuestionReferenceId") + .HasColumnType("uuid") + .HasColumnName("question_reference_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("QuestionId") + .HasColumnType("uuid") + .HasColumnName("question_id"); + + b.Property("QuestionOwnerTenantId") + .HasColumnType("uuid") + .HasColumnName("question_owner_tenant_id"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("source"); + + b.HasKey("TenantId", "UserId", "QuestionReferenceId") + .HasName("pk_favorite_questions"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_favorite_questions_user_id"); + + b.HasIndex("QuestionOwnerTenantId", "QuestionId") + .HasDatabaseName("ix_favorite_questions_question_owner_tenant_id_question_id"); + + b.HasIndex("TenantId", "QuestionReferenceId") + .HasDatabaseName("ix_favorite_questions_tenant_id_question_reference_id"); + + b.ToTable("favorite_questions", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.PracticeAccessEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AccessMode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("access_mode"); + + b.Property("ConsumedFreeQuota") + .HasColumnType("integer") + .HasColumnName("consumed_free_quota"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("EntitlementId") + .HasColumnType("uuid") + .HasColumnName("entitlement_id"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("event_type"); + + b.Property("GrantedCount") + .HasColumnType("integer") + .HasColumnName("granted_count"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("PracticeSessionId") + .HasColumnType("uuid") + .HasColumnName("practice_session_id"); + + b.Property("Reason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("reason"); + + b.Property("RequestedCount") + .HasColumnType("integer") + .HasColumnName("requested_count"); + + b.Property("ScopeId") + .HasColumnType("uuid") + .HasColumnName("scope_id"); + + b.Property("ScopeType") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("scope_type"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_practice_access_events"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_practice_access_events_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_practice_access_events_user_id"); + + b.HasIndex("TenantId", "PracticeSessionId") + .HasDatabaseName("ix_practice_access_events_tenant_id_practice_session_id"); + + b.HasIndex("TenantId", "UserId", "CreatedAt") + .HasDatabaseName("ix_practice_access_events_tenant_id_user_id_created_at"); + + b.ToTable("practice_access_events", null, t => + { + t.HasCheckConstraint("ck_practice_access_events_consumed_free_quota", "consumed_free_quota >= 0"); + + t.HasCheckConstraint("ck_practice_access_events_granted_count", "granted_count >= 0"); + + t.HasCheckConstraint("ck_practice_access_events_requested_count", "requested_count >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.PracticeDailyUsage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("FreeLimit") + .HasColumnType("integer") + .HasColumnName("free_limit"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("ScopeId") + .HasColumnType("uuid") + .HasColumnName("scope_id"); + + b.Property("ScopeType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("scope_type"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UsageDate") + .ValueGeneratedOnAdd() + .HasColumnType("date") + .HasColumnName("usage_date") + .HasDefaultValueSql("current_date"); + + b.Property("UsedCount") + .HasColumnType("integer") + .HasColumnName("used_count"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_practice_daily_usage"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_practice_daily_usage_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_practice_daily_usage_user_id"); + + b.HasIndex("TenantId", "UserId", "UsageDate") + .HasDatabaseName("ix_practice_daily_usage_tenant_id_user_id_usage_date"); + + b.HasIndex("TenantId", "UserId", "UsageDate", "ScopeType", "ScopeId") + .IsUnique() + .HasDatabaseName("ix_practice_daily_usage_tenant_id_user_id_usage_date_scope_typ~"); + + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("TenantId", "UserId", "UsageDate", "ScopeType", "ScopeId"), false); + + b.ToTable("practice_daily_usage", null, t => + { + t.HasCheckConstraint("ck_practice_daily_usage_free_limit", "free_limit >= 0"); + + t.HasCheckConstraint("ck_practice_daily_usage_used_count", "used_count >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.PracticeSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AccessEntitlementId") + .HasColumnType("uuid") + .HasColumnName("access_entitlement_id"); + + b.Property("AccessMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasDefaultValue("free") + .HasColumnName("access_mode"); + + b.Property("AccessSnapshot") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("access_snapshot") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("BlueprintId") + .HasColumnType("uuid") + .HasColumnName("blueprint_id"); + + b.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("ConsumedFreeQuota") + .HasColumnType("integer") + .HasColumnName("consumed_free_quota"); + + b.Property("ContentNodeId") + .HasColumnType("uuid") + .HasColumnName("content_node_id"); + + b.Property("DurationMinutes") + .HasColumnType("integer") + .HasColumnName("duration_minutes"); + + b.Property("EntryId") + .HasColumnType("uuid") + .HasColumnName("entry_id"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("finished_at"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("mode"); + + b.Property("QuestionCount") + .HasColumnType("integer") + .HasColumnName("question_count"); + + b.Property("StartedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at") + .HasDefaultValueSql("now()"); + + b.Property("TargetId") + .HasColumnType("uuid") + .HasColumnName("target_id"); + + b.Property("TargetType") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("target_type"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TotalScore") + .HasPrecision(8, 2) + .HasColumnType("numeric(8,2)") + .HasColumnName("total_score"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_practice_sessions"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_practice_sessions_tenant_id_id"); + + b.HasAlternateKey("TenantId", "UserId", "Id") + .HasName("ak_practice_sessions_tenant_id_user_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_practice_sessions_user_id"); + + b.HasIndex("TenantId", "BlueprintId") + .HasDatabaseName("ix_practice_sessions_tenant_id_blueprint_id"); + + b.HasIndex("TenantId", "CollectionId") + .HasDatabaseName("ix_practice_sessions_tenant_id_collection_id"); + + b.HasIndex("TenantId", "ContentNodeId") + .HasDatabaseName("ix_practice_sessions_tenant_id_content_node_id"); + + b.HasIndex("TenantId", "EntryId") + .HasDatabaseName("ix_practice_sessions_tenant_id_entry_id"); + + b.HasIndex("TenantId", "UserId", "StartedAt") + .HasDatabaseName("ix_practice_sessions_tenant_id_user_id_started_at"); + + b.ToTable("practice_sessions", null, t => + { + t.HasCheckConstraint("ck_practice_sessions_consumed_free_quota", "consumed_free_quota >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.PracticeSessionQuestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Position") + .HasColumnType("integer") + .HasColumnName("position"); + + b.Property("PracticeSessionId") + .HasColumnType("uuid") + .HasColumnName("practice_session_id"); + + b.Property("QuestionId") + .HasColumnType("uuid") + .HasColumnName("question_id"); + + b.Property("QuestionOwnerTenantId") + .HasColumnType("uuid") + .HasColumnName("question_owner_tenant_id"); + + b.Property("QuestionReferenceId") + .HasColumnType("uuid") + .HasColumnName("question_reference_id"); + + b.Property("QuestionVersionId") + .HasColumnType("uuid") + .HasColumnName("question_version_id"); + + b.Property("Score") + .HasPrecision(8, 2) + .HasColumnType("numeric(8,2)") + .HasColumnName("score"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id") + .HasName("pk_practice_session_questions"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_practice_session_questions_tenant_id_id"); + + b.HasAlternateKey("TenantId", "PracticeSessionId", "Id") + .HasName("ak_practice_session_questions_tenant_id_practice_session_id_id"); + + b.HasIndex("QuestionOwnerTenantId", "QuestionId", "QuestionVersionId") + .HasDatabaseName("ix_practice_session_questions_question_owner_tenant_id_questio~"); + + b.HasIndex("TenantId", "PracticeSessionId", "Position") + .IsUnique() + .HasDatabaseName("ix_practice_session_questions_tenant_id_practice_session_id_po~"); + + b.HasIndex("TenantId", "QuestionOwnerTenantId", "QuestionId") + .HasDatabaseName("ix_practice_session_questions_tenant_id_question_owner_tenant_~"); + + b.ToTable("practice_session_questions", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.PracticeSessionReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Accuracy") + .HasPrecision(6, 4) + .HasColumnType("numeric(6,4)") + .HasColumnName("accuracy"); + + b.Property("AnsweredCount") + .HasColumnType("integer") + .HasColumnName("answered_count"); + + b.Property("BlueprintId") + .HasColumnType("uuid") + .HasColumnName("blueprint_id"); + + b.Property("CollectionId") + .HasColumnType("uuid") + .HasColumnName("collection_id"); + + b.Property("CorrectCount") + .HasColumnType("integer") + .HasColumnName("correct_count"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DurationSeconds") + .HasColumnType("integer") + .HasColumnName("duration_seconds"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("mode"); + + b.Property("PracticeSessionId") + .HasColumnType("uuid") + .HasColumnName("practice_session_id"); + + b.Property("QuestionResults") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("question_results") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("Score") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)") + .HasColumnName("score"); + + b.Property("SectionStats") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("section_stats") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("SubmittedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("submitted_at") + .HasDefaultValueSql("now()"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TotalQuestions") + .HasColumnType("integer") + .HasColumnName("total_questions"); + + b.Property("TotalScore") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)") + .HasColumnName("total_score"); + + b.Property("UnansweredCount") + .HasColumnType("integer") + .HasColumnName("unanswered_count"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("WrongCount") + .HasColumnType("integer") + .HasColumnName("wrong_count"); + + b.Property("WrongQuestionIds") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("wrong_question_ids") + .HasDefaultValueSql("'[]'::jsonb"); + + b.HasKey("Id") + .HasName("pk_practice_session_reports"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_practice_session_reports_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_practice_session_reports_user_id"); + + b.HasIndex("TenantId", "CollectionId") + .HasDatabaseName("ix_practice_session_reports_tenant_id_collection_id"); + + b.HasIndex("TenantId", "PracticeSessionId") + .IsUnique() + .HasDatabaseName("ix_practice_session_reports_tenant_id_practice_session_id"); + + b.HasIndex("TenantId", "BlueprintId", "SubmittedAt") + .HasDatabaseName("ix_practice_session_reports_tenant_id_blueprint_id_submitted_at") + .HasFilter("blueprint_id is not null"); + + b.HasIndex("TenantId", "UserId", "SubmittedAt") + .HasDatabaseName("ix_practice_session_reports_tenant_id_user_id_submitted_at"); + + b.ToTable("practice_session_reports", null, t => + { + t.HasCheckConstraint("ck_practice_session_reports_accuracy", "accuracy >= 0 and accuracy <= 1"); + + t.HasCheckConstraint("ck_practice_session_reports_counts", "total_questions >= 0 and answered_count >= 0 and correct_count >= 0 and wrong_count >= 0 and unanswered_count >= 0"); + + t.HasCheckConstraint("ck_practice_session_reports_duration", "duration_seconds >= 0"); + + t.HasCheckConstraint("ck_practice_session_reports_scores", "score >= 0 and total_score >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.PracticeSessionReportSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Accuracy") + .HasPrecision(6, 4) + .HasColumnType("numeric(6,4)") + .HasColumnName("accuracy"); + + b.Property("AnsweredCount") + .HasColumnType("integer") + .HasColumnName("answered_count"); + + b.Property("CorrectCount") + .HasColumnType("integer") + .HasColumnName("correct_count"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("PracticeSessionId") + .HasColumnType("uuid") + .HasColumnName("practice_session_id"); + + b.Property("QuestionCount") + .HasColumnType("integer") + .HasColumnName("question_count"); + + b.Property("QuestionType") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("question_type"); + + b.Property("ReportId") + .HasColumnType("uuid") + .HasColumnName("report_id"); + + b.Property("Score") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)") + .HasColumnName("score"); + + b.Property("SectionKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("section_key"); + + b.Property("SectionName") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("section_name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TotalScore") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)") + .HasColumnName("total_score"); + + b.Property("UnansweredCount") + .HasColumnType("integer") + .HasColumnName("unanswered_count"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("WrongCount") + .HasColumnType("integer") + .HasColumnName("wrong_count"); + + b.HasKey("Id") + .HasName("pk_practice_session_report_sections"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_practice_session_report_sections_tenant_id_id"); + + b.HasIndex("TenantId", "PracticeSessionId") + .HasDatabaseName("ix_practice_session_report_sections_tenant_id_practice_session~"); + + b.HasIndex("TenantId", "ReportId", "SectionKey") + .IsUnique() + .HasDatabaseName("ix_practice_session_report_sections_tenant_id_report_id_sectio~"); + + b.HasIndex("TenantId", "ReportId", "SortOrder") + .HasDatabaseName("ix_practice_session_report_sections_tenant_id_report_id_sort_o~"); + + b.ToTable("practice_session_report_sections", null, t => + { + t.HasCheckConstraint("ck_practice_session_report_sections_accuracy", "accuracy >= 0 and accuracy <= 1"); + + t.HasCheckConstraint("ck_practice_session_report_sections_counts", "question_count >= 0 and answered_count >= 0 and correct_count >= 0 and wrong_count >= 0 and unanswered_count >= 0"); + + t.HasCheckConstraint("ck_practice_session_report_sections_scores", "score >= 0 and total_score >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.RecentPractice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Color") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("color"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("LastAccessAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_access_at"); + + b.Property("LastPracticeAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_practice_at"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("PracticeType") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("practice_type"); + + b.Property("Progress") + .HasColumnType("integer") + .HasColumnName("progress"); + + b.Property("TargetLegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("target_legacy_id"); + + b.Property("TargetName") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("target_name"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_recent_practices"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_recent_practices_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_recent_practices_user_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_recent_practices_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "UserId", "LastAccessAt") + .HasDatabaseName("ix_recent_practices_tenant_id_user_id_last_access_at"); + + b.ToTable("recent_practices", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.Report", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Attachments") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("attachments") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("category"); + + b.Property("Contact") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("contact"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("HandledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("handled_at"); + + b.Property("HandledBy") + .HasColumnType("uuid") + .HasColumnName("handled_by"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("priority"); + + b.Property("QuestionId") + .HasColumnType("uuid") + .HasColumnName("question_id"); + + b.Property("Resolution") + .HasColumnType("text") + .HasColumnName("resolution"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("title"); + + b.Property("Type") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_reports"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_reports_tenant_id_id"); + + b.HasIndex("HandledBy") + .HasDatabaseName("ix_reports_handled_by"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_reports_user_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_reports_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "QuestionId", "CreatedAt") + .HasDatabaseName("ix_reports_tenant_id_question_id_created_at") + .HasFilter("question_id is not null"); + + b.HasIndex("TenantId", "Status", "CreatedAt") + .HasDatabaseName("ix_reports_tenant_id_status_created_at"); + + b.ToTable("reports", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.ReportStatusEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("FromStatus") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("from_status"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Note") + .HasColumnType("text") + .HasColumnName("note"); + + b.Property("ReportId") + .HasColumnType("uuid") + .HasColumnName("report_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("ToStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("to_status"); + + b.HasKey("Id") + .HasName("pk_report_status_events"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_report_status_events_tenant_id_id"); + + b.HasIndex("ActorUserId") + .HasDatabaseName("ix_report_status_events_actor_user_id"); + + b.HasIndex("TenantId", "ReportId", "CreatedAt") + .HasDatabaseName("ix_report_status_events_tenant_id_report_id_created_at"); + + b.ToTable("report_status_events", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.RevenueDailyStat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CodeCount") + .HasColumnType("integer") + .HasColumnName("code_count"); + + b.Property("CodeEstimated") + .HasColumnType("integer") + .HasColumnName("code_estimated"); + + b.Property("CodeUsed") + .HasColumnType("integer") + .HasColumnName("code_used"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("EstimatedRevenueCents") + .HasColumnType("integer") + .HasColumnName("estimated_revenue_cents"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("LegacyRegionId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_region_id"); + + b.Property("OrderCount") + .HasColumnType("integer") + .HasColumnName("order_count"); + + b.Property("RealRevenueCents") + .HasColumnType("integer") + .HasColumnName("real_revenue_cents"); + + b.Property("RebuiltAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("rebuilt_at"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SaleType") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("sale_type"); + + b.Property("StatDate") + .HasColumnType("date") + .HasColumnName("stat_date"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_revenue_daily_stats"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_revenue_daily_stats_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_revenue_daily_stats_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_revenue_daily_stats_tenant_id_region_id"); + + b.HasIndex("TenantId", "StatDate", "RegionId", "SaleType") + .IsUnique() + .HasDatabaseName("ix_revenue_daily_stats_tenant_id_stat_date_region_id_sale_type"); + + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("TenantId", "StatDate", "RegionId", "SaleType"), false); + + b.ToTable("revenue_daily_stats", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.UserScoreEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BalanceAfter") + .HasColumnType("integer") + .HasColumnName("balance_after"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("event_type"); + + b.Property("IdempotencyKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("idempotency_key"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Points") + .HasColumnType("integer") + .HasColumnName("points"); + + b.Property("SourceId") + .HasColumnType("uuid") + .HasColumnName("source_id"); + + b.Property("SourceType") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("source_type"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_score_events"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_user_score_events_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_score_events_user_id"); + + b.HasIndex("TenantId", "IdempotencyKey") + .IsUnique() + .HasDatabaseName("ix_user_score_events_tenant_id_idempotency_key"); + + b.HasIndex("TenantId", "UserId", "CreatedAt") + .HasDatabaseName("ix_user_score_events_tenant_id_user_id_created_at"); + + b.ToTable("user_score_events", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.WrongQuestion", b => + { + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("QuestionReferenceId") + .HasColumnType("uuid") + .HasColumnName("question_reference_id"); + + b.Property("LastWrongAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("last_wrong_at") + .HasDefaultValueSql("now()"); + + b.Property("QuestionId") + .HasColumnType("uuid") + .HasColumnName("question_id"); + + b.Property("QuestionOwnerTenantId") + .HasColumnType("uuid") + .HasColumnName("question_owner_tenant_id"); + + b.Property("ResolvedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("resolved_at"); + + b.Property("WrongCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1) + .HasColumnName("wrong_count"); + + b.HasKey("TenantId", "UserId", "QuestionReferenceId") + .HasName("pk_wrong_questions"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_wrong_questions_user_id"); + + b.HasIndex("QuestionOwnerTenantId", "QuestionId") + .HasDatabaseName("ix_wrong_questions_question_owner_tenant_id_question_id"); + + b.HasIndex("TenantId", "QuestionReferenceId") + .HasDatabaseName("ix_wrong_questions_tenant_id_question_reference_id"); + + b.ToTable("wrong_questions", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.Announcement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BackgroundColor") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("background_color"); + + b.Property("Content") + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Link") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("link"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_announcements"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_announcements_tenant_id_id"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_announcements_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "IsActive", "SortOrder") + .HasDatabaseName("ix_announcements_tenant_id_is_active_sort_order"); + + b.ToTable("announcements", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("action"); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Details") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("details") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("IpAddress") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("ip_address"); + + b.Property("TargetId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("target_id"); + + b.Property("TargetType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("target_type"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UserAgent") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("user_agent"); + + b.HasKey("Id") + .HasName("pk_audit_logs"); + + b.HasIndex("ActorUserId") + .HasDatabaseName("ix_audit_logs_actor_user_id"); + + b.HasIndex("TenantId", "CreatedAt") + .HasDatabaseName("ix_audit_logs_tenant_id_created_at"); + + b.HasIndex("TenantId", "TargetType", "TargetId", "CreatedAt") + .HasDatabaseName("ix_audit_logs_tenant_id_target_type_target_id_created_at"); + + b.ToTable("audit_logs", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.BackendMenu", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Area") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("area"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Icon") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("icon"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ParentCode") + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("parent_code"); + + b.Property("Path") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("path"); + + b.Property("PermissionCode") + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("permission_code"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_backend_menus"); + + b.HasAlternateKey("Code") + .HasName("ak_backend_menus_code"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_backend_menus_code"); + + b.HasIndex("PermissionCode") + .HasDatabaseName("ix_backend_menus_permission_code"); + + b.HasIndex("Area", "ParentCode", "SortOrder") + .HasDatabaseName("ix_backend_menus_area_parent_code_sort_order"); + + b.ToTable("backend_menus", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.BackendPermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Area") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("area"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("character varying(160)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsSystem") + .HasColumnType("boolean") + .HasColumnName("is_system"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("PermissionModuleCode") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("permission_module_code"); + + 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("PermissionModuleCode") + .HasDatabaseName("ix_backend_permissions_permission_module_code"); + + b.HasIndex("Area", "PermissionModuleCode", "SortOrder") + .HasDatabaseName("ix_backend_permissions_area_permission_module_code_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.PermissionModule", 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(120) + .HasColumnType("character varying(120)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RequiredFeatureCode") + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("required_feature_code"); + + 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_permission_modules"); + + b.HasAlternateKey("Code") + .HasName("ak_permission_modules_code"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_permission_modules_code"); + + b.HasIndex("RequiredFeatureCode") + .HasDatabaseName("ix_permission_modules_required_feature_code"); + + b.HasIndex("Area", "SortOrder") + .HasDatabaseName("ix_permission_modules_area_sort_order"); + + b.ToTable("permission_modules", (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.PlatformBillingDunningNotificationChannel", 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_billing_dunning_notification_channels"); + + b.HasIndex("ChannelCode") + .IsUnique() + .HasDatabaseName("ix_platform_billing_dunning_notification_channels_channel_code"); + + b.HasIndex("Enabled", "MinReminderLevel", "ChannelCode") + .HasDatabaseName("ix_platform_billing_dunning_notification_channels_enabled_min_~"); + + b.ToTable("platform_billing_dunning_notification_channels", null, t => + { + t.HasCheckConstraint("ck_platform_billing_dunning_channels_level", "min_reminder_level between 1 and 20"); + + t.HasCheckConstraint("ck_platform_billing_dunning_channels_timeout", "timeout_seconds between 1 and 60"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformBillingDunningNotificationEvent", 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_billing_dunning_notification_events"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_platform_billing_dunning_notification_events_tenant_id_id"); + + b.HasIndex("ChannelId") + .HasDatabaseName("ix_platform_billing_dunning_notification_events_channel_id"); + + b.HasIndex("TenantId", "InvoiceId") + .HasDatabaseName("ix_platform_billing_dunning_notification_events_tenant_id_invo~"); + + b.HasIndex("TenantId", "ReminderId") + .HasDatabaseName("ix_platform_billing_dunning_notification_events_tenant_id_remi~"); + + b.HasIndex("InvoiceId", "Status", "CreatedAt") + .HasDatabaseName("ix_platform_billing_dunning_notification_events_invoice_id_sta~"); + + b.HasIndex("ReminderId", "Status", "CreatedAt") + .HasDatabaseName("ix_platform_billing_dunning_notification_events_reminder_id_st~"); + + b.HasIndex("TenantId", "ChannelId", "ReminderId") + .IsUnique() + .HasDatabaseName("ix_platform_billing_dunning_notification_events_tenant_id_chan~"); + + b.HasIndex("TenantId", "Status", "CreatedAt") + .HasDatabaseName("ix_platform_billing_dunning_notification_events_tenant_id_stat~"); + + b.HasIndex("Status", "NextAttemptAt", "ScheduledAt", "CreatedAt") + .HasDatabaseName("ix_platform_billing_dunning_notification_events_status_next_at~"); + + b.ToTable("platform_billing_dunning_notification_events", null, t => + { + t.HasCheckConstraint("ck_platform_billing_dunning_events_attempts", "attempts >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformBillingInvoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BillingProfileSnapshot") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("billing_profile_snapshot") + .HasDefaultValueSql("'{}'::jsonb"); + + 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("DueDate") + .HasColumnType("date") + .HasColumnName("due_date"); + + b.Property("InvoiceNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("invoice_no"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("issued_at"); + + b.Property("OrderId") + .HasColumnType("uuid") + .HasColumnName("order_id"); + + b.Property("PaidAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("paid_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TotalAmountCents") + .HasColumnType("integer") + .HasColumnName("total_amount_cents"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_platform_billing_invoices"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_platform_billing_invoices_tenant_id_id"); + + b.HasIndex("TenantId", "InvoiceNo") + .IsUnique() + .HasDatabaseName("ix_platform_billing_invoices_tenant_id_invoice_no"); + + b.HasIndex("TenantId", "OrderId") + .HasDatabaseName("ix_platform_billing_invoices_tenant_id_order_id"); + + b.HasIndex("TenantId", "Status", "DueDate") + .HasDatabaseName("ix_platform_billing_invoices_tenant_id_status_due_date"); + + b.ToTable("platform_billing_invoices", null, t => + { + t.HasCheckConstraint("ck_platform_billing_invoices_amount", "total_amount_cents >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformBillingInvoiceReminder", 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_platform_billing_invoice_reminders"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_platform_billing_invoice_reminders_tenant_id_id"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_platform_billing_invoice_reminders_created_by"); + + b.HasIndex("InvoiceId", "ReminderDate") + .HasDatabaseName("ix_platform_billing_invoice_reminders_invoice_id_reminder_date"); + + b.HasIndex("TenantId", "Status", "ReminderDate") + .HasDatabaseName("ix_platform_billing_invoice_reminders_tenant_id_status_reminde~"); + + b.HasIndex("TenantId", "InvoiceId", "ReminderType", "Channel", "ReminderDate") + .IsUnique() + .HasDatabaseName("ix_platform_billing_invoice_reminders_tenant_id_invoice_id_rem~"); + + b.ToTable("platform_billing_invoice_reminders", null, t => + { + t.HasCheckConstraint("ck_platform_billing_invoice_reminders_balance", "balance_cents_snapshot >= 0"); + + t.HasCheckConstraint("ck_platform_billing_invoice_reminders_level", "reminder_level between 1 and 20"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformBillingOrder", 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("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("DiscountAmountCents") + .HasColumnType("integer") + .HasColumnName("discount_amount_cents"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("idempotency_key"); + + b.Property("OrderNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("order_no"); + + b.Property("OriginalAmountCents") + .HasColumnType("integer") + .HasColumnName("original_amount_cents"); + + b.Property("PaidAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("paid_at"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("purpose"); + + b.Property("QuoteId") + .HasColumnType("uuid") + .HasColumnName("quote_id"); + + b.Property("Snapshot") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("snapshot") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TotalAmountCents") + .HasColumnType("integer") + .HasColumnName("total_amount_cents"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_platform_billing_orders"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_platform_billing_orders_tenant_id_id"); + + b.HasIndex("TenantId", "IdempotencyKey") + .IsUnique() + .HasDatabaseName("ix_platform_billing_orders_tenant_id_idempotency_key"); + + b.HasIndex("TenantId", "OrderNo") + .IsUnique() + .HasDatabaseName("ix_platform_billing_orders_tenant_id_order_no"); + + b.HasIndex("TenantId", "QuoteId") + .HasDatabaseName("ix_platform_billing_orders_tenant_id_quote_id"); + + b.HasIndex("TenantId", "Status", "CreatedAt") + .HasDatabaseName("ix_platform_billing_orders_tenant_id_status_created_at"); + + b.ToTable("platform_billing_orders", null, t => + { + t.HasCheckConstraint("ck_platform_billing_orders_amounts", "original_amount_cents >= 0 and discount_amount_cents >= 0 and total_amount_cents >= 0 and total_amount_cents = original_amount_cents - discount_amount_cents"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformBillingOrderItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AmountCents") + .HasColumnType("integer") + .HasColumnName("amount_cents"); + + b.Property("ItemType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("item_type"); + + b.Property("OfferingVersionId") + .HasColumnType("uuid") + .HasColumnName("offering_version_id"); + + b.Property("OrderId") + .HasColumnType("uuid") + .HasColumnName("order_id"); + + b.Property("Quantity") + .HasColumnType("integer") + .HasColumnName("quantity"); + + b.Property("Snapshot") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("snapshot") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UnitAmountCents") + .HasColumnType("integer") + .HasColumnName("unit_amount_cents"); + + b.HasKey("Id") + .HasName("pk_platform_billing_order_items"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_platform_billing_order_items_tenant_id_id"); + + b.HasIndex("OfferingVersionId") + .HasDatabaseName("ix_platform_billing_order_items_offering_version_id"); + + b.HasIndex("TenantId", "OrderId", "OfferingVersionId") + .IsUnique() + .HasDatabaseName("ix_platform_billing_order_items_tenant_id_order_id_offering_ve~"); + + b.ToTable("platform_billing_order_items", null, t => + { + t.HasCheckConstraint("ck_platform_billing_order_items_amounts", "quantity > 0 and unit_amount_cents >= 0 and amount_cents >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformBillingPayment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AmountCents") + .HasColumnType("integer") + .HasColumnName("amount_cents"); + + b.Property("ClientPayload") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("client_payload") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("idempotency_key"); + + b.Property("Method") + .IsRequired() + .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("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("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_billing_payments"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_platform_billing_payments_tenant_id_id"); + + b.HasIndex("TenantId", "IdempotencyKey") + .IsUnique() + .HasDatabaseName("ix_platform_billing_payments_tenant_id_idempotency_key"); + + b.HasIndex("TenantId", "PaymentNo") + .IsUnique() + .HasDatabaseName("ix_platform_billing_payments_tenant_id_payment_no"); + + b.HasIndex("TenantId", "OrderId", "Status") + .HasDatabaseName("ix_platform_billing_payments_tenant_id_order_id_status"); + + b.ToTable("platform_billing_payments", null, t => + { + t.HasCheckConstraint("ck_platform_billing_payments_amount", "amount_cents >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformBillingPaymentEvent", 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("Payload") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("payload") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("PaymentId") + .HasColumnType("uuid") + .HasColumnName("payment_id"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("provider"); + + b.Property("ProviderEventId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("provider_event_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id") + .HasName("pk_platform_billing_payment_events"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_platform_billing_payment_events_tenant_id_id"); + + b.HasIndex("TenantId", "PaymentId", "CreatedAt") + .HasDatabaseName("ix_platform_billing_payment_events_tenant_id_payment_id_create~"); + + b.HasIndex("TenantId", "Provider", "ProviderEventId") + .IsUnique() + .HasDatabaseName("ix_platform_billing_payment_events_tenant_id_provider_provider~"); + + b.ToTable("platform_billing_payment_events", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformBillingQuote", 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("Currency") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("currency"); + + b.Property("DiscountAmountCents") + .HasColumnType("integer") + .HasColumnName("discount_amount_cents"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("FeatureSnapshot") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("feature_snapshot") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("idempotency_key"); + + b.Property("LimitSnapshot") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("limit_snapshot") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("OriginalAmountCents") + .HasColumnType("integer") + .HasColumnName("original_amount_cents"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("purpose"); + + b.Property("QuoteNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("quote_no"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TotalAmountCents") + .HasColumnType("integer") + .HasColumnName("total_amount_cents"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_platform_billing_quotes"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_platform_billing_quotes_tenant_id_id"); + + b.HasIndex("TenantId", "IdempotencyKey") + .IsUnique() + .HasDatabaseName("ix_platform_billing_quotes_tenant_id_idempotency_key"); + + b.HasIndex("TenantId", "QuoteNo") + .IsUnique() + .HasDatabaseName("ix_platform_billing_quotes_tenant_id_quote_no"); + + b.HasIndex("TenantId", "Status", "ExpiresAt") + .HasDatabaseName("ix_platform_billing_quotes_tenant_id_status_expires_at"); + + b.ToTable("platform_billing_quotes", null, t => + { + t.HasCheckConstraint("ck_platform_billing_quotes_amounts", "original_amount_cents >= 0 and discount_amount_cents >= 0 and total_amount_cents >= 0 and total_amount_cents = original_amount_cents - discount_amount_cents"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformBillingQuoteItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AmountCents") + .HasColumnType("integer") + .HasColumnName("amount_cents"); + + b.Property("ItemType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("item_type"); + + b.Property("OfferingVersionId") + .HasColumnType("uuid") + .HasColumnName("offering_version_id"); + + b.Property("Quantity") + .HasColumnType("integer") + .HasColumnName("quantity"); + + b.Property("QuoteId") + .HasColumnType("uuid") + .HasColumnName("quote_id"); + + b.Property("Snapshot") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("snapshot") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UnitAmountCents") + .HasColumnType("integer") + .HasColumnName("unit_amount_cents"); + + b.HasKey("Id") + .HasName("pk_platform_billing_quote_items"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_platform_billing_quote_items_tenant_id_id"); + + b.HasIndex("OfferingVersionId") + .HasDatabaseName("ix_platform_billing_quote_items_offering_version_id"); + + b.HasIndex("TenantId", "QuoteId", "OfferingVersionId") + .IsUnique() + .HasDatabaseName("ix_platform_billing_quote_items_tenant_id_quote_id_offering_ve~"); + + b.ToTable("platform_billing_quote_items", null, t => + { + t.HasCheckConstraint("ck_platform_billing_quote_items_amounts", "quantity > 0 and unit_amount_cents >= 0 and amount_cents >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformBillingRefund", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AmountCents") + .HasColumnType("integer") + .HasColumnName("amount_cents"); + + 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("OrderId") + .HasColumnType("uuid") + .HasColumnName("order_id"); + + b.Property("PaymentId") + .HasColumnType("uuid") + .HasColumnName("payment_id"); + + b.Property("ProviderRefundNo") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("provider_refund_no"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("reason"); + + b.Property("RefundNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("refund_no"); + + 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_billing_refunds"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_platform_billing_refunds_tenant_id_id"); + + b.HasIndex("TenantId", "PaymentId") + .HasDatabaseName("ix_platform_billing_refunds_tenant_id_payment_id"); + + b.HasIndex("TenantId", "RefundNo") + .IsUnique() + .HasDatabaseName("ix_platform_billing_refunds_tenant_id_refund_no"); + + b.HasIndex("TenantId", "OrderId", "Status") + .HasDatabaseName("ix_platform_billing_refunds_tenant_id_order_id_status"); + + b.ToTable("platform_billing_refunds", null, t => + { + t.HasCheckConstraint("ck_platform_billing_refunds_amount", "amount_cents > 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformPaymentApp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AppCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("app_code"); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("app_name"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("SettlementMode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("settlement_mode"); + + 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_payment_apps"); + + b.HasIndex("AppCode") + .IsUnique() + .HasDatabaseName("ix_platform_payment_apps_app_code"); + + b.HasIndex("Status", "AppCode") + .HasDatabaseName("ix_platform_payment_apps_status_app_code"); + + b.ToTable("platform_payment_apps", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformPaymentChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AppId") + .HasColumnType("uuid") + .HasColumnName("app_id"); + + b.Property("CallbackPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("callback_path"); + + 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") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("mode"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .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("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_platform_payment_channels"); + + b.HasIndex("AppId", "Provider") + .IsUnique() + .HasDatabaseName("ix_platform_payment_channels_app_id_provider"); + + b.HasIndex("Status", "Priority", "Provider") + .HasDatabaseName("ix_platform_payment_channels_status_priority_provider"); + + b.ToTable("platform_payment_channels", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.SaasFeature", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("category"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .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") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("IsCore") + .HasColumnType("boolean") + .HasColumnName("is_core"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("ReferencePriceCents") + .HasColumnType("integer") + .HasColumnName("reference_price_cents"); + + 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_saas_features"); + + b.HasAlternateKey("Code") + .HasName("ak_saas_features_code"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_saas_features_code"); + + b.HasIndex("Status", "Category", "SortOrder") + .HasDatabaseName("ix_saas_features_status_category_sort_order"); + + b.ToTable("saas_features", null, t => + { + t.HasCheckConstraint("ck_saas_features_reference_price", "reference_price_cents >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.SaasFeatureLimitDefinition", 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("FeatureCode") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("feature_code"); + + b.Property("IsHardLimit") + .HasColumnType("boolean") + .HasColumnName("is_hard_limit"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("kind"); + + b.Property("MetricCode") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("metric_code"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("unit"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("WarningPercent") + .HasColumnType("integer") + .HasColumnName("warning_percent"); + + b.HasKey("Id") + .HasName("pk_saas_feature_limit_definitions"); + + b.HasAlternateKey("MetricCode") + .HasName("ak_saas_feature_limit_definitions_metric_code"); + + b.HasIndex("MetricCode") + .IsUnique() + .HasDatabaseName("ix_saas_feature_limit_definitions_metric_code"); + + b.HasIndex("FeatureCode", "Kind") + .HasDatabaseName("ix_saas_feature_limit_definitions_feature_code_kind"); + + b.ToTable("saas_feature_limit_definitions", null, t => + { + t.HasCheckConstraint("ck_saas_feature_limit_warning", "warning_percent between 1 and 100"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.SaasOffering", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("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_saas_offerings"); + + b.HasIndex("Code") + .IsUnique() + .HasDatabaseName("ix_saas_offerings_code"); + + b.HasIndex("Type", "Status", "SortOrder") + .HasDatabaseName("ix_saas_offerings_type_status_sort_order"); + + b.ToTable("saas_offerings", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.SaasOfferingVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AmountCents") + .HasColumnType("integer") + .HasColumnName("amount_cents"); + + b.Property("BillingCycle") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("billing_cycle"); + + 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("EffectiveAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("effective_at"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("OfferingId") + .HasColumnType("uuid") + .HasColumnName("offering_id"); + + b.Property("OriginalAmountCents") + .HasColumnType("integer") + .HasColumnName("original_amount_cents"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("RetiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("retired_at"); + + 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.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_saas_offering_versions"); + + b.HasIndex("OfferingId", "Version") + .IsUnique() + .HasDatabaseName("ix_saas_offering_versions_offering_id_version"); + + b.HasIndex("OfferingId", "Status", "EffectiveAt") + .HasDatabaseName("ix_saas_offering_versions_offering_id_status_effective_at"); + + b.ToTable("saas_offering_versions", null, t => + { + t.HasCheckConstraint("ck_saas_offering_versions_amount", "original_amount_cents >= 0 and amount_cents >= 0 and amount_cents <= original_amount_cents"); + + t.HasCheckConstraint("ck_saas_offering_versions_version", "version > 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.SaasOfferingVersionFeature", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("FeatureCode") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("feature_code"); + + b.Property("OfferingVersionId") + .HasColumnType("uuid") + .HasColumnName("offering_version_id"); + + b.HasKey("Id") + .HasName("pk_saas_offering_version_features"); + + b.HasIndex("FeatureCode") + .HasDatabaseName("ix_saas_offering_version_features_feature_code"); + + b.HasIndex("OfferingVersionId", "FeatureCode") + .IsUnique() + .HasDatabaseName("ix_saas_offering_version_features_offering_version_id_feature_~"); + + b.ToTable("saas_offering_version_features", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.SaasOfferingVersionLimit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("LimitValue") + .HasColumnType("bigint") + .HasColumnName("limit_value"); + + b.Property("MetricCode") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("metric_code"); + + b.Property("OfferingVersionId") + .HasColumnType("uuid") + .HasColumnName("offering_version_id"); + + b.HasKey("Id") + .HasName("pk_saas_offering_version_limits"); + + b.HasIndex("MetricCode") + .HasDatabaseName("ix_saas_offering_version_limits_metric_code"); + + b.HasIndex("OfferingVersionId", "MetricCode") + .IsUnique() + .HasDatabaseName("ix_saas_offering_version_limits_offering_version_id_metric_code"); + + b.ToTable("saas_offering_version_limits", null, t => + { + t.HasCheckConstraint("ck_saas_offering_version_limits_value", "limit_value >= 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.TenantFeatureOverride", 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("FeatureCode") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("feature_code"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("mode"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("reason"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_tenant_feature_overrides"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_feature_overrides_tenant_id_id"); + + b.HasIndex("FeatureCode") + .HasDatabaseName("ix_tenant_feature_overrides_feature_code"); + + b.HasIndex("TenantId", "FeatureCode") + .IsUnique() + .HasDatabaseName("ix_tenant_feature_overrides_tenant_id_feature_code"); + + b.ToTable("tenant_feature_overrides", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.TenantFeatureUsage", 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("LimitValueSnapshot") + .HasColumnType("bigint") + .HasColumnName("limit_value_snapshot"); + + b.Property("MetricCode") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("metric_code"); + + b.Property("PeriodEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("period_end"); + + b.Property("PeriodStart") + .HasColumnType("timestamp with time zone") + .HasColumnName("period_start"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UsedValue") + .HasColumnType("bigint") + .HasColumnName("used_value"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("version"); + + b.Property("WarningIssued") + .HasColumnType("boolean") + .HasColumnName("warning_issued"); + + b.HasKey("Id") + .HasName("pk_tenant_feature_usage"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_feature_usage_tenant_id_id"); + + b.HasIndex("MetricCode") + .HasDatabaseName("ix_tenant_feature_usage_metric_code"); + + b.HasIndex("TenantId", "MetricCode", "PeriodStart", "PeriodEnd") + .IsUnique() + .HasDatabaseName("ix_tenant_feature_usage_tenant_id_metric_code_period_start_per~"); + + b.ToTable("tenant_feature_usage", null, t => + { + t.HasCheckConstraint("ck_tenant_feature_usage_period", "period_end > period_start"); + + t.HasCheckConstraint("ck_tenant_feature_usage_values", "used_value >= 0 and limit_value_snapshot >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.TenantSaasSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BaseOfferingVersionId") + .HasColumnType("uuid") + .HasColumnName("base_offering_version_id"); + + b.Property("CancelAtPeriodEnd") + .HasColumnType("boolean") + .HasColumnName("cancel_at_period_end"); + + 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("CurrentPeriodEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("current_period_end"); + + b.Property("CurrentPeriodStart") + .HasColumnType("timestamp with time zone") + .HasColumnName("current_period_start"); + + b.Property("LifecycleVersion") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasColumnName("lifecycle_version"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("ScheduledBaseOfferingVersionId") + .HasColumnType("uuid") + .HasColumnName("scheduled_base_offering_version_id"); + + 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_saas_subscriptions"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_saas_subscriptions_tenant_id_id"); + + b.HasIndex("BaseOfferingVersionId") + .HasDatabaseName("ix_tenant_saas_subscriptions_base_offering_version_id"); + + b.HasIndex("ScheduledBaseOfferingVersionId") + .HasDatabaseName("ix_tenant_saas_subscriptions_scheduled_base_offering_version_id"); + + b.HasIndex("TenantId") + .IsUnique() + .HasDatabaseName("ix_tenant_saas_subscriptions_tenant_id") + .HasFilter("status in ('trial', 'active', 'past_due', 'suspended')"); + + b.HasIndex("TenantId", "Status", "CurrentPeriodEnd") + .HasDatabaseName("ix_tenant_saas_subscriptions_tenant_id_status_current_period_e~"); + + b.ToTable("tenant_saas_subscriptions", null, t => + { + t.HasCheckConstraint("ck_tenant_saas_subscriptions_period", "current_period_end > current_period_start and current_period_start >= starts_at"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.TenantSaasSubscriptionItem", 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("EndsAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ends_at"); + + b.Property("ItemType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("item_type"); + + b.Property("OfferingVersionId") + .HasColumnType("uuid") + .HasColumnName("offering_version_id"); + + b.Property("SourceOrderItemId") + .HasColumnType("uuid") + .HasColumnName("source_order_item_id"); + + 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("SubscriptionId") + .HasColumnType("uuid") + .HasColumnName("subscription_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_tenant_saas_subscription_items"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_saas_subscription_items_tenant_id_id"); + + b.HasIndex("OfferingVersionId") + .HasDatabaseName("ix_tenant_saas_subscription_items_offering_version_id"); + + b.HasIndex("TenantId", "SourceOrderItemId") + .HasDatabaseName("ix_tenant_saas_subscription_items_tenant_id_source_order_item_~"); + + b.HasIndex("TenantId", "SubscriptionId", "ItemType") + .IsUnique() + .HasDatabaseName("ux_tenant_saas_subscription_items_current_base") + .HasFilter("item_type = 'base_plan' and status = 'active'"); + + b.HasIndex("TenantId", "SubscriptionId", "Status") + .IsUnique() + .HasDatabaseName("ux_tenant_saas_subscription_items_scheduled_base") + .HasFilter("item_type = 'base_plan' and status = 'scheduled'"); + + b.HasIndex("TenantId", "SubscriptionId", "OfferingVersionId", "Status") + .HasDatabaseName("ix_tenant_saas_subscription_items_tenant_id_subscription_id_of~"); + + b.ToTable("tenant_saas_subscription_items", null, t => + { + t.HasCheckConstraint("ck_tenant_saas_subscription_items_period", "ends_at > starts_at"); + }); + }); + + 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("ContentEntryId") + .HasColumnType("uuid") + .HasColumnName("content_entry_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("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", "ContentEntryId") + .HasDatabaseName("ix_question_banks_tenant_id_content_entry_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_question_banks_tenant_id_region_id"); + + b.ToTable("question_banks", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.QuestionBanks.QuestionVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AnswerText") + .HasColumnType("text") + .HasColumnName("answer_text"); + + b.Property("CodeLang") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("code_lang"); + + b.Property("CodeTemplate") + .HasColumnType("text") + .HasColumnName("code_template"); + + b.Property("Content") + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("CorrectOptionIndex") + .HasColumnType("integer") + .HasColumnName("correct_option_index"); + + b.Property("CorrectOptionIndices") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("correct_option_indices") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("Explanation") + .HasColumnType("text") + .HasColumnName("explanation"); + + b.Property("Options") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("options") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("QuestionId") + .HasColumnType("uuid") + .HasColumnName("question_id"); + + b.Property("SourceHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("source_hash"); + + b.Property("SubQuestions") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("sub_questions") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("VersionNo") + .HasColumnType("integer") + .HasColumnName("version_no"); + + b.HasKey("Id") + .HasName("pk_question_versions"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_question_versions_tenant_id_id"); + + b.HasAlternateKey("TenantId", "QuestionId", "Id") + .HasName("ak_question_versions_tenant_id_question_id_id"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_question_versions_created_by"); + + b.HasIndex("TenantId", "QuestionId", "VersionNo") + .IsUnique() + .HasDatabaseName("ix_question_versions_tenant_id_question_id_version_no"); + + b.ToTable("question_versions", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.AuthChallenge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("consumed_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("IpAddress") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("ip_address"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("provider"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("purpose"); + + b.Property("Realm") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("realm"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("security_stamp"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash"); + + b.Property("UserAgent") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_auth_challenges"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_auth_challenges_tenant_id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_auth_challenges_token_hash"); + + b.HasIndex("UserId", "Purpose", "ExpiresAt") + .HasDatabaseName("ix_auth_challenges_user_id_purpose_expires_at"); + + b.ToTable("auth_challenges", null, t => + { + t.HasCheckConstraint("ck_auth_challenges_realm_tenant", "(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.AuthLoginEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("FailureCode") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("failure_code"); + + b.Property("Identifier") + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("identifier"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("ip_address"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("provider"); + + b.Property("Result") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("result"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UserAgent") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_auth_login_events"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_auth_login_events_user_id"); + + b.HasIndex("TenantId", "UserId", "CreatedAt") + .HasDatabaseName("ix_auth_login_events_tenant_id_user_id_created_at"); + + b.ToTable("auth_login_events", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.AuthSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("ip_address"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("ParentSessionId") + .HasColumnType("uuid") + .HasColumnName("parent_session_id"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("provider"); + + b.Property("Realm") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("realm"); + + b.Property("ReplacedBySessionId") + .HasColumnType("uuid") + .HasColumnName("replaced_by_session_id"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("RevokedReason") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("revoked_reason"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("security_stamp"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TokenFamilyId") + .HasColumnType("uuid") + .HasColumnName("token_family_id"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("token_hash"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserAgent") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_auth_sessions"); + + b.HasIndex("TenantId") + .HasDatabaseName("ix_auth_sessions_tenant_id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("ix_auth_sessions_token_hash") + .HasAnnotation("Tiku:GlobalUnique", true); + + b.HasIndex("UserId") + .HasDatabaseName("ix_auth_sessions_user_id"); + + b.HasIndex("TokenFamilyId", "RevokedAt") + .HasDatabaseName("ix_auth_sessions_token_family_id_revoked_at"); + + b.HasIndex("Realm", "TenantId", "UserId", "ExpiresAt") + .HasDatabaseName("ix_auth_sessions_realm_tenant_id_user_id_expires_at") + .HasFilter("revoked_at is null"); + + b.ToTable("auth_sessions", null, t => + { + t.HasCheckConstraint("ck_auth_sessions_realm_tenant", "(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.SmsChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + 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("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("MonthlyQuota") + .HasColumnType("integer") + .HasColumnName("monthly_quota"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .HasColumnName("provider"); + + b.Property("Scene") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("scene"); + + b.Property("SecretRef") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("secret_ref"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("signature"); + + 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_sms_channels"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_sms_channels_tenant_id_id"); + + b.HasIndex("TenantId", "Provider", "Scene") + .IsUnique() + .HasDatabaseName("ix_sms_channels_tenant_id_provider_scene"); + + b.HasIndex("TenantId", "Status", "Priority") + .HasDatabaseName("ix_sms_channels_tenant_id_status_priority"); + + b.ToTable("sms_channels", null, t => + { + t.HasCheckConstraint("ck_sms_channels_quota", "monthly_quota >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.SmsSendLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ChannelId") + .HasColumnType("uuid") + .HasColumnName("channel_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("ErrorCode") + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("error_code"); + + b.Property("ErrorMessage") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("error_message"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("PhoneMasked") + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("phone_masked"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .HasColumnName("provider"); + + b.Property("ProviderMessageId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("provider_message_id"); + + b.Property("Scene") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("scene"); + + 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("TemplateId") + .HasColumnType("uuid") + .HasColumnName("template_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.HasKey("Id") + .HasName("pk_sms_send_logs"); + + b.HasIndex("TenantId", "CreatedAt") + .HasDatabaseName("ix_sms_send_logs_tenant_id_created_at"); + + b.HasIndex("TenantId", "TemplateId") + .HasDatabaseName("ix_sms_send_logs_tenant_id_template_id"); + + b.HasIndex("TenantId", "ChannelId", "CreatedAt") + .HasDatabaseName("ix_sms_send_logs_tenant_id_channel_id_created_at"); + + b.ToTable("sms_send_logs", (string)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.SmsTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ApprovedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("approved_at"); + + b.Property("AuditStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("audit_status"); + + b.Property("ChannelId") + .HasColumnType("uuid") + .HasColumnName("channel_id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("code"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("content"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DisabledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("disabled_at"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("ProviderTemplateCode") + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("provider_template_code"); + + b.Property("Remark") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("remark"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("SubmittedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("submitted_at"); + + 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_sms_templates"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_sms_templates_tenant_id_id"); + + b.HasIndex("TenantId", "Code") + .IsUnique() + .HasDatabaseName("ix_sms_templates_tenant_id_code"); + + b.HasIndex("TenantId", "ChannelId", "Status") + .HasDatabaseName("ix_sms_templates_tenant_id_channel_id_status"); + + b.ToTable("sms_templates", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.SmsVerificationCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("code_hash"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("consumed_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("ip_address"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("phone"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("provider"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("purpose"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UserAgent") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("user_agent"); + + b.HasKey("Id") + .HasName("pk_sms_verification_codes"); + + b.HasIndex("TenantId", "Phone", "Purpose") + .IsUnique() + .HasDatabaseName("ix_sms_verification_codes_tenant_id_phone_purpose") + .HasFilter("consumed_at is null and status in ('pending', 'sent')"); + + b.HasIndex("TenantId", "Phone", "Purpose", "CreatedAt") + .HasDatabaseName("ix_sms_verification_codes_tenant_id_phone_purpose_created_at") + .HasFilter("consumed_at is null and status in ('pending', 'sent')"); + + b.HasIndex("TenantId", "Phone", "Purpose", "ExpiresAt") + .HasDatabaseName("ix_sms_verification_codes_tenant_id_phone_purpose_expires_at"); + + b.ToTable("sms_verification_codes", null, t => + { + t.HasCheckConstraint("ck_sms_verification_codes_attempts", "attempts >= 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("BillingStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("billing_status"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("LegalName") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("legal_name"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("mode"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("OwnerUserId") + .HasColumnType("uuid") + .HasColumnName("owner_user_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("citext") + .HasColumnName("slug"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_tenants"); + + b.HasIndex("LegacyId") + .IsUnique() + .HasDatabaseName("ix_tenants_legacy_id"); + + b.HasIndex("Mode") + .IsUnique() + .HasDatabaseName("ix_tenants_mode") + .HasFilter("mode = 'platform_owned'"); + + b.HasIndex("OwnerUserId") + .HasDatabaseName("ix_tenants_owner_user_id"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_tenants_slug"); + + b.ToTable("tenants", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantAuthPolicy", b => + { + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("AllowExternalStudentSelfRegistration") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("allow_external_student_self_registration"); + + b.PrimitiveCollection("AllowedStudentLoginMethods") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text[]") + .HasColumnName("allowed_student_login_methods") + .HasDefaultValueSql("ARRAY['password']::text[]"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("TenantId") + .HasName("pk_tenant_auth_policies"); + + b.ToTable("tenant_auth_policies", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantBranding", b => + { + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("BrandName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("brand_name"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("FaviconUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("favicon_url"); + + b.Property("LogoUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("logo_url"); + + b.Property("OrganizationName") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("organization_name"); + + b.Property("PublicAssets") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("public_assets") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("ServiceAccountName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("service_account_name"); + + b.Property("ServiceWechat") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("service_wechat"); + + b.Property("ShortName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("short_name"); + + b.Property("Slogan") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("slogan"); + + b.Property("Theme") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("theme") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("TenantId") + .HasName("pk_tenant_branding"); + + b.ToTable("tenant_branding", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantClass", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Code") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("LegacyId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("legacy_id"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("RegionId") + .HasColumnType("uuid") + .HasColumnName("region_id"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasColumnName("sort_order"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_tenant_classes"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_classes_tenant_id_id"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_tenant_classes_created_by"); + + b.HasIndex("UpdatedBy") + .HasDatabaseName("ix_tenant_classes_updated_by"); + + b.HasIndex("TenantId", "Code") + .IsUnique() + .HasDatabaseName("ix_tenant_classes_tenant_id_code") + .HasFilter("code is not null and code <> ''"); + + b.HasIndex("TenantId", "LegacyId") + .IsUnique() + .HasDatabaseName("ix_tenant_classes_tenant_id_legacy_id"); + + b.HasIndex("TenantId", "RegionId") + .HasDatabaseName("ix_tenant_classes_tenant_id_region_id") + .HasFilter("region_id is not null"); + + b.HasIndex("TenantId", "Status", "SortOrder", "CreatedAt") + .HasDatabaseName("ix_tenant_classes_tenant_id_status_sort_order_created_at"); + + b.ToTable("tenant_classes", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantClassMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ClassId") + .HasColumnType("uuid") + .HasColumnName("class_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("JoinedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at") + .HasDefaultValueSql("now()"); + + b.Property("LeftAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("left_at"); + + b.Property("MemberType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("member_type"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_tenant_class_members"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_class_members_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_tenant_class_members_user_id"); + + b.HasIndex("TenantId", "ClassId", "Status", "MemberType") + .HasDatabaseName("ix_tenant_class_members_tenant_id_class_id_status_member_type"); + + b.HasIndex("TenantId", "ClassId", "UserId", "MemberType") + .IsUnique() + .HasDatabaseName("ix_tenant_class_members_tenant_id_class_id_user_id_member_type"); + + b.HasIndex("TenantId", "UserId", "Status", "MemberType") + .HasDatabaseName("ix_tenant_class_members_tenant_id_user_id_status_member_type"); + + b.ToTable("tenant_class_members", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantDomain", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DnsVerifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("dns_verified_at"); + + b.Property("DomainType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("domain_type"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("citext") + .HasColumnName("host"); + + b.Property("IsPrimary") + .HasColumnType("boolean") + .HasColumnName("is_primary"); + + b.Property("LastCheckedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_checked_at"); + + b.Property("LastFailureReason") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("last_failure_reason"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("TlsReadyAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("tls_ready_at"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("VerificationToken") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("verification_token"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("verified_at"); + + b.HasKey("Id") + .HasName("pk_tenant_domains"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_domains_tenant_id_id"); + + b.HasIndex("Host") + .IsUnique() + .HasDatabaseName("ix_tenant_domains_host") + .HasAnnotation("Tiku:GlobalUnique", true); + + b.HasIndex("TenantId", "IsPrimary") + .IsUnique() + .HasDatabaseName("ix_tenant_domains_tenant_id_is_primary") + .HasFilter("is_primary"); + + b.ToTable("tenant_domains", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantExternalProvider", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Capability") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("capability"); + + b.Property("ConfigPublic") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("config_public") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("display_name"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("provider"); + + b.Property("SecretRef") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("secret_ref"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_tenant_external_providers"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_external_providers_tenant_id_id"); + + b.HasIndex("TenantId", "Capability", "Provider") + .IsUnique() + .HasDatabaseName("ix_tenant_external_providers_tenant_id_capability_provider"); + + b.HasIndex("TenantId", "Capability", "Status", "Priority") + .HasDatabaseName("ix_tenant_external_providers_tenant_id_capability_status_prior~"); + + b.ToTable("tenant_external_providers", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantFrontendConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("ConfigVersion") + .IsConcurrencyToken() + .HasColumnType("integer") + .HasColumnName("config_version"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("DraftBranding") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("draft_branding") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("DraftFeatures") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("draft_features") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("DraftHomeModules") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("draft_home_modules") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("DraftNavigation") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("draft_navigation") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("DraftTheme") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("draft_theme") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property("PublishedBranding") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("published_branding") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("PublishedFeatures") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("published_features") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("PublishedHomeModules") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("published_home_modules") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("PublishedNavigation") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("published_navigation") + .HasDefaultValueSql("'[]'::jsonb"); + + b.Property("PublishedTheme") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("published_theme") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("SchemaVersion") + .HasColumnType("integer") + .HasColumnName("schema_version"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_tenant_frontend_configs"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_frontend_configs_tenant_id_id"); + + b.HasIndex("TenantId") + .IsUnique() + .HasDatabaseName("ix_tenant_frontend_configs_tenant_id"); + + b.ToTable("tenant_frontend_configs", null, t => + { + t.HasCheckConstraint("ck_tenant_frontend_configs_config_version", "config_version > 0"); + + t.HasCheckConstraint("ck_tenant_frontend_configs_schema_version", "schema_version > 0"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("LegacyRole") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("legacy_role"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("role"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_tenant_memberships"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_memberships_tenant_id_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_tenant_memberships_user_id"); + + b.HasIndex("TenantId", "UserId", "Role") + .IsUnique() + .HasDatabaseName("ix_tenant_memberships_tenant_id_user_id_role"); + + b.ToTable("tenant_memberships", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("encrypted_payload"); + + b.Property("EncryptionKeyId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("encryption_key_id"); + + b.Property("EncryptionNonce") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("encryption_nonce"); + + b.Property("EncryptionTag") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("encryption_tag"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("provider"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)") + .HasColumnName("purpose"); + + b.Property("RotatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("rotated_at"); + + b.Property("SecretKey") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)") + .HasColumnName("secret_key"); + + b.Property("SecretRef") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("secret_ref"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("Id") + .HasName("pk_tenant_secrets"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_secrets_tenant_id_id"); + + b.HasIndex("TenantId", "SecretRef") + .IsUnique() + .HasDatabaseName("ix_tenant_secrets_tenant_id_secret_ref"); + + b.HasIndex("TenantId", "Purpose", "Provider", "SecretKey") + .IsUnique() + .HasDatabaseName("ix_tenant_secrets_tenant_id_purpose_provider_secret_key"); + + b.HasIndex("TenantId", "Purpose", "Provider", "Status") + .HasDatabaseName("ix_tenant_secrets_tenant_id_purpose_provider_status"); + + b.ToTable("tenant_secrets", null, t => + { + t.HasCheckConstraint("ck_tenant_secrets_encryption_envelope", "octet_length(encrypted_payload) > 0 and octet_length(encryption_nonce) = 12 and octet_length(encryption_tag) = 16 and encryption_key_id <> ''"); + }); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantSettings", b => + { + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("AdminFeatureFlags") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("admin_feature_flags") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("FeatureFlags") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("feature_flags") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("PublicConfig") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("public_config") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.HasKey("TenantId") + .HasName("pk_tenant_settings"); + + b.ToTable("tenant_settings", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantStudentFollowup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("AssignedToUserId") + .HasColumnType("uuid") + .HasColumnName("assigned_to_user_id"); + + b.Property("ClassId") + .HasColumnType("uuid") + .HasColumnName("class_id"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at"); + + b.Property("CompletedBy") + .HasColumnType("uuid") + .HasColumnName("completed_by"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("DueAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("due_at"); + + b.Property("FollowupType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("followup_type"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("priority"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("status"); + + b.Property("StudentUserId") + .HasColumnType("uuid") + .HasColumnName("student_user_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.HasKey("Id") + .HasName("pk_tenant_student_followups"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_student_followups_tenant_id_id"); + + b.HasIndex("AssignedToUserId") + .HasDatabaseName("ix_tenant_student_followups_assigned_to_user_id"); + + b.HasIndex("CompletedBy") + .HasDatabaseName("ix_tenant_student_followups_completed_by"); + + b.HasIndex("StudentUserId") + .HasDatabaseName("ix_tenant_student_followups_student_user_id"); + + b.HasIndex("TenantId", "AssignedToUserId", "Status", "DueAt") + .HasDatabaseName("ix_tenant_student_followups_tenant_id_assigned_to_user_id_stat~") + .HasFilter("assigned_to_user_id is not null"); + + b.HasIndex("TenantId", "ClassId", "Status", "DueAt") + .HasDatabaseName("ix_tenant_student_followups_tenant_id_class_id_status_due_at") + .HasFilter("class_id is not null"); + + b.HasIndex("TenantId", "StudentUserId", "Status", "DueAt", "CreatedAt") + .HasDatabaseName("ix_tenant_student_followups_tenant_id_student_user_id_status_d~"); + + b.ToTable("tenant_student_followups", (string)null); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantStudentNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id") + .HasDefaultValueSql("gen_random_uuid()"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property("IsPinned") + .HasColumnType("boolean") + .HasColumnName("is_pinned"); + + b.Property("Metadata") + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasColumnName("metadata") + .HasDefaultValueSql("'{}'::jsonb"); + + b.Property("NoteType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("note_type"); + + b.Property("StudentUserId") + .HasColumnType("uuid") + .HasColumnName("student_user_id"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("now()"); + + b.Property("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property("Visibility") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("visibility"); + + b.HasKey("Id") + .HasName("pk_tenant_student_notes"); + + b.HasAlternateKey("TenantId", "Id") + .HasName("ak_tenant_student_notes_tenant_id_id"); + + b.HasIndex("CreatedBy") + .HasDatabaseName("ix_tenant_student_notes_created_by"); + + b.HasIndex("StudentUserId") + .HasDatabaseName("ix_tenant_student_notes_student_user_id"); + + b.HasIndex("UpdatedBy") + .HasDatabaseName("ix_tenant_student_notes_updated_by"); + + b.HasIndex("TenantId", "CreatedBy", "CreatedAt") + .HasDatabaseName("ix_tenant_student_notes_tenant_id_created_by_created_at") + .HasFilter("created_by is not null"); + + b.HasIndex("TenantId", "StudentUserId", "IsPinned", "CreatedAt") + .HasDatabaseName("ix_tenant_student_notes_tenant_id_student_user_id_is_pinned_cr~"); + + b.ToTable("tenant_student_notes", (string)null); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId") + .HasConstraintName("fk_outbox_message_outbox_state_outbox_id"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId") + .HasConstraintName("fk_outbox_message_inbox_state_inbox_message_id_inbox_consumer_~"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_claims_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_logins_users_user_id"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_tokens_users_user_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.Category", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_categories_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.ModuleNode", null) + .WithMany() + .HasForeignKey("TenantId", "NodeId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_categories_module_nodes_tenant_id_node_id"); + + b.HasOne("Tiku.Domain.Catalog.Subject", null) + .WithMany() + .HasForeignKey("TenantId", "SubjectId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_categories_subjects_tenant_id_subject_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.Major", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_majors_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_majors_regions_tenant_id_region_id"); + + b.HasOne("Tiku.Domain.Catalog.School", null) + .WithMany() + .HasForeignKey("TenantId", "SchoolId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_majors_schools_tenant_id_school_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.ModuleNode", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_module_nodes_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.RegionModule", null) + .WithMany() + .HasForeignKey("TenantId", "ModuleId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_module_nodes_region_modules_tenant_id_module_id"); + + b.HasOne("Tiku.Domain.Catalog.ModuleNode", null) + .WithMany() + .HasForeignKey("TenantId", "ParentId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_module_nodes_module_nodes_tenant_id_parent_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_module_nodes_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.QuestionTaxonomyAssignment", b => + { + b.HasOne("Tiku.Domain.Catalog.TaxonomyNode", null) + .WithMany() + .HasForeignKey("TaxonomyOwnerTenantId", "TaxonomyNodeId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_question_taxonomy_assignments_taxonomy_nodes_taxonomy_owner~"); + + b.HasOne("Tiku.Domain.QuestionBanks.Question", null) + .WithMany() + .HasForeignKey("TenantId", "QuestionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_question_taxonomy_assignments_questions_tenant_id_question_~"); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.Region", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_regions_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.RegionModule", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_region_modules_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_region_modules_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.School", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_schools_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.RegionModule", null) + .WithMany() + .HasForeignKey("TenantId", "ModuleId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_schools_region_modules_tenant_id_module_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_schools_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.ScorelineField", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_scoreline_fields_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_scoreline_fields_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.ScorelineRecord", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_scoreline_records_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Major", null) + .WithMany() + .HasForeignKey("TenantId", "MajorId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_scoreline_records_majors_tenant_id_major_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_scoreline_records_regions_tenant_id_region_id"); + + b.HasOne("Tiku.Domain.Catalog.School", null) + .WithMany() + .HasForeignKey("TenantId", "SchoolId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_scoreline_records_schools_tenant_id_school_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.Subject", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_subjects_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Major", null) + .WithMany() + .HasForeignKey("TenantId", "MajorId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subjects_majors_tenant_id_major_id"); + + b.HasOne("Tiku.Domain.Catalog.RegionModule", null) + .WithMany() + .HasForeignKey("TenantId", "ModuleId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subjects_region_modules_tenant_id_module_id"); + + b.HasOne("Tiku.Domain.Catalog.ModuleNode", null) + .WithMany() + .HasForeignKey("TenantId", "NodeId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subjects_module_nodes_tenant_id_node_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subjects_regions_tenant_id_region_id"); + + b.HasOne("Tiku.Domain.Catalog.School", null) + .WithMany() + .HasForeignKey("TenantId", "SchoolId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_subjects_schools_tenant_id_school_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Catalog.TaxonomyNode", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_taxonomy_nodes_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.TaxonomyNode", null) + .WithMany() + .HasForeignKey("ParentOwnerTenantId", "ParentId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_taxonomy_nodes_taxonomy_nodes_parent_owner_tenant_id_parent~"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.ActivationCode", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("AgentUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_activation_codes_users_agent_user_id"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_activation_codes_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UsedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_activation_codes_users_used_by"); + + b.HasOne("Tiku.Domain.Commerce.CodeBatch", null) + .WithMany() + .HasForeignKey("TenantId", "BatchId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_activation_codes_code_batches_tenant_id_batch_id"); + + b.HasOne("Tiku.Domain.Commerce.CouponRedemption", null) + .WithMany() + .HasForeignKey("TenantId", "CouponRedemptionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_activation_codes_coupon_redemptions_tenant_id_coupon_redemp~"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "UsedRegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_activation_codes_regions_tenant_id_used_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.CodeBatch", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_code_batches_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.CommerceAdjustmentVoucher", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_adjustment_vouchers_users_created_by"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ReviewedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_adjustment_vouchers_users_reviewed_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commerce_adjustment_vouchers_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Commerce.CommerceReconciliationBatch", null) + .WithMany() + .HasForeignKey("TenantId", "BatchId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_adjustment_vouchers_commerce_reconciliation_batche~"); + + b.HasOne("Tiku.Domain.Commerce.CommerceReconciliationIssue", null) + .WithMany() + .HasForeignKey("TenantId", "IssueId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_adjustment_vouchers_commerce_reconciliation_issues~"); + + b.HasOne("Tiku.Domain.Commerce.CommerceReconciliationItem", null) + .WithMany() + .HasForeignKey("TenantId", "ItemId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_adjustment_vouchers_commerce_reconciliation_items_~"); + + b.HasOne("Tiku.Domain.Commerce.Order", null) + .WithMany() + .HasForeignKey("TenantId", "OrderId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_adjustment_vouchers_orders_tenant_id_order_id"); + + b.HasOne("Tiku.Domain.Commerce.Payment", null) + .WithMany() + .HasForeignKey("TenantId", "PaymentId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_adjustment_vouchers_payments_tenant_id_payment_id"); + + b.HasOne("Tiku.Domain.Commerce.CommerceRefundRequest", null) + .WithMany() + .HasForeignKey("TenantId", "RefundRequestId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_adjustment_vouchers_commerce_refund_requests_tenan~"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.CommerceAdjustmentVoucherEvent", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ActorUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_adjustment_voucher_events_users_actor_user_id"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commerce_adjustment_voucher_events_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Commerce.CommerceAdjustmentVoucher", null) + .WithMany() + .HasForeignKey("TenantId", "VoucherId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commerce_adjustment_voucher_events_commerce_adjustment_vouc~"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.CommerceReconciliationBatch", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_reconciliation_batches_users_created_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commerce_reconciliation_batches_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.CommerceReconciliationIssue", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("AssignedTo") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_reconciliation_issues_users_assigned_to"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_reconciliation_issues_users_created_by"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ResolvedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_reconciliation_issues_users_resolved_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commerce_reconciliation_issues_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Commerce.CommerceReconciliationBatch", null) + .WithMany() + .HasForeignKey("TenantId", "BatchId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_commerce_reconciliation_issues_commerce_reconciliation_batc~"); + + b.HasOne("Tiku.Domain.Commerce.CommerceReconciliationItem", null) + .WithMany() + .HasForeignKey("TenantId", "ItemId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_commerce_reconciliation_issues_commerce_reconciliation_item~"); + + b.HasOne("Tiku.Domain.Commerce.Order", null) + .WithMany() + .HasForeignKey("TenantId", "OrderId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_commerce_reconciliation_issues_orders_tenant_id_order_id"); + + b.HasOne("Tiku.Domain.Commerce.Payment", null) + .WithMany() + .HasForeignKey("TenantId", "PaymentId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_commerce_reconciliation_issues_payments_tenant_id_payment_id"); + + b.HasOne("Tiku.Domain.Commerce.CommerceRefundRequest", null) + .WithMany() + .HasForeignKey("TenantId", "RefundRequestId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_commerce_reconciliation_issues_commerce_refund_requests_ten~"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.CommerceReconciliationIssueEvent", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ActorUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_reconciliation_issue_events_users_actor_user_id"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commerce_reconciliation_issue_events_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Commerce.CommerceReconciliationIssue", null) + .WithMany() + .HasForeignKey("TenantId", "IssueId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commerce_reconciliation_issue_events_commerce_reconciliatio~"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.CommerceReconciliationItem", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commerce_reconciliation_items_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Commerce.CommerceReconciliationBatch", null) + .WithMany() + .HasForeignKey("TenantId", "BatchId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commerce_reconciliation_items_commerce_reconciliation_batch~"); + + b.HasOne("Tiku.Domain.Commerce.Order", null) + .WithMany() + .HasForeignKey("TenantId", "OrderId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_commerce_reconciliation_items_orders_tenant_id_order_id"); + + b.HasOne("Tiku.Domain.Commerce.Payment", null) + .WithMany() + .HasForeignKey("TenantId", "PaymentId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_commerce_reconciliation_items_payments_tenant_id_payment_id"); + + b.HasOne("Tiku.Domain.Commerce.CommerceRefundRequest", null) + .WithMany() + .HasForeignKey("TenantId", "RefundRequestId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_commerce_reconciliation_items_commerce_refund_requests_tena~"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.CommerceRefundEvent", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ActorUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_refund_events_users_actor_user_id"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commerce_refund_events_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Commerce.CommerceRefundRequest", null) + .WithMany() + .HasForeignKey("TenantId", "RefundRequestId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commerce_refund_events_commerce_refund_requests_tenant_id_r~"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.CommerceRefundRequest", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ProcessedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_refund_requests_users_processed_by"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("RequestedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_refund_requests_users_requested_by"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ReviewedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commerce_refund_requests_users_reviewed_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commerce_refund_requests_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Commerce.Order", null) + .WithMany() + .HasForeignKey("TenantId", "OrderId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commerce_refund_requests_orders_tenant_id_order_id"); + + b.HasOne("Tiku.Domain.Commerce.Payment", null) + .WithMany() + .HasForeignKey("TenantId", "PaymentId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_commerce_refund_requests_payments_tenant_id_payment_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.Coupon", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_coupons_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Commerce.SvipPlan", null) + .WithMany() + .HasForeignKey("TenantId", "PlanId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_coupons_svip_plans_tenant_id_plan_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.CouponRedemption", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_coupon_redemptions_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_coupon_redemptions_users_user_id"); + + b.HasOne("Tiku.Domain.Commerce.Coupon", null) + .WithMany() + .HasForeignKey("TenantId", "CouponId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_coupon_redemptions_coupons_tenant_id_coupon_id"); + + b.HasOne("Tiku.Domain.Commerce.Order", null) + .WithMany() + .HasForeignKey("TenantId", "OrderId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_coupon_redemptions_orders_tenant_id_order_id"); + + b.HasOne("Tiku.Domain.Commerce.SvipPlan", null) + .WithMany() + .HasForeignKey("TenantId", "PlanId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_coupon_redemptions_svip_plans_tenant_id_plan_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_coupon_redemptions_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.Entitlement", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("RevokedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_entitlements_users_revoked_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_entitlements_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_entitlements_users_user_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.Order", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_orders_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_orders_users_user_id"); + + b.HasOne("Tiku.Domain.Commerce.SvipPlan", null) + .WithMany() + .HasForeignKey("TenantId", "PlanId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_orders_svip_plans_tenant_id_plan_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_orders_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.OrderItem", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_order_items_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Commerce.Order", null) + .WithMany() + .HasForeignKey("TenantId", "OrderId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_order_items_orders_tenant_id_order_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.Payment", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_payments_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Commerce.Order", null) + .WithMany() + .HasForeignKey("TenantId", "OrderId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_payments_orders_tenant_id_order_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.PaymentEvent", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_payment_events_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Commerce.Payment", null) + .WithMany() + .HasForeignKey("TenantId", "PaymentId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_payment_events_payments_tenant_id_payment_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.PointActivityClaim", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_point_activity_claims_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_point_activity_claims_users_user_id"); + + b.HasOne("Tiku.Domain.Commerce.PointActivityTask", null) + .WithMany() + .HasForeignKey("TenantId", "TaskId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_point_activity_claims_point_activity_tasks_tenant_id_task_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.PointActivityTask", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_point_activity_tasks_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.PointExchangeItem", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_point_exchange_items_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_point_exchange_items_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.PointExchangeOrder", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_point_exchange_orders_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_point_exchange_orders_users_user_id"); + + b.HasOne("Tiku.Domain.Commerce.PointExchangeItem", null) + .WithMany() + .HasForeignKey("TenantId", "ItemId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_point_exchange_orders_point_exchange_items_tenant_id_item_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.Product", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_products_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_products_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Commerce.SvipPlan", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_svip_plans_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_svip_plans_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.AiRecommendationReport", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ai_recommendation_reports_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ai_recommendation_reports_users_user_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_ai_recommendation_reports_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.AppAsset", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_app_assets_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentAsset", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_content_assets_users_created_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_content_assets_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_content_assets_users_updated_by"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("VerifiedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_content_assets_users_verified_by"); + + b.HasOne("Tiku.Domain.Catalog.Category", null) + .WithMany() + .HasForeignKey("TenantId", "CategoryId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_content_assets_categories_tenant_id_category_id"); + + b.HasOne("Tiku.Domain.Content.ContentNode", null) + .WithMany() + .HasForeignKey("TenantId", "ContentNodeId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_content_assets_content_nodes_tenant_id_content_node_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_content_assets_regions_tenant_id_region_id"); + + b.HasOne("Tiku.Domain.Catalog.Subject", null) + .WithMany() + .HasForeignKey("TenantId", "SubjectId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_content_assets_subjects_tenant_id_subject_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentAssetAccessEvent", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_content_asset_access_events_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_content_asset_access_events_users_user_id"); + + b.HasOne("Tiku.Domain.Content.ContentAsset", null) + .WithMany() + .HasForeignKey("TenantId", "AssetId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_content_asset_access_events_content_assets_tenant_id_asset_~"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentAssetSecurityScanEvent", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_content_asset_security_scan_events_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Content.ContentAsset", null) + .WithMany() + .HasForeignKey("TenantId", "AssetId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_content_asset_security_scan_events_content_assets_tenant_id~"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentEntry", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_content_entries_users_created_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_content_entries_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_content_entries_users_updated_by"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_content_entries_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentImportIssue", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_content_import_issues_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Content.ContentImportItem", null) + .WithMany() + .HasForeignKey("TenantId", "ItemId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_content_import_issues_content_import_items_tenant_id_item_id"); + + b.HasOne("Tiku.Domain.Content.ContentImportJob", null) + .WithMany() + .HasForeignKey("TenantId", "JobId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_content_import_issues_content_import_jobs_tenant_id_job_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentImportItem", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_content_import_items_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Content.ContentImportJob", null) + .WithMany() + .HasForeignKey("TenantId", "JobId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_content_import_items_content_import_jobs_tenant_id_job_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentImportJob", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_content_import_jobs_users_created_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_content_import_jobs_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Category", null) + .WithMany() + .HasForeignKey("TenantId", "TargetCategoryId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_content_import_jobs_categories_tenant_id_target_category_id"); + + b.HasOne("Tiku.Domain.Content.ContentNode", null) + .WithMany() + .HasForeignKey("TenantId", "TargetContentNodeId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_content_import_jobs_content_nodes_tenant_id_target_content_~"); + + b.HasOne("Tiku.Domain.QuestionBanks.QuestionBank", null) + .WithMany() + .HasForeignKey("TenantId", "TargetQuestionBankId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_content_import_jobs_question_banks_tenant_id_target_questio~"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "TargetRegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_content_import_jobs_regions_tenant_id_target_region_id"); + + b.HasOne("Tiku.Domain.Catalog.Subject", null) + .WithMany() + .HasForeignKey("TenantId", "TargetSubjectId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_content_import_jobs_subjects_tenant_id_target_subject_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ContentNode", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_content_nodes_users_created_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_content_nodes_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_content_nodes_users_updated_by"); + + b.HasOne("Tiku.Domain.Content.ContentEntry", null) + .WithMany() + .HasForeignKey("TenantId", "EntryId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_content_nodes_content_entries_tenant_id_entry_id"); + + b.HasOne("Tiku.Domain.Content.ContentNode", null) + .WithMany() + .HasForeignKey("TenantId", "ParentId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_content_nodes_content_nodes_tenant_id_parent_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_content_nodes_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.HandbookChapter", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_handbook_chapters_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Content.ContentNode", null) + .WithMany() + .HasForeignKey("TenantId", "ContentNodeId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_handbook_chapters_content_nodes_tenant_id_content_node_id"); + + b.HasOne("Tiku.Domain.Content.ContentEntry", null) + .WithMany() + .HasForeignKey("TenantId", "EntryId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_handbook_chapters_content_entries_tenant_id_entry_id"); + + b.HasOne("Tiku.Domain.Content.HandbookSubject", null) + .WithMany() + .HasForeignKey("TenantId", "SubjectId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_handbook_chapters_handbook_subjects_tenant_id_subject_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.HandbookEntry", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_handbook_entries_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Content.HandbookChapter", null) + .WithMany() + .HasForeignKey("TenantId", "ChapterId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_handbook_entries_handbook_chapters_tenant_id_chapter_id"); + + b.HasOne("Tiku.Domain.Content.ContentNode", null) + .WithMany() + .HasForeignKey("TenantId", "ContentNodeId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_handbook_entries_content_nodes_tenant_id_content_node_id"); + + b.HasOne("Tiku.Domain.Content.ContentEntry", null) + .WithMany() + .HasForeignKey("TenantId", "EntryId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_handbook_entries_content_entries_tenant_id_entry_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.HandbookSubject", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_handbook_subjects_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Content.ContentNode", null) + .WithMany() + .HasForeignKey("TenantId", "ContentNodeId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_handbook_subjects_content_nodes_tenant_id_content_node_id"); + + b.HasOne("Tiku.Domain.Content.ContentEntry", null) + .WithMany() + .HasForeignKey("TenantId", "EntryId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_handbook_subjects_content_entries_tenant_id_entry_id"); + + b.HasOne("Tiku.Domain.Catalog.Major", null) + .WithMany() + .HasForeignKey("TenantId", "MajorId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_handbook_subjects_majors_tenant_id_major_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_handbook_subjects_regions_tenant_id_region_id"); + + b.HasOne("Tiku.Domain.Catalog.School", null) + .WithMany() + .HasForeignKey("TenantId", "SchoolId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_handbook_subjects_schools_tenant_id_school_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.ImageAsset", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_images_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.PracticeBlueprint", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_practice_blueprints_users_created_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_practice_blueprints_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_practice_blueprints_users_updated_by"); + + b.HasOne("Tiku.Domain.Content.QuestionCollection", null) + .WithMany() + .HasForeignKey("TenantId", "CollectionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_practice_blueprints_question_collections_tenant_id_collecti~"); + + b.HasOne("Tiku.Domain.Content.ContentEntry", null) + .WithMany() + .HasForeignKey("TenantId", "EntryId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_practice_blueprints_content_entries_tenant_id_entry_id"); + + b.HasOne("Tiku.Domain.Content.ContentNode", null) + .WithMany() + .HasForeignKey("TenantId", "NodeId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_practice_blueprints_content_nodes_tenant_id_node_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_practice_blueprints_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.QuestionCollection", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_question_collections_users_created_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_question_collections_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_question_collections_users_updated_by"); + + b.HasOne("Tiku.Domain.Catalog.Category", null) + .WithMany() + .HasForeignKey("TenantId", "CategoryId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_question_collections_categories_tenant_id_category_id"); + + b.HasOne("Tiku.Domain.Content.ContentEntry", null) + .WithMany() + .HasForeignKey("TenantId", "EntryId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_question_collections_content_entries_tenant_id_entry_id"); + + b.HasOne("Tiku.Domain.Content.ContentNode", null) + .WithMany() + .HasForeignKey("TenantId", "NodeId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_question_collections_content_nodes_tenant_id_node_id"); + + b.HasOne("Tiku.Domain.QuestionBanks.QuestionBank", null) + .WithMany() + .HasForeignKey("TenantId", "QuestionBankId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_question_collections_question_banks_tenant_id_question_bank~"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_question_collections_regions_tenant_id_region_id"); + + b.HasOne("Tiku.Domain.Catalog.Subject", null) + .WithMany() + .HasForeignKey("TenantId", "SubjectId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_question_collections_subjects_tenant_id_subject_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.QuestionCollectionItem", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_question_collection_items_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.QuestionBanks.Question", null) + .WithMany() + .HasForeignKey("QuestionOwnerTenantId", "QuestionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_question_collection_items_questions_question_owner_tenant_i~"); + + b.HasOne("Tiku.Domain.Content.QuestionCollection", null) + .WithMany() + .HasForeignKey("TenantId", "CollectionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_question_collection_items_question_collections_tenant_id_co~"); + + b.HasOne("Tiku.Domain.Content.TenantQuestionReference", null) + .WithMany() + .HasForeignKey("TenantId", "QuestionReferenceId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_question_collection_items_tenant_question_references_tenant~"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.QuestionTypeGroup", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_question_type_groups_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Subject", null) + .WithMany() + .HasForeignKey("TenantId", "SubjectId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_question_type_groups_subjects_tenant_id_subject_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.QuestionVideo", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_question_videos_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.QuestionBanks.Question", null) + .WithMany() + .HasForeignKey("TenantId", "QuestionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_question_videos_questions_tenant_id_question_id"); + + b.HasOne("Tiku.Domain.Content.VideoExplanation", null) + .WithMany() + .HasForeignKey("TenantId", "VideoId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_question_videos_video_explanations_tenant_id_video_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.SubjectShare", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_subject_shares_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Subject", null) + .WithMany() + .HasForeignKey("TenantId", "SourceSubjectId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_subject_shares_subjects_tenant_id_source_subject_id"); + + b.HasOne("Tiku.Domain.Catalog.Subject", null) + .WithMany() + .HasForeignKey("TenantId", "TargetSubjectId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_subject_shares_subjects_tenant_id_target_subject_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.TenantQuestionBankPreference", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenant_question_bank_preferences_users_created_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_question_bank_preferences_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenant_question_bank_preferences_users_updated_by"); + + b.HasOne("Tiku.Domain.QuestionBanks.QuestionBank", null) + .WithMany() + .HasForeignKey("QuestionBankOwnerTenantId", "QuestionBankId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_tenant_question_bank_preferences_question_banks_question_ba~"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.TenantQuestionReference", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenant_question_references_users_created_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_question_references_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.QuestionBanks.Question", null) + .WithMany() + .HasForeignKey("QuestionOwnerTenantId", "QuestionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_tenant_question_references_questions_question_owner_tenant_~"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.UserWordFavorite", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_word_favorites_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_word_favorites_users_user_id"); + + b.HasOne("Tiku.Domain.Content.VocabularyWord", null) + .WithMany() + .HasForeignKey("TenantId", "WordId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_word_favorites_vocabulary_words_tenant_id_word_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.UserWordProgress", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_word_progress_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_word_progress_users_user_id"); + + b.HasOne("Tiku.Domain.Content.VocabularyWord", null) + .WithMany() + .HasForeignKey("TenantId", "WordId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_word_progress_vocabulary_words_tenant_id_word_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.VideoExplanation", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_video_explanations_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Subject", null) + .WithMany() + .HasForeignKey("TenantId", "SubjectId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_video_explanations_subjects_tenant_id_subject_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.VideoPlaybackProgress", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_video_playback_progress_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_video_playback_progress_users_user_id"); + + b.HasOne("Tiku.Domain.QuestionBanks.Question", null) + .WithMany() + .HasForeignKey("TenantId", "QuestionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_video_playback_progress_questions_tenant_id_question_id"); + + b.HasOne("Tiku.Domain.Content.VideoExplanation", null) + .WithMany() + .HasForeignKey("TenantId", "VideoId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_video_playback_progress_video_explanations_tenant_id_video_~"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.VocabularyUnit", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_vocabulary_units_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Content.ContentNode", null) + .WithMany() + .HasForeignKey("TenantId", "ContentNodeId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_vocabulary_units_content_nodes_tenant_id_content_node_id"); + + b.HasOne("Tiku.Domain.Content.ContentEntry", null) + .WithMany() + .HasForeignKey("TenantId", "EntryId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_vocabulary_units_content_entries_tenant_id_entry_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_vocabulary_units_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Content.VocabularyWord", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_vocabulary_words_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Content.ContentNode", null) + .WithMany() + .HasForeignKey("TenantId", "ContentNodeId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_vocabulary_words_content_nodes_tenant_id_content_node_id"); + + b.HasOne("Tiku.Domain.Content.ContentEntry", null) + .WithMany() + .HasForeignKey("TenantId", "EntryId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_vocabulary_words_content_entries_tenant_id_entry_id"); + + b.HasOne("Tiku.Domain.Content.VocabularyUnit", null) + .WithMany() + .HasForeignKey("TenantId", "UnitId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_vocabulary_words_vocabulary_units_tenant_id_unit_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.CommissionSettlement", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("GeneratedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commission_settlements_users_generated_by"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("PaidBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commission_settlements_users_paid_by"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ReferrerUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commission_settlements_users_referrer_user_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ReviewedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commission_settlements_users_reviewed_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commission_settlements_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.CommissionSettlementExportEvent", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ExportedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commission_settlement_export_events_users_exported_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commission_settlement_export_events_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Growth.CommissionSettlement", null) + .WithMany() + .HasForeignKey("TenantId", "SettlementId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commission_settlement_export_events_commission_settlements_~"); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.CommissionSettlementItem", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ReferrerUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commission_settlement_items_users_referrer_user_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("StudentUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commission_settlement_items_users_student_user_id"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commission_settlement_items_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Growth.CommissionSettlement", null) + .WithMany() + .HasForeignKey("TenantId", "SettlementId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commission_settlement_items_commission_settlements_tenant_i~"); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.CommissionSettlementProof", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ReviewedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commission_settlement_proofs_users_reviewed_by"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("SubmittedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commission_settlement_proofs_users_submitted_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commission_settlement_proofs_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Content.ContentAsset", null) + .WithMany() + .HasForeignKey("TenantId", "AssetId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_commission_settlement_proofs_content_assets_tenant_id_asset~"); + + b.HasOne("Tiku.Domain.Growth.CommissionSettlement", null) + .WithMany() + .HasForeignKey("TenantId", "SettlementId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_commission_settlement_proofs_commission_settlements_tenant_~"); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.CrmConfig", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_crm_config_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.CrmWebhookLog", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_crm_webhook_log_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.CrmWebhookQueueItem", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_crm_webhook_queue_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.ReferralCode", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_referral_codes_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_referral_codes_users_user_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.ReferralLead", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("AssignedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_referral_leads_users_assigned_by"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("AssignedToUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_referral_leads_users_assigned_to_user_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ReferrerUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_referral_leads_users_referrer_user_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("StudentUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_referral_leads_users_student_user_id"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_referral_leads_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Growth.ReferralTrack", null) + .WithMany() + .HasForeignKey("TenantId", "FirstTrackId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_referral_leads_referral_tracks_tenant_id_first_track_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.ReferralQrcode", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_referral_qrcodes_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_referral_qrcodes_users_user_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.ReferralTeamEdge", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("LeaderUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_referral_team_edges_users_leader_user_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("MemberUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_referral_team_edges_users_member_user_id"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_referral_team_edges_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.ReferralTrack", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ReferrerUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_referral_tracks_users_referrer_user_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("TargetUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_referral_tracks_users_target_user_id"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_referral_tracks_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Growth.ReferralLead", null) + .WithMany() + .HasForeignKey("TenantId", "LeadId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_referral_tracks_referral_leads_tenant_id_lead_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Growth.TenantCommissionSetting", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_commission_settings_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenant_commission_settings_users_updated_by"); + }); + + modelBuilder.Entity("Tiku.Domain.Identity.StudentProfile", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_student_profiles_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_student_profiles_users_user_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_student_profiles_regions_tenant_id_region_id"); + + b.HasOne("Tiku.Domain.Catalog.Major", null) + .WithMany() + .HasForeignKey("TenantId", "SelectedMajorId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_student_profiles_majors_tenant_id_selected_major_id"); + + b.HasOne("Tiku.Domain.Catalog.School", null) + .WithMany() + .HasForeignKey("TenantId", "SelectedSchoolId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_student_profiles_schools_tenant_id_selected_school_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Identity.UserIdentity", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_identities_users_user_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Import.PocketBaseImportIssue", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_pb_import_issues_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Import.PocketBaseImportRun", null) + .WithMany() + .HasForeignKey("TenantId", "RunId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_pb_import_issues_pb_import_runs_tenant_id_run_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Import.PocketBaseImportRun", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_pb_import_runs_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Import.PocketBaseRawRecord", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_pb_raw_records_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Import.PocketBaseImportRun", null) + .WithMany() + .HasForeignKey("TenantId", "RunId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_pb_raw_records_pb_import_runs_tenant_id_run_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.AnswerRecord", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_answer_records_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_answer_records_users_user_id"); + + b.HasOne("Tiku.Domain.Learning.PracticeSessionQuestion", null) + .WithMany() + .HasForeignKey("TenantId", "PracticeSessionId", "SessionQuestionId") + .HasPrincipalKey("TenantId", "PracticeSessionId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_answer_records_practice_session_questions_tenant_id_practic~"); + + b.HasOne("Tiku.Domain.Learning.PracticeSession", null) + .WithMany() + .HasForeignKey("TenantId", "UserId", "PracticeSessionId") + .HasPrincipalKey("TenantId", "UserId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_answer_records_practice_sessions_tenant_id_user_id_practice~"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.DashboardDailyStat", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_dashboard_daily_stats_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_dashboard_daily_stats_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.ExamDate", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_exam_dates_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_exam_dates_regions_tenant_id_region_id"); + + b.HasOne("Tiku.Domain.Catalog.School", null) + .WithMany() + .HasForeignKey("TenantId", "SchoolId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_exam_dates_schools_tenant_id_school_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.FavoriteQuestion", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_favorite_questions_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_favorite_questions_users_user_id"); + + b.HasOne("Tiku.Domain.QuestionBanks.Question", null) + .WithMany() + .HasForeignKey("QuestionOwnerTenantId", "QuestionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_favorite_questions_questions_question_owner_tenant_id_quest~"); + + b.HasOne("Tiku.Domain.Content.TenantQuestionReference", null) + .WithMany() + .HasForeignKey("TenantId", "QuestionReferenceId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_favorite_questions_tenant_question_references_tenant_id_que~"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.PracticeAccessEvent", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_practice_access_events_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_practice_access_events_users_user_id"); + + b.HasOne("Tiku.Domain.Learning.PracticeSession", null) + .WithMany() + .HasForeignKey("TenantId", "PracticeSessionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_practice_access_events_practice_sessions_tenant_id_practice~"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.PracticeDailyUsage", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_practice_daily_usage_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_practice_daily_usage_users_user_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.PracticeSession", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_practice_sessions_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_practice_sessions_users_user_id"); + + b.HasOne("Tiku.Domain.Content.PracticeBlueprint", null) + .WithMany() + .HasForeignKey("TenantId", "BlueprintId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_practice_sessions_practice_blueprints_tenant_id_blueprint_id"); + + b.HasOne("Tiku.Domain.Content.QuestionCollection", null) + .WithMany() + .HasForeignKey("TenantId", "CollectionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_practice_sessions_question_collections_tenant_id_collection~"); + + b.HasOne("Tiku.Domain.Content.ContentNode", null) + .WithMany() + .HasForeignKey("TenantId", "ContentNodeId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_practice_sessions_content_nodes_tenant_id_content_node_id"); + + b.HasOne("Tiku.Domain.Content.ContentEntry", null) + .WithMany() + .HasForeignKey("TenantId", "EntryId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_practice_sessions_content_entries_tenant_id_entry_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.PracticeSessionQuestion", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_practice_session_questions_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Learning.PracticeSession", null) + .WithMany() + .HasForeignKey("TenantId", "PracticeSessionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_practice_session_questions_practice_sessions_tenant_id_prac~"); + + b.HasOne("Tiku.Domain.QuestionBanks.QuestionVersion", null) + .WithMany() + .HasForeignKey("QuestionOwnerTenantId", "QuestionId", "QuestionVersionId") + .HasPrincipalKey("TenantId", "QuestionId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_practice_session_questions_question_versions_question_owner~"); + + b.HasOne("Tiku.Domain.Content.TenantQuestionReference", null) + .WithMany() + .HasForeignKey("TenantId", "QuestionOwnerTenantId", "QuestionId") + .HasPrincipalKey("TenantId", "QuestionOwnerTenantId", "QuestionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_practice_session_questions_tenant_question_references_tenan~"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.PracticeSessionReport", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_practice_session_reports_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_practice_session_reports_users_user_id"); + + b.HasOne("Tiku.Domain.Content.PracticeBlueprint", null) + .WithMany() + .HasForeignKey("TenantId", "BlueprintId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_practice_session_reports_practice_blueprints_tenant_id_blue~"); + + b.HasOne("Tiku.Domain.Content.QuestionCollection", null) + .WithMany() + .HasForeignKey("TenantId", "CollectionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_practice_session_reports_question_collections_tenant_id_col~"); + + b.HasOne("Tiku.Domain.Learning.PracticeSession", null) + .WithMany() + .HasForeignKey("TenantId", "PracticeSessionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_practice_session_reports_practice_sessions_tenant_id_practi~"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.PracticeSessionReportSection", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_practice_session_report_sections_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Learning.PracticeSession", null) + .WithMany() + .HasForeignKey("TenantId", "PracticeSessionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_practice_session_report_sections_practice_sessions_tenant_i~"); + + b.HasOne("Tiku.Domain.Learning.PracticeSessionReport", null) + .WithMany() + .HasForeignKey("TenantId", "ReportId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_practice_session_report_sections_practice_session_reports_t~"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.RecentPractice", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_recent_practices_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_recent_practices_users_user_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.Report", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("HandledBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_reports_users_handled_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_reports_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_reports_users_user_id"); + + b.HasOne("Tiku.Domain.QuestionBanks.Question", null) + .WithMany() + .HasForeignKey("TenantId", "QuestionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_reports_questions_tenant_id_question_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.ReportStatusEvent", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ActorUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_report_status_events_users_actor_user_id"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_report_status_events_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Learning.Report", null) + .WithMany() + .HasForeignKey("TenantId", "ReportId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_report_status_events_reports_tenant_id_report_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.RevenueDailyStat", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_revenue_daily_stats_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_revenue_daily_stats_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.UserScoreEvent", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_score_events_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_score_events_users_user_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Learning.WrongQuestion", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_wrong_questions_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_wrong_questions_users_user_id"); + + b.HasOne("Tiku.Domain.QuestionBanks.Question", null) + .WithMany() + .HasForeignKey("QuestionOwnerTenantId", "QuestionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_wrong_questions_questions_question_owner_tenant_id_question~"); + + b.HasOne("Tiku.Domain.Content.TenantQuestionReference", null) + .WithMany() + .HasForeignKey("TenantId", "QuestionReferenceId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_wrong_questions_tenant_question_references_tenant_id_questi~"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.Announcement", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_announcements_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.AuditLog", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ActorUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_audit_logs_users_actor_user_id"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_audit_logs_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.BackendMenu", b => + { + b.HasOne("Tiku.Domain.Operations.BackendPermission", null) + .WithMany() + .HasForeignKey("PermissionCode") + .HasPrincipalKey("Code") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_backend_menus_backend_permissions_permission_code"); + }); + + modelBuilder.Entity("Tiku.Domain.Operations.BackendPermission", b => + { + b.HasOne("Tiku.Domain.Platform.PermissionModule", null) + .WithMany() + .HasForeignKey("PermissionModuleCode") + .HasPrincipalKey("Code") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_backend_permissions_permission_modules_permission_module_co~"); + }); + + 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.PermissionModule", b => + { + b.HasOne("Tiku.Domain.Platform.SaasFeature", null) + .WithMany() + .HasForeignKey("RequiredFeatureCode") + .HasPrincipalKey("Code") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_permission_modules_saas_features_required_feature_code"); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformAuditAlert", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("AcknowledgedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_platform_audit_alerts_users_acknowledged_by"); + + b.HasOne("Tiku.Domain.Operations.AuditLog", null) + .WithMany() + .HasForeignKey("AuditLogId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_audit_alerts_audit_logs_audit_log_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("ResolvedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_platform_audit_alerts_users_resolved_by"); + + b.HasOne("Tiku.Domain.Platform.PlatformAuditAlertRule", null) + .WithMany() + .HasForeignKey("RuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_audit_alerts_platform_audit_alert_rules_rule_id"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_platform_audit_alerts_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformAuditAlertRule", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_platform_audit_alert_rules_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformBillingDunningNotificationEvent", b => + { + b.HasOne("Tiku.Domain.Platform.PlatformBillingDunningNotificationChannel", null) + .WithMany() + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_billing_dunning_events_channel"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_billing_dunning_notification_events_tenants_tenant~"); + + b.HasOne("Tiku.Domain.Platform.PlatformBillingInvoice", null) + .WithMany() + .HasForeignKey("TenantId", "InvoiceId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_billing_dunning_events_invoice"); + + b.HasOne("Tiku.Domain.Platform.PlatformBillingInvoiceReminder", null) + .WithMany() + .HasForeignKey("TenantId", "ReminderId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_billing_dunning_events_reminder"); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformBillingInvoice", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_billing_invoices_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Platform.PlatformBillingOrder", null) + .WithMany() + .HasForeignKey("TenantId", "OrderId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_platform_billing_invoices_platform_billing_orders_tenant_id~"); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformBillingInvoiceReminder", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_platform_billing_invoice_reminders_users_created_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_billing_invoice_reminders_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Platform.PlatformBillingInvoice", null) + .WithMany() + .HasForeignKey("TenantId", "InvoiceId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_billing_invoice_reminders_platform_billing_invoice~"); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformBillingOrder", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_billing_orders_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Platform.PlatformBillingQuote", null) + .WithMany() + .HasForeignKey("TenantId", "QuoteId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_platform_billing_orders_platform_billing_quotes_tenant_id_q~"); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformBillingOrderItem", b => + { + b.HasOne("Tiku.Domain.Platform.SaasOfferingVersion", null) + .WithMany() + .HasForeignKey("OfferingVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_platform_billing_order_items_saas_offering_versions_offerin~"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_billing_order_items_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Platform.PlatformBillingOrder", null) + .WithMany() + .HasForeignKey("TenantId", "OrderId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_billing_order_items_platform_billing_orders_tenant~"); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformBillingPayment", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_billing_payments_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Platform.PlatformBillingOrder", null) + .WithMany() + .HasForeignKey("TenantId", "OrderId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_billing_payments_platform_billing_orders_tenant_id~"); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformBillingPaymentEvent", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_billing_payment_events_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Platform.PlatformBillingPayment", null) + .WithMany() + .HasForeignKey("TenantId", "PaymentId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_billing_payment_events_platform_billing_payments_t~"); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformBillingQuote", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_billing_quotes_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformBillingQuoteItem", b => + { + b.HasOne("Tiku.Domain.Platform.SaasOfferingVersion", null) + .WithMany() + .HasForeignKey("OfferingVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_platform_billing_quote_items_saas_offering_versions_offerin~"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_billing_quote_items_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Platform.PlatformBillingQuote", null) + .WithMany() + .HasForeignKey("TenantId", "QuoteId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_billing_quote_items_platform_billing_quotes_tenant~"); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformBillingRefund", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_billing_refunds_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Platform.PlatformBillingOrder", null) + .WithMany() + .HasForeignKey("TenantId", "OrderId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_platform_billing_refunds_platform_billing_orders_tenant_id_~"); + + b.HasOne("Tiku.Domain.Platform.PlatformBillingPayment", null) + .WithMany() + .HasForeignKey("TenantId", "PaymentId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_platform_billing_refunds_platform_billing_payments_tenant_i~"); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.PlatformPaymentChannel", b => + { + b.HasOne("Tiku.Domain.Platform.PlatformPaymentApp", null) + .WithMany() + .HasForeignKey("AppId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_platform_payment_channels_platform_payment_apps_app_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.SaasFeatureLimitDefinition", b => + { + b.HasOne("Tiku.Domain.Platform.SaasFeature", null) + .WithMany() + .HasForeignKey("FeatureCode") + .HasPrincipalKey("Code") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_saas_feature_limit_definitions_saas_features_feature_code"); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.SaasOfferingVersion", b => + { + b.HasOne("Tiku.Domain.Platform.SaasOffering", null) + .WithMany() + .HasForeignKey("OfferingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_saas_offering_versions_saas_offerings_offering_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.SaasOfferingVersionFeature", b => + { + b.HasOne("Tiku.Domain.Platform.SaasFeature", null) + .WithMany() + .HasForeignKey("FeatureCode") + .HasPrincipalKey("Code") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_saas_offering_version_features_saas_features_feature_code"); + + b.HasOne("Tiku.Domain.Platform.SaasOfferingVersion", null) + .WithMany() + .HasForeignKey("OfferingVersionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_saas_offering_version_features_saas_offering_versions_offer~"); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.SaasOfferingVersionLimit", b => + { + b.HasOne("Tiku.Domain.Platform.SaasFeatureLimitDefinition", null) + .WithMany() + .HasForeignKey("MetricCode") + .HasPrincipalKey("MetricCode") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_saas_offering_version_limits_saas_feature_limit_definitions~"); + + b.HasOne("Tiku.Domain.Platform.SaasOfferingVersion", null) + .WithMany() + .HasForeignKey("OfferingVersionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_saas_offering_version_limits_saas_offering_versions_offerin~"); + }); + + 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.TenantFeatureOverride", b => + { + b.HasOne("Tiku.Domain.Platform.SaasFeature", null) + .WithMany() + .HasForeignKey("FeatureCode") + .HasPrincipalKey("Code") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_tenant_feature_overrides_saas_features_feature_code"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_feature_overrides_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.TenantFeatureUsage", b => + { + b.HasOne("Tiku.Domain.Platform.SaasFeatureLimitDefinition", null) + .WithMany() + .HasForeignKey("MetricCode") + .HasPrincipalKey("MetricCode") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_tenant_feature_usage_saas_feature_limit_definitions_metric_~"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_feature_usage_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.TenantSaasSubscription", b => + { + b.HasOne("Tiku.Domain.Platform.SaasOfferingVersion", null) + .WithMany() + .HasForeignKey("BaseOfferingVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_tenant_saas_subscriptions_saas_offering_versions_base_offer~"); + + b.HasOne("Tiku.Domain.Platform.SaasOfferingVersion", null) + .WithMany() + .HasForeignKey("ScheduledBaseOfferingVersionId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_tenant_saas_subscriptions_saas_offering_versions_scheduled_~"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_saas_subscriptions_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Platform.TenantSaasSubscriptionItem", b => + { + b.HasOne("Tiku.Domain.Platform.SaasOfferingVersion", null) + .WithMany() + .HasForeignKey("OfferingVersionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_tenant_saas_subscription_items_saas_offering_versions_offer~"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_saas_subscription_items_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Platform.PlatformBillingOrderItem", null) + .WithMany() + .HasForeignKey("TenantId", "SourceOrderItemId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_tenant_saas_subscription_items_platform_billing_order_items~"); + + b.HasOne("Tiku.Domain.Platform.TenantSaasSubscription", null) + .WithMany() + .HasForeignKey("TenantId", "SubscriptionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_saas_subscription_items_tenant_saas_subscriptions_te~"); + }); + + 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.Content.ContentEntry", null) + .WithMany() + .HasForeignKey("TenantId", "ContentEntryId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_question_banks_content_entries_tenant_id_content_entry_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.SmsChannel", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_sms_channels_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.SmsSendLog", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_sms_send_logs_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Tenancy.SmsChannel", null) + .WithMany() + .HasForeignKey("TenantId", "ChannelId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_sms_send_logs_sms_channels_tenant_id_channel_id"); + + b.HasOne("Tiku.Domain.Tenancy.SmsTemplate", null) + .WithMany() + .HasForeignKey("TenantId", "TemplateId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_sms_send_logs_sms_templates_tenant_id_template_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.SmsTemplate", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_sms_templates_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Tenancy.SmsChannel", null) + .WithMany() + .HasForeignKey("TenantId", "ChannelId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_sms_templates_sms_channels_tenant_id_channel_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.SmsVerificationCode", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_sms_verification_codes_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.Tenant", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenants_users_owner_user_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantAuthPolicy", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithOne() + .HasForeignKey("Tiku.Domain.Tenancy.TenantAuthPolicy", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_auth_policies_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantBranding", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithOne() + .HasForeignKey("Tiku.Domain.Tenancy.TenantBranding", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_branding_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantClass", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenant_classes_users_created_by"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_classes_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenant_classes_users_updated_by"); + + b.HasOne("Tiku.Domain.Catalog.Region", null) + .WithMany() + .HasForeignKey("TenantId", "RegionId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_tenant_classes_regions_tenant_id_region_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantClassMember", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_class_members_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_class_members_users_user_id"); + + b.HasOne("Tiku.Domain.Tenancy.TenantClass", null) + .WithMany() + .HasForeignKey("TenantId", "ClassId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_class_members_tenant_classes_tenant_id_class_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantDomain", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_domains_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantExternalProvider", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_external_providers_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantFrontendConfig", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_frontend_configs_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantMembership", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_memberships_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_memberships_users_user_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantSecret", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_secrets_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantSettings", b => + { + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithOne() + .HasForeignKey("Tiku.Domain.Tenancy.TenantSettings", "TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_settings_tenants_tenant_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantStudentFollowup", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("AssignedToUserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenant_student_followups_users_assigned_to_user_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CompletedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenant_student_followups_users_completed_by"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("StudentUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_student_followups_users_student_user_id"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_student_followups_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Tenancy.TenantClass", null) + .WithMany() + .HasForeignKey("TenantId", "ClassId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenant_student_followups_tenant_classes_tenant_id_class_id"); + }); + + modelBuilder.Entity("Tiku.Domain.Tenancy.TenantStudentNote", b => + { + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenant_student_notes_users_created_by"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("StudentUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_student_notes_users_student_user_id"); + + b.HasOne("Tiku.Domain.Tenancy.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tenant_student_notes_tenants_tenant_id"); + + b.HasOne("Tiku.Domain.Identity.User", null) + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("fk_tenant_student_notes_users_updated_by"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Tiku.Infrastructure/Persistence/Migrations/20260730034205_AddPlatformQuestionBankWorkspace.cs b/Tiku.Infrastructure/Persistence/Migrations/20260730034205_AddPlatformQuestionBankWorkspace.cs new file mode 100644 index 0000000..b49bee2 --- /dev/null +++ b/Tiku.Infrastructure/Persistence/Migrations/20260730034205_AddPlatformQuestionBankWorkspace.cs @@ -0,0 +1,80 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Tiku.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddPlatformQuestionBankWorkspace : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "content_entry_id", + table: "question_banks", + type: "uuid", + nullable: true); + + migrationBuilder.Sql(""" + INSERT INTO content_entries ( + id, tenant_id, entry_key, name, entry_type, visibility, + access_rules, layout_config, sort_order, is_active, created_at, updated_at) + SELECT + qb.id, + qb.tenant_id, + 'public-question-bank-' || replace(qb.id::text, '-', ''), + qb.name, + 'question_practice', + 'public', + '{}'::jsonb, + '{}'::jsonb, + 0, + true, + qb.created_at, + qb.updated_at + FROM question_banks qb + JOIN tenants t ON t.id = qb.tenant_id AND t.mode = 'platform_owned' + WHERE NOT EXISTS (SELECT 1 FROM content_entries ce WHERE ce.id = qb.id); + + UPDATE question_banks qb + SET content_entry_id = qb.id + FROM tenants t + WHERE t.id = qb.tenant_id + AND t.mode = 'platform_owned' + AND qb.content_entry_id IS NULL + AND EXISTS (SELECT 1 FROM content_entries ce WHERE ce.tenant_id = qb.tenant_id AND ce.id = qb.id); + """); + + migrationBuilder.CreateIndex( + name: "ix_question_banks_tenant_id_content_entry_id", + table: "question_banks", + columns: new[] { "tenant_id", "content_entry_id" }); + + migrationBuilder.AddForeignKey( + name: "fk_question_banks_content_entries_tenant_id_content_entry_id", + table: "question_banks", + columns: new[] { "tenant_id", "content_entry_id" }, + principalTable: "content_entries", + principalColumns: new[] { "tenant_id", "id" }, + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "fk_question_banks_content_entries_tenant_id_content_entry_id", + table: "question_banks"); + + migrationBuilder.DropIndex( + name: "ix_question_banks_tenant_id_content_entry_id", + table: "question_banks"); + + migrationBuilder.DropColumn( + name: "content_entry_id", + table: "question_banks"); + } + } +} diff --git a/Tiku.Infrastructure/Persistence/Migrations/TikuDbContextModelSnapshot.cs b/Tiku.Infrastructure/Persistence/Migrations/TikuDbContextModelSnapshot.cs index 48ce782..2b05e70 100644 --- a/Tiku.Infrastructure/Persistence/Migrations/TikuDbContextModelSnapshot.cs +++ b/Tiku.Infrastructure/Persistence/Migrations/TikuDbContextModelSnapshot.cs @@ -14290,6 +14290,10 @@ namespace Tiku.Infrastructure.Persistence.Migrations .HasColumnName("id") .HasDefaultValueSql("gen_random_uuid()"); + b.Property("ContentEntryId") + .HasColumnType("uuid") + .HasColumnName("content_entry_id"); + b.Property("CreatedAt") .ValueGeneratedOnAdd() .HasColumnType("timestamp with time zone") @@ -14334,6 +14338,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations b.HasAlternateKey("TenantId", "Id") .HasName("ak_question_banks_tenant_id_id"); + b.HasIndex("TenantId", "ContentEntryId") + .HasDatabaseName("ix_question_banks_tenant_id_content_entry_id"); + b.HasIndex("TenantId", "RegionId") .HasDatabaseName("ix_question_banks_tenant_id_region_id"); @@ -19706,6 +19713,13 @@ namespace Tiku.Infrastructure.Persistence.Migrations .IsRequired() .HasConstraintName("fk_question_banks_tenants_tenant_id"); + b.HasOne("Tiku.Domain.Content.ContentEntry", null) + .WithMany() + .HasForeignKey("TenantId", "ContentEntryId") + .HasPrincipalKey("TenantId", "Id") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_question_banks_content_entries_tenant_id_content_entry_id"); + b.HasOne("Tiku.Domain.Catalog.Region", null) .WithMany() .HasForeignKey("TenantId", "RegionId") diff --git a/Tiku.Infrastructure/PlatformAdmin/PlatformQuestionBankService.cs b/Tiku.Infrastructure/PlatformAdmin/PlatformQuestionBankService.cs new file mode 100644 index 0000000..bdd1b48 --- /dev/null +++ b/Tiku.Infrastructure/PlatformAdmin/PlatformQuestionBankService.cs @@ -0,0 +1,711 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Tiku.Application.Assets; +using Tiku.Application.PlatformAdmin; +using Tiku.Application.Security; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Operations; +using Tiku.Domain.QuestionBanks; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.PlatformAdmin; + +internal sealed class PlatformQuestionBankService( + ICurrentAccessContext currentAccessContext, + ITenantExecutionScope tenantExecutionScope) : IPlatformQuestionBankService +{ + private const int MaxPageSize = 100; + private static readonly HashSet SupportedQuestionTypes = new(StringComparer.OrdinalIgnoreCase) + { + "choice", "multiple_choice", "true_false", "fill_blank", "short_answer", "reading", "programming" + }; + + public Task> GetBanksAsync( + PlatformAdminActor actor, + PlatformQuestionBankFilter filter, + CancellationToken cancellationToken = default) => + ExecuteAsync>(actor, "查询平台公共题库", async (_, dbContext, tenantId, token) => + { + var query = dbContext.QuestionBanks.AsNoTracking().Where(item => item.TenantId == tenantId); + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(item => item.Name.Contains(keyword)); + } + + if (!string.IsNullOrWhiteSpace(filter.Status) && !filter.Status.Equals("all", StringComparison.OrdinalIgnoreCase)) + { + var status = ParseBankStatus(filter.Status); + query = query.Where(item => item.Status == status); + } + + var banks = await query.OrderBy(item => item.Name).ThenBy(item => item.CreatedAt).ToArrayAsync(token); + var entryIds = banks.Where(item => item.ContentEntryId.HasValue).Select(item => item.ContentEntryId!.Value).ToArray(); + var nodeCounts = await dbContext.ContentNodes.AsNoTracking() + .Where(item => item.TenantId == tenantId && entryIds.Contains(item.EntryId) && item.IsActive) + .GroupBy(item => item.EntryId) + .Select(group => new { EntryId = group.Key, Count = group.Count() }) + .ToDictionaryAsync(item => item.EntryId, item => item.Count, token); + var questionCounts = await dbContext.Questions.AsNoTracking() + .Where(item => item.TenantId == tenantId && item.QuestionBankId.HasValue && item.Status != QuestionStatus.Archived) + .GroupBy(item => item.QuestionBankId!.Value) + .Select(group => new { BankId = group.Key, Count = group.Count() }) + .ToDictionaryAsync(item => item.BankId, item => item.Count, token); + return banks.Select(item => ToBankItem( + item, + item.ContentEntryId.HasValue && nodeCounts.TryGetValue(item.ContentEntryId.Value, out var nodes) ? nodes : 0, + questionCounts.TryGetValue(item.Id, out var questions) ? questions : 0)).ToArray(); + }, cancellationToken); + + public Task UpsertBankAsync( + PlatformAdminActor actor, + UpsertPlatformQuestionBankCommand command, + CancellationToken cancellationToken = default) => + ExecuteAsync(actor, "保存平台公共题库", async (_, dbContext, tenantId, token) => + { + if (string.IsNullOrWhiteSpace(command.Name)) + { + throw Error("题库名称不能为空。", "question_bank_name_required"); + } + + var bank = command.Id.HasValue + ? await dbContext.QuestionBanks.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == command.Id.Value, token) + : null; + if (command.Id.HasValue && bank is null) + { + throw Error("公共题库不存在。", "question_bank_not_found"); + } + + ContentEntry? trackedEntry = null; + if (bank is null) + { + var entry = new ContentEntry + { + TenantId = tenantId, + EntryKey = $"public-question-bank-{Guid.NewGuid():N}", + Name = command.Name.Trim(), + EntryType = ContentEntryType.QuestionPractice, + Visibility = ContentVisibility.Public, + IsActive = true, + CreatedBy = actor.UserId, + UpdatedBy = actor.UserId + }; + trackedEntry = entry; + bank = new QuestionBank + { + TenantId = tenantId, + ContentEntryId = entry.Id, + Status = QuestionBankStatus.Active + }; + dbContext.ContentEntries.Add(entry); + dbContext.QuestionBanks.Add(bank); + } + else if (!bank.ContentEntryId.HasValue) + { + var entry = new ContentEntry + { + TenantId = tenantId, + EntryKey = $"public-question-bank-{bank.Id:N}", + Name = command.Name.Trim(), + EntryType = ContentEntryType.QuestionPractice, + Visibility = ContentVisibility.Public, + IsActive = true, + CreatedBy = actor.UserId, + UpdatedBy = actor.UserId + }; + trackedEntry = entry; + dbContext.ContentEntries.Add(entry); + bank.ContentEntryId = entry.Id; + } + + bank.Name = command.Name.Trim(); + bank.Metadata = ObjectOrDefault(command.Metadata); + if (bank.ContentEntryId.HasValue) + { + var entry = trackedEntry ?? await dbContext.ContentEntries.SingleAsync(item => item.TenantId == tenantId && item.Id == bank.ContentEntryId.Value, token); + entry.Name = bank.Name; + entry.UpdatedBy = actor.UserId; + } + AddAudit(dbContext, actor, "platform.question_bank.saved", bank.Id, new { bank.Name }); + await dbContext.SaveChangesAsync(token); + return ToBankItem(bank, 0, 0); + }, cancellationToken); + + public Task ArchiveBankAsync( + PlatformAdminActor actor, + Guid bankId, + CancellationToken cancellationToken = default) => + ExecuteAsync(actor, "归档平台公共题库", async (_, dbContext, tenantId, token) => + { + var bank = await RequireBankAsync(dbContext, tenantId, bankId, token); + if (await dbContext.Questions.AnyAsync(item => item.TenantId == tenantId && item.QuestionBankId == bankId && item.Status != QuestionStatus.Archived, token)) + { + throw Error("题库中仍有未归档题目,不能归档题库。", "question_bank_not_empty"); + } + if (bank.ContentEntryId.HasValue && await dbContext.ContentNodes.AnyAsync(item => item.TenantId == tenantId && item.EntryId == bank.ContentEntryId && item.IsActive, token)) + { + throw Error("题库中仍有启用的内容层级,不能归档题库。", "question_bank_nodes_not_archived"); + } + bank.Status = QuestionBankStatus.Archived; + AddAudit(dbContext, actor, "platform.question_bank.archived", bank.Id, new { bank.Name }); + await dbContext.SaveChangesAsync(token); + return ToBankItem(bank, 0, 0); + }, cancellationToken); + + public Task> GetNodesAsync( + PlatformAdminActor actor, + Guid bankId, + CancellationToken cancellationToken = default) => + ExecuteAsync>(actor, "查询公共题库内容结构", async (_, dbContext, tenantId, token) => + { + var bank = await RequireBankWithEntryAsync(dbContext, tenantId, bankId, token); + var nodes = await dbContext.ContentNodes.AsNoTracking() + .Where(item => item.TenantId == tenantId && item.EntryId == bank.ContentEntryId) + .OrderBy(item => item.Depth).ThenBy(item => item.SortOrder).ThenBy(item => item.Name) + .ToArrayAsync(token); + var counts = await dbContext.Questions.AsNoTracking() + .Where(item => item.TenantId == tenantId && item.QuestionBankId == bankId && item.ContentNodeId.HasValue && item.Status != QuestionStatus.Archived) + .GroupBy(item => item.ContentNodeId!.Value) + .Select(group => new { NodeId = group.Key, Count = group.Count() }) + .ToDictionaryAsync(item => item.NodeId, item => item.Count, token); + return nodes.Select(item => ToNodeItem(item, bankId, counts.TryGetValue(item.Id, out var count) ? count : 0)).ToArray(); + }, cancellationToken); + + public Task UpsertNodeAsync( + PlatformAdminActor actor, + UpsertPlatformQuestionBankNodeCommand command, + CancellationToken cancellationToken = default) => + ExecuteAsync(actor, "保存公共题库内容节点", async (_, dbContext, tenantId, token) => + { + var bank = await RequireBankWithEntryAsync(dbContext, tenantId, command.QuestionBankId, token); + return await UpsertNodeCoreAsync(dbContext, actor, tenantId, bank, command, token); + }, cancellationToken); + + public Task> BatchCreateNodesAsync( + PlatformAdminActor actor, + BatchCreatePlatformQuestionBankNodesCommand command, + CancellationToken cancellationToken = default) => + ExecuteAsync>(actor, "批量创建章节或试卷", async (_, dbContext, tenantId, token) => + { + if (command.NodeType is not ContentNodeType.Chapter and not ContentNodeType.Paper) + { + throw Error("批量创建仅支持章节或试卷。", "node_batch_type_invalid"); + } + var bank = await RequireBankWithEntryAsync(dbContext, tenantId, command.QuestionBankId, token); + var names = command.Names.Select(item => item.Trim()).Where(item => item.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + if (names.Length is 0 or > 100) + { + throw Error("请提供 1 至 100 个不重复的名称。", "node_batch_names_invalid"); + } + var result = new List(); + var order = 0; + foreach (var name in names) + { + result.Add(await UpsertNodeCoreAsync(dbContext, actor, tenantId, bank, new UpsertPlatformQuestionBankNodeCommand( + null, command.QuestionBankId, command.ParentId, null, name, command.NodeType, order++, true, JsonDefaults.Object()), token)); + } + return result; + }, cancellationToken); + + public Task ArchiveNodeAsync( + PlatformAdminActor actor, + Guid nodeId, + CancellationToken cancellationToken = default) => + ExecuteAsync(actor, "归档公共题库内容节点", async (_, dbContext, tenantId, token) => + { + var node = await dbContext.ContentNodes.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == nodeId, token) + ?? throw Error("内容节点不存在。", "question_bank_node_not_found"); + if (await dbContext.ContentNodes.AnyAsync(item => item.TenantId == tenantId && item.ParentId == nodeId && item.IsActive, token)) + { + throw Error("该节点仍有启用的下级节点,不能归档。", "question_bank_node_has_children"); + } + if (await dbContext.Questions.AnyAsync(item => item.TenantId == tenantId && item.ContentNodeId == nodeId && item.Status != QuestionStatus.Archived, token)) + { + throw Error("该节点仍有未归档题目,不能归档。", "question_bank_node_has_questions"); + } + node.IsActive = false; + node.UpdatedBy = actor.UserId; + var bankId = await dbContext.QuestionBanks.Where(item => item.TenantId == tenantId && item.ContentEntryId == node.EntryId).Select(item => item.Id).SingleAsync(token); + AddAudit(dbContext, actor, "platform.question_bank.node_archived", node.Id, new { node.Name }); + await dbContext.SaveChangesAsync(token); + return ToNodeItem(node, bankId, 0); + }, cancellationToken); + + public Task GetQuestionsAsync( + PlatformAdminActor actor, + PlatformQuestionBankFilter filter, + CancellationToken cancellationToken = default) => + ExecuteAsync(actor, "查询公共题库题目", async (_, dbContext, tenantId, token) => + { + if (!filter.QuestionBankId.HasValue) + { + throw Error("请选择公共题库。", "question_bank_id_required"); + } + await RequireBankAsync(dbContext, tenantId, filter.QuestionBankId.Value, token); + var query = dbContext.Questions.AsNoTracking().Where(item => item.TenantId == tenantId && item.QuestionBankId == filter.QuestionBankId); + if (filter.ContentNodeId.HasValue) query = query.Where(item => item.ContentNodeId == filter.ContentNodeId); + if (!string.IsNullOrWhiteSpace(filter.Type)) { var type = filter.Type.Trim(); query = query.Where(item => item.Type == type); } + if (filter.Difficulty.HasValue) query = query.Where(item => item.Difficulty == filter.Difficulty); + if (!string.IsNullOrWhiteSpace(filter.Status) && !filter.Status.Equals("all", StringComparison.OrdinalIgnoreCase)) + { + var status = ParseQuestionStatus(filter.Status); + query = query.Where(item => item.Status == status); + } + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(item => item.Type.Contains(keyword) || dbContext.QuestionVersions.Any(version => version.TenantId == tenantId && version.QuestionId == item.Id && version.Id == item.CurrentVersionId && version.Content != null && version.Content.Contains(keyword))); + } + var page = Math.Max(1, filter.Page); + var pageSize = Math.Clamp(filter.PageSize, 1, MaxPageSize); + var total = await query.CountAsync(token); + var questions = await query.OrderByDescending(item => item.UpdatedAt).Skip((page - 1) * pageSize).Take(pageSize).ToArrayAsync(token); + var versionIds = questions.Where(item => item.CurrentVersionId.HasValue).Select(item => item.CurrentVersionId!.Value).ToArray(); + var versions = await dbContext.QuestionVersions.AsNoTracking().Where(item => item.TenantId == tenantId && versionIds.Contains(item.Id)).ToDictionaryAsync(item => item.Id, token); + return new PlatformQuestionPage(questions.Select(item => ToQuestionItem(item, item.CurrentVersionId.HasValue && versions.TryGetValue(item.CurrentVersionId.Value, out var version) ? version : null)).ToArray(), total, page, pageSize); + }, cancellationToken); + + public Task UpsertQuestionAsync( + PlatformAdminActor actor, + UpsertPlatformQuestionCommand command, + CancellationToken cancellationToken = default) => + ExecuteAsync(actor, "保存公共题库题目", async (_, dbContext, tenantId, token) => + { + var bank = await RequireBankWithEntryAsync(dbContext, tenantId, command.QuestionBankId, token); + var node = await RequireNodeAsync(dbContext, tenantId, bank, command.ContentNodeId, token); + ValidateQuestion(command); + var question = command.Id.HasValue + ? await dbContext.Questions.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == command.Id.Value && item.QuestionBankId == bank.Id, token) + : null; + if (command.Id.HasValue && question is null) throw Error("题目不存在。", "question_not_found"); + question ??= new Question { TenantId = tenantId, QuestionBankId = bank.Id, EntryId = bank.ContentEntryId, CreatedAt = DateTimeOffset.UtcNow }; + if (!command.Id.HasValue) dbContext.Questions.Add(question); + ApplyQuestion(question, command, bank.ContentEntryId!.Value, node.Id); + await dbContext.SaveChangesAsync(token); + var nextVersion = await dbContext.QuestionVersions.Where(item => item.TenantId == tenantId && item.QuestionId == question.Id).Select(item => (int?)item.VersionNo).MaxAsync(token) ?? 0; + var version = BuildVersion(actor, tenantId, question.Id, nextVersion + 1, command); + dbContext.QuestionVersions.Add(version); + question.CurrentVersionId = version.Id; + AddAudit(dbContext, actor, "platform.question_bank.question_saved", question.Id, new { bankId = bank.Id, nodeId = node.Id, version = version.VersionNo }); + await dbContext.SaveChangesAsync(token); + return ToQuestionItem(question, version); + }, cancellationToken); + + public Task ArchiveQuestionsAsync( + PlatformAdminActor actor, + ArchivePlatformQuestionsCommand command, + CancellationToken cancellationToken = default) => + ExecuteAsync(actor, "归档公共题库题目", async (_, dbContext, tenantId, token) => + { + var ids = command.QuestionIds.Distinct().Take(500).ToArray(); + if (ids.Length == 0) throw Error("请选择需要归档的题目。", "question_ids_required"); + var questions = await dbContext.Questions.Where(item => item.TenantId == tenantId && ids.Contains(item.Id)).ToArrayAsync(token); + foreach (var question in questions) question.Status = QuestionStatus.Archived; + AddAudit(dbContext, actor, "platform.question_bank.questions_archived", Guid.NewGuid(), new { questionIds = questions.Select(item => item.Id).ToArray() }); + await dbContext.SaveChangesAsync(token); + return questions.Length; + }, cancellationToken); + + public Task PreviewImportAsync(PlatformAdminActor actor, PlatformQuestionImportCommand command, CancellationToken cancellationToken = default) => + ImportAsync(actor, command, false, cancellationToken); + + public Task ExecuteImportAsync(PlatformAdminActor actor, PlatformQuestionImportCommand command, CancellationToken cancellationToken = default) => + ImportAsync(actor, command, true, cancellationToken); + + public Task GetImportAsync(PlatformAdminActor actor, Guid jobId, CancellationToken cancellationToken = default) => + ExecuteAsync(actor, "查询公共题库导入结果", async (_, dbContext, tenantId, token) => await LoadImportDetailAsync(dbContext, tenantId, jobId, token), cancellationToken); + + public Task SignQuestionAssetUploadAsync(PlatformAdminActor actor, AssetUploadSignCommand command, CancellationToken cancellationToken = default) => + ExecuteAsync(actor, "签发公共题库图片上传地址", async (provider, _, tenantId, token) => + await provider.GetRequiredService().SignUploadAsync(new AssetManagementActor(tenantId, actor.UserId), command with { AssetType = "image", Category = "question", IsPublic = true }, token), cancellationToken); + + public Task ConfirmQuestionAssetUploadAsync(PlatformAdminActor actor, AssetUploadConfirmCommand command, CancellationToken cancellationToken = default) => + ExecuteAsync(actor, "确认公共题库图片上传", async (provider, _, tenantId, token) => + await provider.GetRequiredService().ConfirmUploadAsync(new AssetManagementActor(tenantId, actor.UserId), command, token), cancellationToken); + + private async Task ImportAsync(PlatformAdminActor actor, PlatformQuestionImportCommand command, bool execute, CancellationToken cancellationToken) + { + return await ExecuteAsync(actor, execute ? "执行公共题库导入" : "预检公共题库导入", async (_, dbContext, tenantId, token) => + { + var bank = await RequireBankWithEntryAsync(dbContext, tenantId, command.QuestionBankId, token); + var format = NormalizeImportFormat(command.Format); + ContentNode? targetNode = null; + if (command.ContentNodeId.HasValue) targetNode = await RequireNodeAsync(dbContext, tenantId, bank, command.ContentNodeId.Value, token); + if (format == "simple" && targetNode is null) throw Error("普通批量导入必须选择章节或试卷。", "import_target_node_required"); + + var job = new ContentImportJob + { + TenantId = tenantId, + CreatedBy = actor.UserId, + TargetQuestionBankId = bank.Id, + TargetContentNodeId = targetNode?.Id, + ImportType = ContentImportType.Questions, + SourceFormat = ImportSourceFormat.Json, + Status = execute ? ContentImportStatus.Importing : ContentImportStatus.Preview, + SourceName = string.IsNullOrWhiteSpace(command.SourceName) ? null : command.SourceName.Trim(), + SourceHash = Hash(command.Payload.GetRawText()), + DryRun = !execute, + RawPayload = command.Payload.Clone(), + StartedAt = execute ? DateTimeOffset.UtcNow : null + }; + if (execute) + { + dbContext.ContentImportJobs.Add(job); + } + + var rows = format == "simple" + ? ExtractSimpleRows(command.Payload, targetNode!.Id) + : ExtractStructuredRows(command.Payload, targetNode?.Id); + job.TotalCount = rows.Count; + var importItems = new List(); + var issues = new List(); + var createdNodes = 0; + var inserted = 0; + var updated = 0; + var skipped = 0; + var nodeCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var row in rows) + { + var item = new ContentImportItem + { + TenantId = tenantId, + JobId = job.Id, + RowNo = row.RowNo, + ExternalId = GetString(row.Question, "legacyId") ?? GetString(row.Question, "id"), + SourcePayload = row.Question.Clone(), + NormalizedPayload = row.Question.Clone() + }; + var validation = ValidateImportRow(row.Question); + if (validation is not null) + { + item.Status = ContentImportItemStatus.Invalid; + item.IssuesCount = 1; + issues.Add(NewIssue(tenantId, job.Id, item.Id, row.RowNo, validation.Value.Code, validation.Value.Field, validation.Value.Message)); + job.ErrorCount++; + importItems.Add(item); + continue; + } + job.ValidCount++; + item.Status = ContentImportItemStatus.Valid; + if (execute) + { + var node = targetNode; + if (format == "structured") + { + (node, var made) = await EnsureStructuredPathAsync(dbContext, actor, tenantId, bank, row.Path, targetNode, nodeCache, token); + createdNodes += made; + } + if (node is null) throw Error("导入题目没有可用的目标章节。", "import_target_node_required"); + var result = await UpsertImportedQuestionAsync(dbContext, actor, tenantId, bank, node, row.Question, token); + item.TargetType = "question"; + item.TargetId = result.Question.Id; + item.ContentHash = result.SourceHash; + item.Status = result.Action switch + { + "inserted" => ContentImportItemStatus.Inserted, + "updated" => ContentImportItemStatus.Updated, + _ => ContentImportItemStatus.Skipped + }; + if (result.Action == "inserted") inserted++; + else if (result.Action == "updated") updated++; + else skipped++; + } + importItems.Add(item); + } + job.InsertedCount = inserted; + job.UpdatedCount = updated; + job.SkippedCount = skipped; + job.Status = !execute ? ContentImportStatus.Preview : job.ErrorCount > 0 ? ContentImportStatus.CompletedWithErrors : ContentImportStatus.Completed; + job.FinishedAt = execute ? DateTimeOffset.UtcNow : null; + job.Summary = JsonSerializer.SerializeToElement(new { format, createdNodes, inserted, updated, skipped, invalid = job.ErrorCount }); + job.NormalizedPayload = JsonSerializer.SerializeToElement(rows.Select(item => item.Question)); + if (execute) + { + dbContext.ContentImportItems.AddRange(importItems); + dbContext.ContentImportIssues.AddRange(issues); + AddAudit(dbContext, actor, "platform.question_bank.import_executed", job.Id, new { bankId = bank.Id, format, job.TotalCount, job.ErrorCount }); + await dbContext.SaveChangesAsync(token); + } + var detail = new ContentImportJobDetail(ToJobItem(job), importItems.Select(ToImportItem).ToArray(), issues.Select(ToIssueItem).ToArray()); + return new PlatformQuestionImportResult(detail, createdNodes, inserted, updated, skipped); + }, cancellationToken); + } + + private async Task ExecuteAsync(PlatformAdminActor actor, string reason, Func> action, CancellationToken cancellationToken) + { + var access = await currentAccessContext.GetAsync(cancellationToken); + if (access.UserId != actor.UserId || !access.HasPlatformPermission(BackendPermissions.PlatformQuestionBankManage)) + { + throw Error("需要平台公共题库管理权限。", "platform_question_bank_access_denied"); + } + var correlationId = Guid.NewGuid().ToString("N"); + var platformTenantId = await tenantExecutionScope.ExecuteAsync( + new SystemScopeRequest(null, SystemScopeCallerType.Platform, nameof(PlatformQuestionBankService), "解析平台公共题库所属租户", correlationId, IsGlobal: true), + async (provider, token) => + { + var dbContext = provider.GetRequiredService(); + var tenantIds = await dbContext.Tenants.AsNoTracking().Where(item => item.Mode == TenantMode.PlatformOwned).Select(item => item.Id).Take(2).ToArrayAsync(token); + if (tenantIds.Length != 1) + { + throw Error(tenantIds.Length == 0 ? "平台内容所属租户尚未初始化,请先运行数据库迁移器。" : "检测到多个平台内容所属租户,请先修复数据。", tenantIds.Length == 0 ? "platform_question_owner_missing" : "platform_question_owner_ambiguous"); + } + return tenantIds[0]; + }, cancellationToken); + return await tenantExecutionScope.ExecuteAsync( + new SystemScopeRequest(platformTenantId, SystemScopeCallerType.Platform, nameof(PlatformQuestionBankService), reason, correlationId), + async (provider, token) => + { + var dbContext = provider.GetRequiredService(); + return await action(provider, dbContext, platformTenantId, token); + }, cancellationToken); + } + + private static async Task UpsertNodeCoreAsync(TikuDbContext dbContext, PlatformAdminActor actor, Guid tenantId, QuestionBank bank, UpsertPlatformQuestionBankNodeCommand command, CancellationToken token) + { + if (string.IsNullOrWhiteSpace(command.Name)) throw Error("节点名称不能为空。", "question_bank_node_name_required"); + ContentNode? parent = null; + if (command.ParentId.HasValue) parent = await RequireNodeAsync(dbContext, tenantId, bank, command.ParentId.Value, token); + var node = command.Id.HasValue ? await dbContext.ContentNodes.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == command.Id.Value && item.EntryId == bank.ContentEntryId, token) : null; + if (command.Id.HasValue && node is null) throw Error("内容节点不存在。", "question_bank_node_not_found"); + node ??= new ContentNode { TenantId = tenantId, EntryId = bank.ContentEntryId!.Value, CreatedBy = actor.UserId }; + if (!command.Id.HasValue) dbContext.ContentNodes.Add(node); + node.ParentId = parent?.Id; + node.NodeKey = string.IsNullOrWhiteSpace(command.NodeKey) ? $"node-{node.Id:N}" : command.NodeKey.Trim(); + node.Name = command.Name.Trim(); + node.NodeType = command.NodeType; + node.Depth = parent is null ? 0 : parent.Depth + 1; + node.Path = parent is null ? $"n{node.Id:N}" : $"{parent.Path}.n{node.Id:N}"; + node.SortOrder = command.SortOrder; + node.IsActive = true; + node.IsSelectable = command.IsSelectable; + node.IsLeaf = command.NodeType is ContentNodeType.Chapter or ContentNodeType.Paper; + node.Metadata = ObjectOrDefault(command.Metadata); + node.UpdatedBy = actor.UserId; + AddAudit(dbContext, actor, "platform.question_bank.node_saved", node.Id, new { bankId = bank.Id, node.Name, node.NodeType }); + await dbContext.SaveChangesAsync(token); + return ToNodeItem(node, bank.Id, 0); + } + + private static async Task<(ContentNode Node, int Created)> EnsureStructuredPathAsync(TikuDbContext dbContext, PlatformAdminActor actor, Guid tenantId, QuestionBank bank, IReadOnlyCollection path, ContentNode? root, Dictionary cache, CancellationToken token) + { + var parent = root; + var created = 0; + foreach (var part in path) + { + var cacheKey = $"{parent?.Id:N}/{part.Key}"; + if (!cache.TryGetValue(cacheKey, out var node)) + { + var parentId = parent?.Id; + node = await dbContext.ContentNodes.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.EntryId == bank.ContentEntryId && item.ParentId == parentId && item.NodeKey == part.Key, token); + if (node is null) + { + node = new ContentNode + { + TenantId = tenantId, + EntryId = bank.ContentEntryId!.Value, + ParentId = parent?.Id, + NodeKey = part.Key, + Name = part.Name, + NodeType = part.Type, + Depth = parent is null ? 0 : parent.Depth + 1, + Path = parent is null ? null : $"{parent.Path}.n{Guid.NewGuid():N}", + SortOrder = part.Order, + IsActive = true, + IsSelectable = true, + IsLeaf = part.Type is ContentNodeType.Chapter or ContentNodeType.Paper, + CreatedBy = actor.UserId, + UpdatedBy = actor.UserId + }; + node.Path = parent is null ? $"n{node.Id:N}" : $"{parent.Path}.n{node.Id:N}"; + dbContext.ContentNodes.Add(node); + await dbContext.SaveChangesAsync(token); + created++; + } + cache[cacheKey] = node; + } + parent = node; + } + if (parent is null) throw Error("结构化导入未包含可用的章节或试卷。", "structured_import_path_required"); + return (parent, created); + } + + private static async Task<(Question Question, string Action, string SourceHash)> UpsertImportedQuestionAsync(TikuDbContext dbContext, PlatformAdminActor actor, Guid tenantId, QuestionBank bank, ContentNode node, JsonElement payload, CancellationToken token) + { + var legacyId = GetString(payload, "legacyId") ?? GetString(payload, "id"); + var sourceHash = GetString(payload, "sourceHash") ?? Hash(payload.GetRawText()); + var question = !string.IsNullOrWhiteSpace(legacyId) + ? await dbContext.Questions.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.LegacyId == legacyId, token) + : await dbContext.Questions.Where(item => item.TenantId == tenantId && item.QuestionBankId == bank.Id) + .Join(dbContext.QuestionVersions.Where(item => item.TenantId == tenantId && item.SourceHash == sourceHash), item => item.CurrentVersionId, version => (Guid?)version.Id, (item, _) => item) + .SingleOrDefaultAsync(token); + if (question is not null && question.QuestionBankId != bank.Id) throw Error("题目稳定编号已被其他题库使用。", "question_legacy_id_conflict"); + if (question?.CurrentVersionId is { } currentId && await dbContext.QuestionVersions.AnyAsync(item => item.TenantId == tenantId && item.Id == currentId && item.SourceHash == sourceHash, token)) return (question, "skipped", sourceHash); + var isNew = question is null; + question ??= new Question { TenantId = tenantId, QuestionBankId = bank.Id, EntryId = bank.ContentEntryId, LegacyId = legacyId }; + if (isNew) dbContext.Questions.Add(question); + var command = FromImportPayload(bank.Id, node.Id, payload, legacyId, sourceHash); + ApplyQuestion(question, command, bank.ContentEntryId!.Value, node.Id); + await dbContext.SaveChangesAsync(token); + var nextVersion = await dbContext.QuestionVersions.Where(item => item.TenantId == tenantId && item.QuestionId == question.Id).Select(item => (int?)item.VersionNo).MaxAsync(token) ?? 0; + var version = BuildVersion(actor, tenantId, question.Id, nextVersion + 1, command); + dbContext.QuestionVersions.Add(version); + question.CurrentVersionId = version.Id; + await dbContext.SaveChangesAsync(token); + return (question, isNew ? "inserted" : "updated", sourceHash); + } + + private static List ExtractSimpleRows(JsonElement payload, Guid nodeId) + { + var array = payload.ValueKind == JsonValueKind.Array ? payload : payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty("questions", out var questions) ? questions : default; + if (array.ValueKind != JsonValueKind.Array) throw Error("普通导入内容必须是题目数组,或包含 questions 数组。", "import_payload_invalid"); + return array.EnumerateArray().Select((item, index) => new ImportRow(index + 1, item.Clone(), [], nodeId)).ToList(); + } + + private static List ExtractStructuredRows(JsonElement payload, Guid? rootNodeId) + { + if (payload.ValueKind != JsonValueKind.Object) throw Error("结构化导入内容必须是 JSON 对象。", "structured_import_payload_invalid"); + if (payload.TryGetProperty("_tikuExport", out var marker) && marker.GetString() != "2.0") throw Error("仅支持 2.0 结构化题库文件。", "structured_import_version_invalid"); + var rows = new List(); + WalkStructured(payload, [], rows, rootNodeId); + if (rows.Count == 0) throw Error("结构化文件中没有找到题目。", "structured_import_questions_empty"); + return rows; + } + + private static void WalkStructured(JsonElement current, List path, List rows, Guid? rootNodeId) + { + if (current.ValueKind != JsonValueKind.Object) return; + if (current.TryGetProperty("questions", out var questions) && questions.ValueKind == JsonValueKind.Array) + { + foreach (var question in questions.EnumerateArray()) rows.Add(new ImportRow(rows.Count + 1, question.Clone(), path.ToArray(), rootNodeId)); + } + string[] childProperties = ["categories", "children", "subjects", "chapters", "papers", "nodes"]; + foreach (var property in childProperties) + { + if (!current.TryGetProperty(property, out var children) || children.ValueKind != JsonValueKind.Array) continue; + foreach (var child in children.EnumerateArray()) + { + if (child.ValueKind != JsonValueKind.Object) continue; + var name = GetString(child, "name") ?? GetString(child, "title") ?? "未命名节点"; + var key = GetString(child, "code") ?? GetString(child, "key") ?? GetString(child, "id") ?? $"{property}-{Hash(name)[..12]}"; + var type = property switch + { + "subjects" => ContentNodeType.Subject, + "chapters" => ContentNodeType.Chapter, + "papers" => ContentNodeType.Paper, + _ => ParseNodeType(GetString(child, "type"), ContentNodeType.Category) + }; + var next = new List(path) { new(key, name, type, GetInt(child, "order") ?? path.Count) }; + WalkStructured(child, next, rows, rootNodeId); + } + } + } + + private static (string Code, string Field, string Message)? ValidateImportRow(JsonElement payload) + { + if (payload.ValueKind != JsonValueKind.Object) return ("question_payload_invalid", "$", "题目必须是 JSON 对象。"); + if (string.IsNullOrWhiteSpace(GetString(payload, "content")) && string.IsNullOrWhiteSpace(GetString(payload, "title"))) return ("question_content_required", "content", "题干不能为空。"); + var type = GetString(payload, "type") ?? "choice"; + if (!SupportedQuestionTypes.Contains(type)) return ("question_type_invalid", "type", $"不支持的题型:{type}。"); + return null; + } + + private static void ValidateQuestion(UpsertPlatformQuestionCommand command) + { + if (string.IsNullOrWhiteSpace(command.Content)) throw Error("题干不能为空。", "question_content_required"); + var type = string.IsNullOrWhiteSpace(command.Type) ? "choice" : command.Type.Trim(); + if (!SupportedQuestionTypes.Contains(type)) throw Error("题型不受支持。", "question_type_invalid"); + if (command.Difficulty is < 1 or > 5) throw Error("难度必须在 1 到 5 之间。", "question_difficulty_invalid"); + } + + private static void ApplyQuestion(Question question, UpsertPlatformQuestionCommand command, Guid entryId, Guid nodeId) + { + question.QuestionBankId = command.QuestionBankId; + question.EntryId = entryId; + question.ContentNodeId = nodeId; + question.LegacyId = Normalize(command.LegacyId); + question.Type = Normalize(command.Type) ?? "choice"; + question.TypeLabel = Normalize(command.TypeLabel); + question.Difficulty = command.Difficulty; + question.Tags = ArrayOrDefault(command.Tags); + question.MediaUrl = Normalize(command.MediaUrl); + question.ExamMarkers = ObjectOrDefault(command.ExamMarkers); + question.Status = ParseQuestionStatus(command.Status); + } + + private static QuestionVersion BuildVersion(PlatformAdminActor actor, Guid tenantId, Guid questionId, int versionNo, UpsertPlatformQuestionCommand command) => new() + { + TenantId = tenantId, + QuestionId = questionId, + VersionNo = versionNo, + Content = Normalize(command.Content), + Options = ArrayOrDefault(command.Options), + CorrectOptionIndex = command.CorrectOptionIndex, + CorrectOptionIndices = ArrayOrDefault(command.CorrectOptionIndices), + AnswerText = Normalize(command.AnswerText), + Explanation = Normalize(command.Explanation), + SubQuestions = ArrayOrDefault(command.SubQuestions), + CodeLang = Normalize(command.CodeLang), + CodeTemplate = Normalize(command.CodeTemplate), + SourceHash = Normalize(command.SourceHash) ?? Hash(JsonSerializer.Serialize(command)), + CreatedBy = actor.UserId + }; + + private static UpsertPlatformQuestionCommand FromImportPayload(Guid bankId, Guid nodeId, JsonElement payload, string? legacyId, string sourceHash) => new( + null, bankId, nodeId, legacyId, GetString(payload, "type") ?? "choice", GetString(payload, "typeLabel"), GetInt(payload, "difficulty"), + GetElement(payload, "tags", JsonDefaults.Array()), GetString(payload, "content") ?? GetString(payload, "title"), GetElement(payload, "options", JsonDefaults.Array()), + GetInt(payload, "correctOptionIndex"), GetElement(payload, "correctOptionIndices", JsonDefaults.Array()), GetString(payload, "answerText") ?? GetString(payload, "answer"), + GetString(payload, "explanation"), GetElement(payload, "subQuestions", JsonDefaults.Array()), GetString(payload, "codeLang"), GetString(payload, "codeTemplate"), + GetString(payload, "mediaUrl"), GetString(payload, "status") ?? "published", GetElement(payload, "examMarkers", JsonDefaults.Object()), sourceHash); + + private static async Task RequireBankAsync(TikuDbContext dbContext, Guid tenantId, Guid bankId, CancellationToken token) => + await dbContext.QuestionBanks.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == bankId, token) ?? throw Error("公共题库不存在。", "question_bank_not_found"); + + private static async Task RequireBankWithEntryAsync(TikuDbContext dbContext, Guid tenantId, Guid bankId, CancellationToken token) + { + var bank = await RequireBankAsync(dbContext, tenantId, bankId, token); + if (!bank.ContentEntryId.HasValue) throw Error("题库内容入口尚未初始化,请先编辑并保存题库。", "question_bank_entry_missing"); + return bank; + } + + private static async Task RequireNodeAsync(TikuDbContext dbContext, Guid tenantId, QuestionBank bank, Guid nodeId, CancellationToken token) => + await dbContext.ContentNodes.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.EntryId == bank.ContentEntryId && item.Id == nodeId && item.IsActive, token) ?? throw Error("所选内容节点不存在或已归档。", "question_bank_node_not_found"); + + private static PlatformQuestionBankItem ToBankItem(QuestionBank item, int nodeCount, int questionCount) => new(item.Id, item.ContentEntryId, item.Name, item.Status, nodeCount, questionCount, item.Metadata, item.CreatedAt, item.UpdatedAt); + private static PlatformQuestionBankNodeItem ToNodeItem(ContentNode item, Guid bankId, int questionCount) => new(item.Id, bankId, item.EntryId, item.ParentId, item.NodeKey, item.Name, item.NodeType, item.Depth, item.SortOrder, item.IsActive, item.IsSelectable, item.IsLeaf, questionCount, item.Metadata); + private static PlatformQuestionItem ToQuestionItem(Question item, QuestionVersion? version) => new(item.Id, version?.Id, item.QuestionBankId!.Value, item.EntryId!.Value, item.ContentNodeId!.Value, item.LegacyId, item.Type, item.TypeLabel, item.Difficulty, item.Tags, version?.Content, version?.Options ?? JsonDefaults.Array(), version?.CorrectOptionIndex, version?.CorrectOptionIndices ?? JsonDefaults.Array(), version?.AnswerText, version?.Explanation, version?.SubQuestions ?? JsonDefaults.Array(), version?.CodeLang, version?.CodeTemplate, item.MediaUrl, item.Status, version?.VersionNo ?? 0, item.CreatedAt, item.UpdatedAt); + + private static async Task LoadImportDetailAsync(TikuDbContext dbContext, Guid tenantId, Guid jobId, CancellationToken token) + { + var job = await dbContext.ContentImportJobs.AsNoTracking().SingleOrDefaultAsync(item => item.TenantId == tenantId && item.Id == jobId, token) ?? throw Error("导入任务不存在。", "question_import_not_found"); + var items = await dbContext.ContentImportItems.AsNoTracking().Where(item => item.TenantId == tenantId && item.JobId == jobId).OrderBy(item => item.RowNo).Select(item => ToImportItem(item)).ToArrayAsync(token); + var issues = await dbContext.ContentImportIssues.AsNoTracking().Where(item => item.TenantId == tenantId && item.JobId == jobId).OrderBy(item => item.RowNo).Select(item => ToIssueItem(item)).ToArrayAsync(token); + return new ContentImportJobDetail(ToJobItem(job), items, issues); + } + + private static ContentImportJobItem ToJobItem(ContentImportJob item) => new(item.Id, item.TargetRegionId, item.TargetSubjectId, item.TargetCategoryId, item.TargetContentNodeId, item.TargetQuestionBankId, item.ImportType, item.SourceFormat, item.Status, item.SourceName, item.SourceHash, item.DryRun, item.TotalCount, item.ValidCount, item.ErrorCount, item.WarningCount, item.InsertedCount, item.UpdatedCount, item.SkippedCount, item.Summary, item.ErrorMessage, item.StartedAt, item.FinishedAt, item.CreatedAt, item.UpdatedAt); + private static ContentImportItemModel ToImportItem(ContentImportItem item) => new(item.Id, item.JobId, item.RowNo, item.ExternalId, item.Status, item.TargetType, item.TargetId, item.SourcePayload, item.NormalizedPayload, item.ContentHash, item.IssuesCount); + private static ContentImportIssueModel ToIssueItem(ContentImportIssue item) => new(item.Id, item.JobId, item.ItemId, item.RowNo, item.Severity, item.Code, item.FieldPath, item.Message, item.Details); + private static ContentImportIssue NewIssue(Guid tenantId, Guid jobId, Guid itemId, int rowNo, string code, string field, string message) => new() { TenantId = tenantId, JobId = jobId, ItemId = itemId, RowNo = rowNo, Severity = ImportIssueSeverity.Error, Code = code, FieldPath = field, Message = message }; + + 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 = "question_bank", TargetId = targetId.ToString("N"), Details = JsonSerializer.SerializeToElement(details) }); + private static PlatformAdminException Error(string message, string code) => new(message, code); + private static string NormalizeImportFormat(string? value) => value?.Trim().ToLowerInvariant() switch { "simple" or "json" => "simple", "structured-v2" or "structured" or "v2" => "structured", _ => throw Error("导入格式不受支持。", "question_import_format_invalid") }; + private static QuestionBankStatus ParseBankStatus(string? value) => value?.Trim().ToLowerInvariant() switch { "archived" or "已归档" => QuestionBankStatus.Archived, _ => QuestionBankStatus.Active }; + private static QuestionStatus ParseQuestionStatus(string? value) => value?.Trim().ToLowerInvariant() switch { "draft" or "草稿" => QuestionStatus.Draft, "archived" or "已归档" => QuestionStatus.Archived, _ => QuestionStatus.Published }; + private static ContentNodeType ParseNodeType(string? value, ContentNodeType fallback) => value?.Trim().ToLowerInvariant() switch { "subject" => ContentNodeType.Subject, "chapter" => ContentNodeType.Chapter, "paper" => ContentNodeType.Paper, "category" => ContentNodeType.Category, _ => fallback }; + private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + private static JsonElement ObjectOrDefault(JsonElement value) => value.ValueKind == JsonValueKind.Object ? value.Clone() : JsonDefaults.Object(); + private static JsonElement ArrayOrDefault(JsonElement value) => value.ValueKind == JsonValueKind.Array ? value.Clone() : JsonDefaults.Array(); + private static string Hash(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); + private static string? GetString(JsonElement value, string name) => value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String ? property.GetString() : null; + private static int? GetInt(JsonElement value, string name) => value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var property) && property.TryGetInt32(out var result) ? result : null; + private static JsonElement GetElement(JsonElement value, string name, JsonElement fallback) => value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var property) ? property.Clone() : fallback; + + private sealed record ImportPathPart(string Key, string Name, ContentNodeType Type, int Order); + private sealed record ImportRow(int RowNo, JsonElement Question, IReadOnlyCollection Path, Guid? TargetNodeId); +} diff --git a/Tiku.IntegrationTests/Api/AuthorizationManifestTests.cs b/Tiku.IntegrationTests/Api/AuthorizationManifestTests.cs index c4fa798..1741e05 100644 --- a/Tiku.IntegrationTests/Api/AuthorizationManifestTests.cs +++ b/Tiku.IntegrationTests/Api/AuthorizationManifestTests.cs @@ -16,8 +16,8 @@ namespace Tiku.IntegrationTests.Api; public sealed class AuthorizationManifestTests { - private const int ExpectedActionCount = 382; - private const string ExpectedSha256 = "a1ca69bc9cfa439d5df1a456e60ba2f6b1f1baf1cca22e5a28be04a774682bb1"; + private const int ExpectedActionCount = 397; + private const string ExpectedSha256 = "fe0636f609e86c8c7540d84914f8254106d20194ee5bc715d616c5a4f84c7a94"; [Fact] public void Controller_authorization_surface_matches_reviewed_manifest() diff --git a/Tiku.IntegrationTests/Api/PlatformAdminStaticEndpointTests.cs b/Tiku.IntegrationTests/Api/PlatformAdminStaticEndpointTests.cs index 5a1d5ff..0579a4a 100644 --- a/Tiku.IntegrationTests/Api/PlatformAdminStaticEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/PlatformAdminStaticEndpointTests.cs @@ -50,4 +50,68 @@ public sealed class PlatformAdminStaticEndpointTests Assert.Contains("/api/auth/password/change-required", authentication, StringComparison.Ordinal); Assert.DoesNotContain("/api/auth/mfa", authentication, StringComparison.Ordinal); } + + [Fact] + public async Task Platform_admin_shell_covers_current_platform_capabilities_and_dto_fields() + { + await using var factory = new ApiTestFactory(); + using var client = factory.CreateClient(); + + var application = await client.GetStringAsync("/platform-admin/app.js"); + var api = await client.GetStringAsync("/platform-admin/platform-api.js"); + var contract = await client.GetStringAsync("/platform-admin/spec-contract.js"); + + Assert.Contains("租户域名与证书", application, StringComparison.Ordinal); + Assert.Contains("SaaS 商品与版本", application, StringComparison.Ordinal); + Assert.Contains("SaaS 订单与交易", application, StringComparison.Ordinal); + Assert.Contains("平台角色与功能", application, StringComparison.Ordinal); + Assert.Contains("function businessStatusLabel(value)", application, StringComparison.Ordinal); + Assert.Contains("function backendStatusLabel()", application, StringComparison.Ordinal); + Assert.DoesNotContain("业务功能名称和中文说明", application, StringComparison.Ordinal); + Assert.DoesNotContain("中文说明", application, StringComparison.Ordinal); + Assert.DoesNotContain("API 契约", application, StringComparison.Ordinal); + Assert.Contains("/api/backoffice/platform/bootstrap", contract, StringComparison.Ordinal); + Assert.Contains("/api/platform-admin/payment-settings/channels", contract, StringComparison.Ordinal); + Assert.Contains("/api/platform-admin/tenant-capabilities/crm/logs", contract, StringComparison.Ordinal); + Assert.Contains("userId: input.userId", api, StringComparison.Ordinal); + Assert.Contains("roleIds: input.roleIds", api, StringComparison.Ordinal); + Assert.Contains("resolutionNote: input.resolutionNote", api, StringComparison.Ordinal); + Assert.Contains("platformPermissionLabels[item.code]", api, StringComparison.Ordinal); + Assert.Contains("'platform:role:manage': '管理平台角色与权限'", api, StringComparison.Ordinal); + Assert.DoesNotContain("authUserId: input.authUserId", api, StringComparison.Ordinal); + Assert.DoesNotContain("platformPermissions:", api, StringComparison.Ordinal); + Assert.DoesNotContain("staffId: input.staffId", api, StringComparison.Ordinal); + } + + [Fact] + public async Task Platform_question_bank_workspace_loads_cache_busted_scripts_and_real_api_bindings() + { + await using var factory = new ApiTestFactory(); + using var client = factory.CreateClient(); + + var shell = await client.GetStringAsync("/platform-admin/"); + var application = await client.GetStringAsync("/platform-admin/app.js"); + var api = await client.GetStringAsync("/platform-admin/platform-api.js"); + var contract = await client.GetStringAsync("/platform-admin/spec-contract.js"); + + Assert.Contains("app.js?v=20260730-question-bank1", shell, StringComparison.Ordinal); + Assert.Contains("platform-api.js?v=20260730-question-bank1", shell, StringComparison.Ordinal); + Assert.Contains("spec-contract.js?v=20260730-question-bank1", shell, StringComparison.Ordinal); + Assert.Contains("operationId: 'PlatformQuestionBanksController_upsertBank'", application, StringComparison.Ordinal); + Assert.Contains("operationId: 'PlatformQuestionBanksController_upsertNode'", application, StringComparison.Ordinal); + Assert.Contains("operationId: 'PlatformQuestionBanksController_upsertQuestion'", application, StringComparison.Ordinal); + Assert.Contains("PlatformQuestionBanksController_archiveQuestions", application, StringComparison.Ordinal); + Assert.Contains("PlatformQuestionBanksController_archiveNode", application, StringComparison.Ordinal); + Assert.Contains("PlatformQuestionBanksController_archiveBank", application, StringComparison.Ordinal); + Assert.Contains("data-action=\"shift-bank-node\"", application, StringComparison.Ordinal); + Assert.Contains("PlatformQuestionBanksController_signAssetUpload", application, StringComparison.Ordinal); + Assert.Contains("PlatformQuestionBanksController_confirmAssetUpload", application, StringComparison.Ordinal); + Assert.Contains("PlatformQuestionBanksController_upsertBank(input)", api, StringComparison.Ordinal); + Assert.Contains("PlatformQuestionBanksController_upsertQuestion(input)", api, StringComparison.Ordinal); + Assert.Contains("PlatformQuestionBanksController_executeImport(input)", api, StringComparison.Ordinal); + Assert.Contains("['P-09', 'PlatformQuestionBanksController_upsertBank', 'PUT', '/api/platform-admin/question-banks']", contract, StringComparison.Ordinal); + Assert.Contains("['P-09', 'PlatformQuestionBanksController_executeImport', 'POST', '/api/platform-admin/question-banks/imports']", contract, StringComparison.Ordinal); + Assert.DoesNotContain("question_bank:grant", application, StringComparison.Ordinal); + Assert.DoesNotContain("question_bank:ops", application, StringComparison.Ordinal); + } } diff --git a/Tiku.IntegrationTests/Api/PlatformQuestionBankEndpointTests.cs b/Tiku.IntegrationTests/Api/PlatformQuestionBankEndpointTests.cs new file mode 100644 index 0000000..7737775 --- /dev/null +++ b/Tiku.IntegrationTests/Api/PlatformQuestionBankEndpointTests.cs @@ -0,0 +1,219 @@ +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.Application.PlatformAdmin; +using Tiku.Application.Security; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Identity; +using Tiku.Domain.Operations; +using Tiku.Domain.Platform; +using Tiku.Domain.QuestionBanks; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Auth; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.IntegrationTests.Api; + +public sealed class PlatformQuestionBankEndpointTests +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) } + }; + + [Fact] + public async Task Platform_question_bank_workflow_is_scoped_versioned_and_idempotent() + { + await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary + { + ["Tenancy:Resolution:PlatformHosts:0"] = "localhost" + }); + var platformTenantId = Guid.NewGuid(); + var ordinaryTenantId = Guid.NewGuid(); + var ordinaryBankId = Guid.NewGuid(); + var platformUser = await SeedPlatformQuestionBankUserAsync(factory); + await factory.SeedAsync( + new Tenant { Id = platformTenantId, Slug = "platform-content-test", Name = "平台公共内容", Mode = TenantMode.PlatformOwned, Status = TenantStatus.Active, BillingStatus = BillingStatus.Active }, + new Tenant { Id = ordinaryTenantId, Slug = "ordinary-question-bank", Name = "普通租户", Status = TenantStatus.Active, BillingStatus = BillingStatus.Active }, + new QuestionBank { Id = ordinaryBankId, TenantId = ordinaryTenantId, Name = "普通租户私有题库" }); + + using var client = factory.CreateClient(); + client.UseAccessToken(await client.LoginAsPlatformAsync(platformUser.Email)); + + var bankResponse = await client.PutAsJsonAsync("/api/platform-admin/question-banks", new UpsertPlatformQuestionBankCommand(null, "平台高等数学公共题库", JsonDefaults.Object())); + Assert.True(bankResponse.StatusCode == HttpStatusCode.OK, await bankResponse.Content.ReadAsStringAsync()); + var bank = await bankResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(bank); + Assert.NotNull(bank.ContentEntryId); + + var nodeResponse = await client.PutAsJsonAsync("/api/platform-admin/question-banks/nodes", new UpsertPlatformQuestionBankNodeCommand( + null, bank.Id, null, "math-chapter-1", "函数、极限与连续", ContentNodeType.Chapter, 10, true, JsonDefaults.Object())); + Assert.Equal(HttpStatusCode.OK, nodeResponse.StatusCode); + var node = await nodeResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(node); + + var createQuestion = QuestionCommand(null, bank.Id, node.Id, "platform-question-1", "函数极限的定义是什么?"); + var questionResponse = await client.PutAsJsonAsync("/api/platform-admin/question-banks/questions", createQuestion); + Assert.Equal(HttpStatusCode.OK, questionResponse.StatusCode); + var question = await questionResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(question); + Assert.Equal(1, question.VersionNo); + + var updatedResponse = await client.PutAsJsonAsync( + "/api/platform-admin/question-banks/questions", + createQuestion with { Id = question.Id, Content = "请说明函数极限的严格定义。" }); + Assert.Equal(HttpStatusCode.OK, updatedResponse.StatusCode); + var updated = await updatedResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(updated); + Assert.Equal(2, updated.VersionNo); + + var import = new PlatformQuestionImportCommand( + bank.Id, + node.Id, + "simple", + "平台普通 JSON 回归", + JsonSerializer.SerializeToElement(new object[] + { + new { legacyId = "import-stable-1", type = "true_false", content = "函数极限一定存在。", answerText = "错误" }, + new { legacyId = "invalid-1", type = "choice", content = "" } + })); + var jobsBeforePreview = await CountImportJobsAsync(factory, platformTenantId); + var previewResponse = await client.PostAsJsonAsync("/api/platform-admin/question-banks/imports/preview", import); + Assert.Equal(HttpStatusCode.OK, previewResponse.StatusCode); + var preview = await previewResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(preview); + Assert.Equal(2, preview.Detail.Job.TotalCount); + Assert.Equal(1, preview.Detail.Job.ValidCount); + Assert.Equal(1, preview.Detail.Job.ErrorCount); + Assert.Equal(jobsBeforePreview, await CountImportJobsAsync(factory, platformTenantId)); + + var firstImportResponse = await client.PostAsJsonAsync("/api/platform-admin/question-banks/imports", import); + var repeatedImportResponse = await client.PostAsJsonAsync("/api/platform-admin/question-banks/imports", import); + Assert.True(firstImportResponse.StatusCode == HttpStatusCode.OK, await firstImportResponse.Content.ReadAsStringAsync()); + Assert.True(repeatedImportResponse.StatusCode == HttpStatusCode.OK, await repeatedImportResponse.Content.ReadAsStringAsync()); + var firstImport = await firstImportResponse.Content.ReadFromJsonAsync(JsonOptions); + var repeatedImport = await repeatedImportResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(firstImport); + Assert.NotNull(repeatedImport); + Assert.Equal(1, firstImport.InsertedQuestionCount); + Assert.Equal(1, repeatedImport.SkippedQuestionCount); + + var structuredImport = new PlatformQuestionImportCommand( + bank.Id, + null, + "structured-v2", + "平台结构化 JSON 回归", + JsonSerializer.SerializeToElement(new + { + _tikuExport = "2.0", + subjects = new[] + { + new + { + code = "advanced-math", + name = "高等数学", + chapters = new[] + { + new + { + code = "limits", + name = "极限", + questions = new[] + { + new { legacyId = "structured-stable-1", type = "choice", content = "下列极限存在的是?", options = new[] { "A", "B" }, answerText = "A" } + } + } + } + } + } + })); + var structuredResponse = await client.PostAsJsonAsync("/api/platform-admin/question-banks/imports", structuredImport); + var repeatedStructuredResponse = await client.PostAsJsonAsync("/api/platform-admin/question-banks/imports", structuredImport); + Assert.True(structuredResponse.StatusCode == HttpStatusCode.OK, await structuredResponse.Content.ReadAsStringAsync()); + Assert.True(repeatedStructuredResponse.StatusCode == HttpStatusCode.OK, await repeatedStructuredResponse.Content.ReadAsStringAsync()); + var structured = await structuredResponse.Content.ReadFromJsonAsync(JsonOptions); + var repeatedStructured = await repeatedStructuredResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(structured); + Assert.NotNull(repeatedStructured); + Assert.Equal(2, structured.CreatedNodeCount); + Assert.Equal(1, structured.InsertedQuestionCount); + Assert.Equal(0, repeatedStructured.CreatedNodeCount); + Assert.Equal(1, repeatedStructured.SkippedQuestionCount); + + var listedResponse = await client.GetAsync($"/api/platform-admin/question-banks/questions?questionBankId={bank.Id}&page=1&pageSize=20"); + Assert.Equal(HttpStatusCode.OK, listedResponse.StatusCode); + var listed = await listedResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(listed); + Assert.Equal(3, listed.Total); + + var bankList = await client.GetFromJsonAsync("/api/platform-admin/question-banks?status=all", JsonOptions); + Assert.NotNull(bankList); + Assert.Contains(bankList, item => item.Id == bank.Id); + Assert.DoesNotContain(bankList, item => item.Id == ordinaryBankId); + + using var scope = factory.CreateSystemScope("验证平台公共题库租户隔离与版本"); + var dbContext = scope.ServiceProvider.GetRequiredService(); + Assert.Equal(platformTenantId, await dbContext.QuestionBanks.Where(item => item.Id == bank.Id).Select(item => item.TenantId).SingleAsync()); + Assert.Equal(2, await dbContext.QuestionVersions.CountAsync(item => item.TenantId == platformTenantId && item.QuestionId == question.Id)); + Assert.Equal(1, await dbContext.Questions.CountAsync(item => item.TenantId == platformTenantId && item.LegacyId == "import-stable-1")); + } + + [Fact] + public async Task Platform_question_bank_rejects_missing_permission_and_non_platform_host() + { + await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary + { + ["Tenancy:Resolution:PlatformHosts:0"] = "platform.example.test" + }); + await factory.SeedAsync( + new Tenant { Id = Guid.NewGuid(), Slug = "platform-content-auth", Name = "平台公共内容", Mode = TenantMode.PlatformOwned }); + var platformWithoutPermission = await SeedPlatformUserAsync(factory, BackendPermissions.PlatformDashboardView); + using var client = factory.CreateClient(); + client.UseAccessToken(await client.LoginAsPlatformAsync(platformWithoutPermission.Email)); + + using var wrongHostRequest = new HttpRequestMessage(HttpMethod.Get, "/api/platform-admin/question-banks"); + wrongHostRequest.Headers.Host = "tenant.example.test"; + var wrongHost = await client.SendAsync(wrongHostRequest); + + using var platformHostRequest = new HttpRequestMessage(HttpMethod.Get, "/api/platform-admin/question-banks"); + platformHostRequest.Headers.Host = "platform.example.test"; + var missingPermission = await client.SendAsync(platformHostRequest); + + Assert.Equal(HttpStatusCode.NotFound, wrongHost.StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, missingPermission.StatusCode); + } + + private static UpsertPlatformQuestionCommand QuestionCommand(Guid? id, Guid bankId, Guid nodeId, string legacyId, string content) => new( + id, bankId, nodeId, legacyId, "short_answer", "简答题", 3, + JsonSerializer.SerializeToElement(new[] { "极限" }), content, JsonDefaults.Array(), null, JsonDefaults.Array(), + "按定义作答", "考查函数极限定义", JsonDefaults.Array(), null, null, null, "published", JsonDefaults.Object(), null); + + private static async Task CountImportJobsAsync(ApiTestFactory factory, Guid tenantId) + { + using var scope = factory.CreateSystemScope("统计平台公共题库导入任务"); + return await scope.ServiceProvider.GetRequiredService().ContentImportJobs.CountAsync(item => item.TenantId == tenantId); + } + + private static Task<(Guid UserId, string Email)> SeedPlatformQuestionBankUserAsync(ApiTestFactory factory) => + SeedPlatformUserAsync(factory, BackendPermissions.PlatformQuestionBankManage); + + private static async Task<(Guid UserId, string Email)> SeedPlatformUserAsync(ApiTestFactory factory, string permissionCode) + { + var userId = Guid.NewGuid(); + var roleId = Guid.NewGuid(); + var email = $"platform-question-bank-{Guid.NewGuid():N}@example.test"; + var moduleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(permissionCode); + await factory.SeedAsync( + new PermissionModule { Code = moduleCode, Name = "平台公共题库", Area = BackendPermissionArea.Platform }, + new BackendPermission { Code = permissionCode, Name = "平台公共题库运营", Area = BackendPermissionArea.Platform, PermissionModuleCode = moduleCode, IsSystem = true }, + new User { Id = userId, Email = email, NormalizedEmail = email.ToUpperInvariant(), UserName = email, NormalizedUserName = email.ToUpperInvariant(), Name = "平台题库运营", PrimaryRole = "platform_admin", RawProfile = JsonDefaults.Object() }.WithTestPassword(), + new PlatformBackendRole { Id = roleId, Code = $"platform_question_bank_{roleId:N}", Name = "平台题库运营", Status = BackendRoleStatus.Active }, + new PlatformBackendRolePermission { RoleId = roleId, PermissionCode = permissionCode }, + new PlatformBackendUserRole { UserId = userId, RoleId = roleId }); + return (userId, email); + } +}