From 570377a31ffcf601e499dfff2c4d1fd50f1eca19 Mon Sep 17 00:00:00 2001 From: iven Date: Wed, 6 May 2026 12:35:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(config):=20=E8=A7=92=E8=89=B2=E6=9D=83?= =?UTF-8?q?=E9=99=90=E6=8E=A7=E5=88=B6=E8=8F=9C=E5=8D=95=E5=8F=AF=E8=A7=81?= =?UTF-8?q?=E6=80=A7=20+=20=E5=8C=BB=E7=96=97=E4=B8=9A=E5=8A=A1=E8=A7=92?= =?UTF-8?q?=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 menu_service 角色过滤 bug: ctx.roles 存的是角色 code 而非 UUID, 新增 resolve_role_ids() 方法通过 code 查找数据库中的角色 ID - 创建 4 个医疗业务角色: 医生/护士/健康管理师/运营人员 - 重组菜单目录结构: 基础模块→工作台、业务模块→系统管理、健康管理→健康业务 - 菜单排序按功能域分组(患者医护/随访咨询/积分运营/内容运营/AI分析) - 为各角色分配对应的菜单可见性和操作权限 --- crates/erp-config/src/error.rs | 6 + crates/erp-config/src/handler/menu_handler.rs | 17 +- crates/erp-config/src/service/menu_service.rs | 42 +- crates/erp-server/migration/src/lib.rs | 2 + ...0506_000125_restructure_menus_and_roles.rs | 409 ++++++++++++++++++ 5 files changed, 457 insertions(+), 19 deletions(-) create mode 100644 crates/erp-server/migration/src/m20260506_000125_restructure_menus_and_roles.rs diff --git a/crates/erp-config/src/error.rs b/crates/erp-config/src/error.rs index 466c7c3..6152dbe 100644 --- a/crates/erp-config/src/error.rs +++ b/crates/erp-config/src/error.rs @@ -28,6 +28,12 @@ impl From> for ConfigError { } } +impl From for ConfigError { + fn from(err: sea_orm::DbErr) -> Self { + ConfigError::Validation(err.to_string()) + } +} + impl From for AppError { fn from(err: ConfigError) -> Self { match err { diff --git a/crates/erp-config/src/handler/menu_handler.rs b/crates/erp-config/src/handler/menu_handler.rs index f45db78..3fd2bbf 100644 --- a/crates/erp-config/src/handler/menu_handler.rs +++ b/crates/erp-config/src/handler/menu_handler.rs @@ -36,13 +36,7 @@ where { require_permission(&ctx, "menu.list")?; - let role_ids: Vec = ctx - .roles - .iter() - .filter_map(|r| Uuid::parse_str(r).ok()) - .collect(); - - let menus = MenuService::get_menu_tree(ctx.tenant_id, &role_ids, &state.db).await?; + let menus = MenuService::get_menu_tree(ctx.tenant_id, &ctx.roles, &state.db).await?; Ok(JsonResponse(ApiResponse::ok(menus))) } @@ -257,14 +251,7 @@ where ConfigState: FromRef, S: Clone + Send + Sync + 'static, { - let role_ids: Vec = ctx - .roles - .iter() - .filter_map(|r| Uuid::parse_str(r).ok()) - .collect(); - - // 如果用户有角色关联菜单,按角色过滤;否则返回全部(admin 兜底) - let menus = MenuService::get_menu_tree(ctx.tenant_id, &role_ids, &state.db).await?; + let menus = MenuService::get_menu_tree(ctx.tenant_id, &ctx.roles, &state.db).await?; Ok(JsonResponse(ApiResponse::ok(menus))) } diff --git a/crates/erp-config/src/service/menu_service.rs b/crates/erp-config/src/service/menu_service.rs index dffe534..092bbc4 100644 --- a/crates/erp-config/src/service/menu_service.rs +++ b/crates/erp-config/src/service/menu_service.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use chrono::Utc; -use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set}; +use sea_orm::{ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QueryOrder, Set}; use uuid::Uuid; use crate::dto::{CreateMenuReq, MenuResp}; @@ -17,15 +17,49 @@ use erp_core::events::EventBus; pub struct MenuService; impl MenuService { + /// 通过角色 code 列表查找对应的角色 ID 列表。 + async fn resolve_role_ids( + tenant_id: Uuid, + role_codes: &[String], + db: &sea_orm::DatabaseConnection, + ) -> ConfigResult> { + if role_codes.is_empty() { + return Ok(vec![]); + } + let codes_csv: String = role_codes + .iter() + .map(|c| format!("'{}'", c.replace('\'', "''"))) + .collect::>() + .join(","); + let sql = format!( + "SELECT id FROM roles WHERE tenant_id = '{}' AND code IN ({}) AND deleted_at IS NULL", + tenant_id, codes_csv + ); + let stmt = sea_orm::Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql); + let rows = db.query_all(stmt).await?; + + Ok(rows + .into_iter() + .filter_map(|row| { + let id: Uuid = row.try_get_by_index(0).ok()?; + Some(id) + }) + .collect()) + } + /// 获取当前租户下指定角色可见的菜单树。 /// - /// 如果 `role_ids` 非空,仅返回这些角色关联的菜单; - /// 否则返回租户全部菜单。结果按 `sort_order` 排列并组装为树形结构。 + /// `role_codes` 为当前用户的角色 code 列表(如 ["admin"]、["doctor"])。 + /// 方法内部将 code 转换为 ID,再通过 menu_roles 表过滤。 + /// 如果角色没有任何菜单关联,返回全部菜单(admin 兜底)。 pub async fn get_menu_tree( tenant_id: Uuid, - role_ids: &[Uuid], + role_codes: &[String], db: &sea_orm::DatabaseConnection, ) -> ConfigResult> { + // 0. 将角色 code 转换为 UUID + let role_ids = Self::resolve_role_ids(tenant_id, role_codes, db).await?; + // 1. 查询租户下所有未删除的菜单,按 sort_order 排序 let all_menus = menu::Entity::find() .filter(menu::Column::TenantId.eq(tenant_id)) diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index 08dadd7..57fdf99 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -124,6 +124,7 @@ mod m20260505_000121_create_ai_knowledge_references; mod m20260505_000122_create_ai_knowledge_guides; mod m20260505_000123_update_ai_prompts_system_instruction; mod m20260505_000124_freeze_deferred_menus; +mod m20260506_000125_restructure_menus_and_roles; pub struct Migrator; @@ -255,6 +256,7 @@ impl MigratorTrait for Migrator { Box::new(m20260505_000122_create_ai_knowledge_guides::Migration), Box::new(m20260505_000123_update_ai_prompts_system_instruction::Migration), Box::new(m20260505_000124_freeze_deferred_menus::Migration), + Box::new(m20260506_000125_restructure_menus_and_roles::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260506_000125_restructure_menus_and_roles.rs b/crates/erp-server/migration/src/m20260506_000125_restructure_menus_and_roles.rs new file mode 100644 index 0000000..794824e --- /dev/null +++ b/crates/erp-server/migration/src/m20260506_000125_restructure_menus_and_roles.rs @@ -0,0 +1,409 @@ +//! 重组菜单结构 + 创建医疗业务角色 + 菜单-角色关联 +//! +//! 1. 重命名顶级目录,使其更贴合健康管理机构使用习惯 +//! 2. 调整菜单项排序,按功能域分组 +//! 3. 创建 4 个医疗业务角色: doctor / nurse / health_manager / operator +//! 4. 为各角色分配菜单可见性和操作权限 + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + // ================================================================ + // Part 1: 重命名顶级目录 + // ================================================================ + let dir_renames: &[(&str, &str)] = &[ + ("基础模块", "工作台"), + ("业务模块", "系统管理"), + ("健康管理", "健康业务"), + ("系统", "配置"), + ]; + for &(old, new) in dir_renames { + db.execute_unprepared(&format!( + "UPDATE menus SET title = '{new}' WHERE title = '{old}' AND menu_type = 'directory' AND deleted_at IS NULL" + )).await?; + } + + // ================================================================ + // Part 2: 调整菜单排序和归属 + // ================================================================ + // 将"统计报表"移到"工作台"目录下 + db.execute_unprepared( + "UPDATE menus SET parent_id = (SELECT id FROM menus WHERE title = '工作台' AND menu_type = 'directory' AND deleted_at IS NULL LIMIT 1), sort_order = 1 WHERE path = '/health/statistics' AND deleted_at IS NULL" + ).await?; + + // 将"工作流"和"消息中心"移到"系统管理"目录下 + // (它们已经是"业务模块"的子项,目录重命名后自动归到"系统管理") + + // 将"系统设置"和"插件管理"移到"系统管理"目录下 + db.execute_unprepared( + "UPDATE menus SET parent_id = (SELECT id FROM menus WHERE title = '系统管理' AND menu_type = 'directory' AND deleted_at IS NULL LIMIT 1) WHERE path IN ('/settings', '/plugins/admin') AND deleted_at IS NULL" + ).await?; + + // 删除空的"配置"目录(原"系统"目录,其子项已移走) + db.execute_unprepared( + "UPDATE menus SET deleted_at = NOW() WHERE title = '配置' AND menu_type = 'directory' AND deleted_at IS NULL AND NOT EXISTS (SELECT 1 FROM menus c WHERE c.parent_id = menus.id AND c.deleted_at IS NULL)" + ).await?; + + // 调整"系统管理"目录下的排序 + let sys_sort: &[(&str, i32)] = &[ + ("/users", 0), + ("/roles", 1), + ("/organizations", 2), + ("/workflow", 3), + ("/messages", 4), + ("/settings", 5), + ("/plugins/admin", 6), + ]; + for &(path, sort) in sys_sort { + db.execute_unprepared(&format!( + "UPDATE menus SET sort_order = {sort} WHERE path = '{path}' AND deleted_at IS NULL" + )).await?; + } + + // 调整"健康业务"目录下的排序 — 按功能域分组 + let health_sort: &[(&str, i32)] = &[ + // 患者与医护 + ("/health/patients", 0), + ("/health/doctors", 1), + ("/health/tags", 2), + ("/health/diagnoses", 3), + // 随访与咨询 + ("/health/follow-up-tasks", 10), + ("/health/consultations", 11), + ("/health/action-inbox", 12), + ("/health/follow-up-templates", 13), + // 监测与知情同意 + ("/health/daily-monitoring", 20), + ("/health/consents", 21), + ("/health/realtime-monitor", 22), + // 告警与设备 + ("/health/alert-dashboard", 30), + ("/health/alerts", 31), + ("/health/alert-rules", 32), + ("/health/devices", 33), + ("/health/ble-gateways", 34), + ("/health/critical-value-thresholds", 35), + // 运营 + ("/health/articles", 40), + ("/health/points-rules", 41), + ("/health/points-products", 42), + ("/health/points-orders", 43), + ("/health/offline-events", 44), + // AI + ("/health/ai-prompts", 50), + ("/health/ai-analysis", 51), + ("/health/ai-usage", 52), + // 其他 + ("/health/oauth-clients", 60), + ]; + for &(path, sort) in health_sort { + db.execute_unprepared(&format!( + "UPDATE menus SET sort_order = {sort} WHERE path = '{path}' AND deleted_at IS NULL" + )).await?; + } + + // ================================================================ + // Part 3: 创建/更新医疗业务角色(幂等) + // ================================================================ + let sys = "00000000-0000-0000-0000-000000000000"; + + let roles: &[(&str, &str, &str)] = &[ + ("doctor", "医生", "负责患者诊疗、随访管理、AI辅助诊断"), + ("nurse", "护士", "负责患者护理、体征监测、用药管理"), + ("health_manager", "健康管理师", "负责健康管理计划、随访协调、运营统计"), + ("operator", "运营人员", "负责内容运营、积分商城、活动管理"), + ]; + + for &(code, name, desc) in roles { + // 对已存在的角色:更新名称和描述;对新租户:创建角色 + db.execute_unprepared(&format!( + "INSERT INTO roles (id, tenant_id, name, code, description, is_system, created_at, updated_at, created_by, updated_by, deleted_at, version) \ + SELECT gen_random_uuid(), t.id, '{name}', '{code}', '{desc}', false, NOW(), NOW(), '{sys}', '{sys}', NULL, 1 \ + FROM tenant t \ + WHERE NOT EXISTS (SELECT 1 FROM roles r WHERE r.tenant_id = t.id AND r.code = '{code}' AND r.deleted_at IS NULL)" + )).await?; + // 更新已存在的角色的名称和描述 + db.execute_unprepared(&format!( + "UPDATE roles SET name = '{name}', description = '{desc}' WHERE code = '{code}' AND deleted_at IS NULL AND name != '{name}'" + )).await?; + } + + // ================================================================ + // Part 4: 为新角色分配权限 + // ================================================================ + + // doctor 权限: 患者 + 医护 + 随访 + 咨询 + AI + 告警 + 日常监测 + 诊断 + 知情同意 + 行动收件箱 + 消息(只读) + assign_perms_by_codes(db, "doctor", &[ + "health.patient.list", "health.patient.manage", + "health.doctor.list", "health.doctor.manage", + "health.follow-up.list", "health.follow-up.manage", + "health.consultation.list", "health.consultation.manage", + "health.action-inbox.list", "health.action-inbox.manage", + "health.daily-monitoring.list", "health.daily-monitoring.manage", + "health.alerts.list", "health.alerts.manage", + "health.alert-rules.list", + "health.critical-alerts.list", + "health.diagnosis.list", "health.diagnosis.manage", + "health.consent.list", "health.consent.manage", + "health.health-data.list", + "ai.analysis.list", "ai.suggestion.list", + "message.list", + "workflow.list", "workflow.read", + ]).await?; + + // nurse 权限: 患者 + 随访 + 咨询 + 告警 + 日常监测 + 诊断 + 知情同意 + 行动收件箱 + 消息(只读) + assign_perms_by_codes(db, "nurse", &[ + "health.patient.list", "health.patient.manage", + "health.follow-up.list", "health.follow-up.manage", + "health.consultation.list", + "health.action-inbox.list", "health.action-inbox.manage", + "health.daily-monitoring.list", "health.daily-monitoring.manage", + "health.alerts.list", + "health.critical-alerts.list", + "health.diagnosis.list", + "health.consent.list", "health.consent.manage", + "health.health-data.list", + "health.device-readings.list", + "message.list", + ]).await?; + + // health_manager 权限: 统计 + 患者 + 医护 + 随访 + 咨询 + AI + 告警 + 设备 + 日常监测 + 标签 + 诊断 + 知情同意 + 行动收件箱 + 随访模板 + assign_perms_by_codes(db, "health_manager", &[ + "health.patient.list", "health.patient.manage", + "health.doctor.list", + "health.follow-up.list", "health.follow-up.manage", + "health.consultation.list", "health.consultation.manage", + "health.action-inbox.list", "health.action-inbox.manage", "health.action-inbox.team", + "health.daily-monitoring.list", "health.daily-monitoring.manage", + "health.alerts.list", "health.alerts.manage", + "health.alert-rules.list", "health.alert-rules.manage", + "health.critical-alerts.list", + "health.critical-value-thresholds.list", + "health.devices.list", + "health.tags.list", "health.tags.manage", + "health.diagnosis.list", "health.diagnosis.manage", + "health.consent.list", "health.consent.manage", + "health.health-data.list", "health.health-data.manage", + "health.follow-up-templates.list", "health.follow-up-templates.manage", + "health.dashboard.manage", + "ai.analysis.list", "ai.analysis.manage", + "ai.prompt.list", + "ai.suggestion.list", "ai.suggestion.manage", + "ai.usage.list", + "message.list", + "workflow.list", "workflow.read", "workflow.start", + ]).await?; + + // operator 权限: 统计 + 标签 + 内容 + 积分 + 活动 + 设备 + 告警(只读) + assign_perms_by_codes(db, "operator", &[ + "health.patient.list", + "health.tags.list", "health.tags.manage", + "health.articles.list", "health.articles.manage", + "health.articles.review", + "health.points.list", "health.points.manage", + "health.offline-events.list", "health.offline-events.manage", + "health.devices.list", + "health.alerts.list", + "health.dashboard.manage", + "ai.usage.list", + "message.list", + ]).await?; + + // ================================================================ + // Part 5: 菜单-角色关联(menu_roles) + // ================================================================ + // admin 角色自动看到所有 visible=true 菜单(service 层 fallback 逻辑) + // 只需为新角色关联菜单 + + // doctor 可见菜单路径 + let doctor_paths: &[&str] = &[ + "/", "/health/statistics", + "/health/patients", "/health/doctors", + "/health/follow-up-tasks", "/health/consultations", + "/health/action-inbox", "/health/follow-up-templates", + "/health/daily-monitoring", "/health/consents", "/health/diagnoses", + "/health/alert-dashboard", "/health/alerts", + "/health/ai-analysis", "/health/ai-usage", + "/messages", + ]; + assign_menus_for_role(db, "doctor", doctor_paths).await?; + + // nurse 可见菜单路径 + let nurse_paths: &[&str] = &[ + "/", "/health/statistics", + "/health/patients", + "/health/follow-up-tasks", "/health/consultations", + "/health/action-inbox", + "/health/daily-monitoring", "/health/consents", "/health/diagnoses", + "/health/alert-dashboard", "/health/alerts", + "/messages", + ]; + assign_menus_for_role(db, "nurse", nurse_paths).await?; + + // health_manager 可见菜单路径 + let hm_paths: &[&str] = &[ + "/", "/health/statistics", + "/health/patients", "/health/doctors", "/health/tags", + "/health/follow-up-tasks", "/health/consultations", + "/health/action-inbox", "/health/follow-up-templates", + "/health/daily-monitoring", "/health/consents", "/health/diagnoses", + "/health/alert-dashboard", "/health/alerts", "/health/alert-rules", + "/health/devices", "/health/critical-value-thresholds", + "/health/ai-prompts", "/health/ai-analysis", "/health/ai-usage", + "/health/realtime-monitor", + "/messages", + ]; + assign_menus_for_role(db, "health_manager", hm_paths).await?; + + // operator 可见菜单路径 + let op_paths: &[&str] = &[ + "/", "/health/statistics", + "/health/patients", "/health/tags", + "/health/articles", + "/health/points-rules", "/health/points-products", "/health/points-orders", + "/health/offline-events", + "/health/devices", + "/health/alert-dashboard", "/health/alerts", + "/health/ai-usage", + "/messages", + ]; + assign_menus_for_role(db, "operator", op_paths).await?; + + // 也要为 admin 角色显式关联所有可见菜单 + assign_admin_all_menus(db).await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + // 删除新创建的角色及其关联 + for code in &["doctor", "nurse", "health_manager", "operator"] { + // 删除 menu_roles 关联 + db.execute_unprepared(&format!( + "UPDATE menu_roles SET deleted_at = NOW() WHERE role_id IN (SELECT id FROM roles WHERE code = '{code}')" + )).await?; + // 删除 role_permissions 关联 + db.execute_unprepared(&format!( + "UPDATE role_permissions SET deleted_at = NOW() WHERE role_id IN (SELECT id FROM roles WHERE code = '{code}')" + )).await?; + // 删除 user_roles 关联 + db.execute_unprepared(&format!( + "UPDATE user_roles SET deleted_at = NOW() WHERE role_id IN (SELECT id FROM roles WHERE code = '{code}')" + )).await?; + // 软删除角色 + db.execute_unprepared(&format!( + "UPDATE roles SET deleted_at = NOW() WHERE code = '{code}' AND is_system = false" + )).await?; + } + + // 恢复目录名 + let dir_renames: &[(&str, &str)] = &[ + ("工作台", "基础模块"), + ("系统管理", "业务模块"), + ("健康业务", "健康管理"), + ]; + for &(old, new) in dir_renames { + db.execute_unprepared(&format!( + "UPDATE menus SET title = '{new}' WHERE title = '{old}' AND menu_type = 'directory'" + )).await?; + } + + // 恢复"系统"目录 + db.execute_unprepared( + "UPDATE menus SET deleted_at = NULL WHERE title = '配置' AND menu_type = 'directory'" + ).await?; + // 恢复"系统"名称 + db.execute_unprepared( + "UPDATE menus SET title = '系统' WHERE title = '配置' AND menu_type = 'directory'" + ).await?; + + Ok(()) + } +} + +/// 为指定角色分配权限码 +async fn assign_perms_by_codes( + db: &sea_orm_migration::SchemaManagerConnection<'_>, + role_code: &str, + perm_codes: &[&str], +) -> Result<(), DbErr> { + let codes_csv: String = perm_codes + .iter() + .map(|c| format!("'{}'", c)) + .collect::>() + .join(","); + + // 为所有租户的该角色分配这些权限 + // 使用 ON CONFLICT 处理已软删除的行(恢复它们而非报错) + db.execute_unprepared(&format!( + "INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope, created_at, updated_at, created_by, updated_by, deleted_at, version) \ + SELECT r.id, p.id, r.tenant_id, 'all', NOW(), NOW(), r.id, r.id, NULL, 1 \ + FROM roles r \ + JOIN permissions p ON p.tenant_id = r.tenant_id AND p.code IN ({codes_csv}) AND p.deleted_at IS NULL \ + WHERE r.code = '{role_code}' AND r.deleted_at IS NULL \ + ON CONFLICT (role_id, permission_id) DO UPDATE SET \ + data_scope = 'all', deleted_at = NULL, updated_at = NOW(), version = role_permissions.version + 1" + )).await?; + + Ok(()) +} + +/// 为指定角色关联菜单 +async fn assign_menus_for_role( + db: &sea_orm_migration::SchemaManagerConnection<'_>, + role_code: &str, + menu_paths: &[&str], +) -> Result<(), DbErr> { + let paths_csv: String = menu_paths + .iter() + .map(|p| format!("'{}'", p)) + .collect::>() + .join(","); + + // 为所有租户的该角色关联菜单 + db.execute_unprepared(&format!( + "INSERT INTO menu_roles (id, menu_id, role_id, tenant_id, created_at, updated_at, created_by, updated_by, deleted_at, version) \ + SELECT gen_random_uuid(), m.id, r.id, r.tenant_id, NOW(), NOW(), r.id, r.id, NULL, 1 \ + FROM roles r \ + JOIN menus m ON m.tenant_id = r.tenant_id AND m.path IN ({paths_csv}) AND m.deleted_at IS NULL AND m.visible = true \ + WHERE r.code = '{role_code}' AND r.deleted_at IS NULL \ + ON CONFLICT (id) DO NOTHING" + )).await?; + + // 同时关联所有顶级 directory(菜单组标题),确保目录可见 + db.execute_unprepared(&format!( + "INSERT INTO menu_roles (id, menu_id, role_id, tenant_id, created_at, updated_at, created_by, updated_by, deleted_at, version) \ + SELECT gen_random_uuid(), m.id, r.id, r.tenant_id, NOW(), NOW(), r.id, r.id, NULL, 1 \ + FROM roles r \ + JOIN menus m ON m.tenant_id = r.tenant_id AND m.menu_type = 'directory' AND m.deleted_at IS NULL \ + WHERE r.code = '{role_code}' AND r.deleted_at IS NULL \ + ON CONFLICT (id) DO NOTHING" + )).await?; + + Ok(()) +} + +/// 为 admin 角色关联所有可见菜单 +async fn assign_admin_all_menus( + db: &sea_orm_migration::SchemaManagerConnection<'_>, +) -> Result<(), DbErr> { + db.execute_unprepared( + "INSERT INTO menu_roles (id, menu_id, role_id, tenant_id, created_at, updated_at, created_by, updated_by, deleted_at, version) \ + SELECT gen_random_uuid(), m.id, r.id, r.tenant_id, NOW(), NOW(), r.id, r.id, NULL, 1 \ + FROM roles r \ + JOIN menus m ON m.tenant_id = r.tenant_id AND m.deleted_at IS NULL AND m.visible = true \ + WHERE r.code = 'admin' AND r.deleted_at IS NULL \ + ON CONFLICT (id) DO NOTHING" + ).await?; + + Ok(()) +}