From 95fa09c383c2bdc284f2148cc54fbc1582bfd468 Mon Sep 17 00:00:00 2001 From: iven Date: Mon, 4 May 2026 20:57:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(health):=20=E5=AE=B6=E5=BA=AD=E6=88=90?= =?UTF-8?q?=E5=91=98=E5=81=A5=E5=BA=B7=E4=BB=A3=E7=90=86=20=E2=80=94=20?= =?UTF-8?q?=E5=90=8C=E6=84=8F=E8=BF=BD=E8=B8=AA=20+=20=E5=81=A5=E5=BA=B7?= =?UTF-8?q?=E6=91=98=E8=A6=81=E6=9F=A5=E7=9C=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 Care Engine MVP 最后一项 (#8): - 迁移: patient_family_member 表新增 user_id/consent_status/access_level/consented_at/consent_revoked_at - 实体: 更新 patient_family_member Model 含新字段 - DTO: FamilyMemberResp 扩展 + 新增 GrantFamilyAccessReq/FamilyPatientSummaryResp/FamilyHealthSummaryResp - Service: 授权/撤销访问、家庭成员查看关联患者列表、查看健康摘要(按 access_level 分级) - Handler: 5 个端点(grant/revoke/list/summary/link-user) - 路由: /health/patients/{id}/family-members/{fid}/grant-access 等 - 权限: health.family-proxy.list/manage - 已有 CRUD 适配新字段(list/create/update 返回 consent 状态) --- crates/erp-health/src/dto/patient_dto.rs | 37 ++ .../src/entity/patient_family_member.rs | 11 + crates/erp-health/src/error.rs | 4 + .../src/handler/family_proxy_handler.rs | 84 ++++ crates/erp-health/src/handler/mod.rs | 1 + crates/erp-health/src/module.rs | 37 +- .../src/service/family_proxy_service.rs | 362 ++++++++++++++++++ crates/erp-health/src/service/mod.rs | 1 + .../src/service/patient_service/relation.rs | 17 + crates/erp-server/migration/src/lib.rs | 2 + ...60505_000115_family_member_health_proxy.rs | 120 ++++++ 11 files changed, 675 insertions(+), 1 deletion(-) create mode 100644 crates/erp-health/src/handler/family_proxy_handler.rs create mode 100644 crates/erp-health/src/service/family_proxy_service.rs create mode 100644 crates/erp-server/migration/src/m20260505_000115_family_member_health_proxy.rs diff --git a/crates/erp-health/src/dto/patient_dto.rs b/crates/erp-health/src/dto/patient_dto.rs index 5881a3a..fc2d36e 100644 --- a/crates/erp-health/src/dto/patient_dto.rs +++ b/crates/erp-health/src/dto/patient_dto.rs @@ -112,6 +112,10 @@ pub struct FamilyMemberResp { pub phone: Option, pub birth_date: Option, pub notes: Option, + pub user_id: Option, + pub consent_status: String, + pub access_level: String, + pub consented_at: Option>, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, pub version: i32, @@ -138,3 +142,36 @@ pub struct TagResp { pub color: Option, pub description: Option, } + +// --------------------------------------------------------------------------- +// 家庭成员健康代理 DTO +// --------------------------------------------------------------------------- + +/// 患者授权家庭成员查看健康数据 +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct GrantFamilyAccessReq { + pub access_level: String, +} + +/// 家庭成员查看关联患者列表 +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct FamilyPatientSummaryResp { + pub family_member_id: Uuid, + pub patient_id: Uuid, + pub patient_name: String, + pub relationship: String, + pub consent_status: String, + pub access_level: String, + pub consented_at: Option>, +} + +/// 家庭成员查看的患者健康摘要 +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct FamilyHealthSummaryResp { + pub patient_id: Uuid, + pub patient_name: String, + pub latest_vital_signs: Option, + pub active_care_plan: Option, + pub recent_alerts_count: i64, + pub next_appointment: Option, +} diff --git a/crates/erp-health/src/entity/patient_family_member.rs b/crates/erp-health/src/entity/patient_family_member.rs index b17e2a2..4d1672c 100644 --- a/crates/erp-health/src/entity/patient_family_member.rs +++ b/crates/erp-health/src/entity/patient_family_member.rs @@ -18,6 +18,17 @@ pub struct Model { pub birth_date: Option, #[sea_orm(skip_serializing_if = "Option::is_none")] pub notes: Option, + /// 关联系统用户(家庭成员绑定账号后可登录查看) + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub user_id: Option, + /// none / pending / granted / revoked + pub consent_status: String, + /// none / summary / full / limited + pub access_level: String, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub consented_at: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub consent_revoked_at: Option, pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, #[sea_orm(skip_serializing_if = "Option::is_none")] diff --git a/crates/erp-health/src/error.rs b/crates/erp-health/src/error.rs index e9dc48e..26aab08 100644 --- a/crates/erp-health/src/error.rs +++ b/crates/erp-health/src/error.rs @@ -109,6 +109,9 @@ pub enum HealthError { #[error("数据库操作失败: {0}")] DbError(String), + + #[error("权限不足: {0}")] + Forbidden(String), } impl From for AppError { @@ -149,6 +152,7 @@ impl From for AppError { HealthError::ScheduleFull => AppError::Validation(err.to_string()), HealthError::InvalidStatusTransition(s) => AppError::Validation(s), HealthError::VersionMismatch => AppError::VersionMismatch, + HealthError::Forbidden(msg) => AppError::Forbidden(msg), HealthError::DbError(_) => AppError::Internal(err.to_string()), } } diff --git a/crates/erp-health/src/handler/family_proxy_handler.rs b/crates/erp-health/src/handler/family_proxy_handler.rs new file mode 100644 index 0000000..e35264a --- /dev/null +++ b/crates/erp-health/src/handler/family_proxy_handler.rs @@ -0,0 +1,84 @@ +//! 家庭成员健康代理 Handler — 同意管理 + 健康摘要查看 + +use axum::extract::{Json, Path, Query, State}; +use axum::Extension; +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, TenantContext}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::dto::patient_dto::*; +use crate::service::family_proxy_service; +use crate::state::HealthState; + +#[derive(Debug, Clone, Deserialize)] +pub struct VersionQuery { + pub version: i32, +} + +/// 授权家庭成员访问健康数据 +pub async fn grant_family_access( + State(state): State, + Extension(ctx): Extension, + Path((patient_id, family_member_id)): Path<(Uuid, Uuid)>, + Query(params): Query, + Json(req): Json, +) -> Result>, AppError> { + require_permission(&ctx, "health.patient.manage")?; + let result = family_proxy_service::grant_family_access( + &state, ctx.tenant_id, patient_id, family_member_id, + Some(ctx.user_id), req, params.version, + ).await?; + Ok(Json(ApiResponse::ok(result))) +} + +/// 撤销家庭成员健康数据访问 +pub async fn revoke_family_access( + State(state): State, + Extension(ctx): Extension, + Path((patient_id, family_member_id)): Path<(Uuid, Uuid)>, + Query(params): Query, +) -> Result>, AppError> { + require_permission(&ctx, "health.patient.manage")?; + let result = family_proxy_service::revoke_family_access( + &state, ctx.tenant_id, patient_id, family_member_id, + Some(ctx.user_id), params.version, + ).await?; + Ok(Json(ApiResponse::ok(result))) +} + +/// 家庭成员查看关联患者列表 +pub async fn list_my_family_patients( + State(state): State, + Extension(ctx): Extension, +) -> Result>>, AppError> { + let result = family_proxy_service::list_family_patients( + &state, ctx.tenant_id, ctx.user_id, + ).await?; + Ok(Json(ApiResponse::ok(result))) +} + +/// 家庭成员查看患者健康摘要 +pub async fn get_family_health_summary( + State(state): State, + Extension(ctx): Extension, + Path(patient_id): Path, +) -> Result>, AppError> { + let result = family_proxy_service::get_family_health_summary( + &state, ctx.tenant_id, ctx.user_id, patient_id, + ).await?; + Ok(Json(ApiResponse::ok(result))) +} + +/// 绑定家庭成员到系统用户(小程序扫码验证后调用) +pub async fn link_family_member_user( + State(state): State, + Extension(ctx): Extension, + Path(family_member_id): Path, +) -> Result>, AppError> { + let result = family_proxy_service::link_family_member_user( + &state, ctx.tenant_id, family_member_id, ctx.user_id, + ).await?; + Ok(Json(ApiResponse::ok(result))) +} diff --git a/crates/erp-health/src/handler/mod.rs b/crates/erp-health/src/handler/mod.rs index 9e4a8fd..dd28264 100644 --- a/crates/erp-health/src/handler/mod.rs +++ b/crates/erp-health/src/handler/mod.rs @@ -15,6 +15,7 @@ pub mod daily_monitoring_handler; pub mod device_handler; pub mod device_reading_handler; pub mod diagnosis_handler; +pub mod family_proxy_handler; pub mod medication_record_handler; pub mod medication_reminder_handler; pub mod doctor_handler; diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs index d62f5f6..e73e60b 100644 --- a/crates/erp-health/src/module.rs +++ b/crates/erp-health/src/module.rs @@ -9,7 +9,7 @@ use crate::handler::{ action_inbox_handler, alert_handler, alert_rule_handler, appointment_handler, article_category_handler, article_handler, article_tag_handler, - ble_gateway_handler, care_plan_handler, consultation_handler, consent_handler, critical_alert_handler, critical_value_threshold_handler, daily_monitoring_handler, device_handler, device_reading_handler, diagnosis_handler, doctor_handler, follow_up_handler, follow_up_template_handler, + ble_gateway_handler, care_plan_handler, consultation_handler, consent_handler, critical_alert_handler, critical_value_threshold_handler, daily_monitoring_handler, device_handler, device_reading_handler, diagnosis_handler, doctor_handler, family_proxy_handler, follow_up_handler, follow_up_template_handler, health_data_handler, medication_record_handler, medication_reminder_handler, patient_handler, points_handler, shift_handler, stats_handler, vital_signs_daily_handler, }; @@ -233,6 +233,28 @@ impl HealthModule { "/health/patients/{id}/doctors", axum::routing::post(patient_handler::assign_doctor), ) + // 家庭成员健康代理 — 管理端 + .route( + "/health/patients/{patient_id}/family-members/{family_member_id}/grant-access", + axum::routing::post(family_proxy_handler::grant_family_access), + ) + .route( + "/health/patients/{patient_id}/family-members/{family_member_id}/revoke-access", + axum::routing::put(family_proxy_handler::revoke_family_access), + ) + // 家庭成员健康代理 — 患者端(小程序) + .route( + "/health/family/patients", + axum::routing::get(family_proxy_handler::list_my_family_patients), + ) + .route( + "/health/family/patients/{patient_id}/health-summary", + axum::routing::get(family_proxy_handler::get_family_health_summary), + ) + .route( + "/health/family/members/{family_member_id}/link-user", + axum::routing::post(family_proxy_handler::link_family_member_user), + ) .route( "/health/patients/{id}/doctors/{did}", axum::routing::delete(patient_handler::remove_doctor), @@ -1384,6 +1406,19 @@ impl ErpModule for HealthModule { description: "注册/编辑/删除 BLE 网关、管理患者绑定".into(), module: "health".into(), }, + // 家庭成员健康代理 + PermissionDescriptor { + code: "health.family-proxy.list".into(), + name: "查看家庭健康代理".into(), + description: "家庭成员查看关联患者列表和健康摘要".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.family-proxy.manage".into(), + name: "管理家庭健康代理".into(), + description: "授权/撤销家庭成员健康数据访问".into(), + module: "health".into(), + }, ] } diff --git a/crates/erp-health/src/service/family_proxy_service.rs b/crates/erp-health/src/service/family_proxy_service.rs new file mode 100644 index 0000000..4a2568e --- /dev/null +++ b/crates/erp-health/src/service/family_proxy_service.rs @@ -0,0 +1,362 @@ +//! 家庭成员健康代理 Service — 同意追踪 + 健康摘要查看 + +use chrono::Utc; +use sea_orm::ActiveValue::Set; +use sea_orm::entity::prelude::*; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder}; +use uuid::Uuid; + +use crate::dto::patient_dto::*; +use crate::entity::patient_family_member; +use crate::error::{HealthError, HealthResult}; +use crate::state::HealthState; + +/// 授权家庭成员访问健康数据 +pub async fn grant_family_access( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + family_member_id: Uuid, + operator_id: Option, + req: GrantFamilyAccessReq, + expected_version: i32, +) -> HealthResult { + validate_access_level(&req.access_level)?; + + let model = patient_family_member::Entity::find() + .filter(patient_family_member::Column::Id.eq(family_member_id)) + .filter(patient_family_member::Column::PatientId.eq(patient_id)) + .filter(patient_family_member::Column::TenantId.eq(tenant_id)) + .filter(patient_family_member::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::FamilyMemberNotFound)?; + + let next_ver = erp_core::error::check_version(expected_version, model.version) + .map_err(|_| HealthError::VersionMismatch)?; + + let mut active: patient_family_member::ActiveModel = model.into(); + active.consent_status = Set("granted".into()); + active.access_level = Set(req.access_level); + active.consented_at = Set(Some(Utc::now())); + active.consent_revoked_at = Set(None); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + + let updated = active.update(&state.db).await?; + + tracing::info!( + family_member_id = %updated.id, + patient_id = %patient_id, + access_level = %updated.access_level, + "家庭成员健康访问已授权" + ); + + Ok(to_family_member_resp(&state, updated)) +} + +/// 撤销家庭成员健康数据访问 +pub async fn revoke_family_access( + state: &HealthState, + tenant_id: Uuid, + patient_id: Uuid, + family_member_id: Uuid, + operator_id: Option, + expected_version: i32, +) -> HealthResult { + let model = patient_family_member::Entity::find() + .filter(patient_family_member::Column::Id.eq(family_member_id)) + .filter(patient_family_member::Column::PatientId.eq(patient_id)) + .filter(patient_family_member::Column::TenantId.eq(tenant_id)) + .filter(patient_family_member::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::FamilyMemberNotFound)?; + + let next_ver = erp_core::error::check_version(expected_version, model.version) + .map_err(|_| HealthError::VersionMismatch)?; + + let mut active: patient_family_member::ActiveModel = model.into(); + active.consent_status = Set("revoked".into()); + active.access_level = Set("none".into()); + active.consent_revoked_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + + let updated = active.update(&state.db).await?; + + tracing::info!( + family_member_id = %updated.id, + patient_id = %patient_id, + "家庭成员健康访问已撤销" + ); + + Ok(to_family_member_resp(&state, updated)) +} + +/// 家庭成员查看关联患者列表(通过 user_id 关联) +pub async fn list_family_patients( + state: &HealthState, + tenant_id: Uuid, + user_id: Uuid, +) -> HealthResult> { + let links = patient_family_member::Entity::find() + .filter(patient_family_member::Column::TenantId.eq(tenant_id)) + .filter(patient_family_member::Column::UserId.eq(user_id)) + .filter(patient_family_member::Column::DeletedAt.is_null()) + .filter(patient_family_member::Column::ConsentStatus.eq("granted")) + .all(&state.db) + .await?; + + let mut result = Vec::with_capacity(links.len()); + for link in links { + let patient = match crate::entity::patient::Entity::find_by_id(link.patient_id) + .one(&state.db) + .await? + { + Some(p) => p, + None => continue, + }; + + result.push(FamilyPatientSummaryResp { + family_member_id: link.id, + patient_id: link.patient_id, + patient_name: patient.name, + relationship: link.relationship, + consent_status: link.consent_status, + access_level: link.access_level, + consented_at: link.consented_at, + }); + } + + Ok(result) +} + +/// 家庭成员查看患者健康摘要 +pub async fn get_family_health_summary( + state: &HealthState, + tenant_id: Uuid, + user_id: Uuid, + patient_id: Uuid, +) -> HealthResult { + // 验证家庭成员有访问权限 + let link = patient_family_member::Entity::find() + .filter(patient_family_member::Column::TenantId.eq(tenant_id)) + .filter(patient_family_member::Column::PatientId.eq(patient_id)) + .filter(patient_family_member::Column::UserId.eq(user_id)) + .filter(patient_family_member::Column::DeletedAt.is_null()) + .filter(patient_family_member::Column::ConsentStatus.eq("granted")) + .one(&state.db) + .await? + .ok_or(HealthError::Forbidden("无权查看该患者健康数据".into()))?; + + let patient = crate::entity::patient::Entity::find_by_id(patient_id) + .one(&state.db) + .await? + .ok_or(HealthError::PatientNotFound)?; + + let access = link.access_level.as_str(); + + // 根据访问级别构建摘要 + let latest_vital_signs = if matches!(access, "summary" | "full" | "limited") { + get_latest_vital_signs_summary(&state.db, tenant_id, patient_id).await? + } else { + None + }; + + let active_care_plan = if matches!(access, "full" | "summary") { + get_active_care_plan_summary(&state.db, tenant_id, patient_id).await? + } else { + None + }; + + let recent_alerts_count = if matches!(access, "full" | "limited") { + count_recent_alerts(&state.db, tenant_id, patient_id).await? + } else { + 0 + }; + + let next_appointment = if matches!(access, "summary" | "full") { + get_next_appointment_summary(&state.db, tenant_id, patient_id).await? + } else { + None + }; + + Ok(FamilyHealthSummaryResp { + patient_id, + patient_name: patient.name, + latest_vital_signs, + active_care_plan, + recent_alerts_count, + next_appointment, + }) +} + +/// 绑定家庭成员到系统用户(扫码/短信验证后调用) +pub async fn link_family_member_user( + state: &HealthState, + tenant_id: Uuid, + family_member_id: Uuid, + user_id: Uuid, +) -> HealthResult { + let model = patient_family_member::Entity::find() + .filter(patient_family_member::Column::Id.eq(family_member_id)) + .filter(patient_family_member::Column::TenantId.eq(tenant_id)) + .filter(patient_family_member::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::FamilyMemberNotFound)?; + + let mut active: patient_family_member::ActiveModel = model.into(); + active.user_id = Set(Some(user_id)); + active.updated_at = Set(Utc::now()); + + let updated = active.update(&state.db).await?; + + tracing::info!( + family_member_id = %updated.id, + user_id = %user_id, + "家庭成员已绑定系统用户" + ); + + Ok(to_family_member_resp(&state, updated)) +} + +// --------------------------------------------------------------------------- +// 私有辅助函数 +// --------------------------------------------------------------------------- + +fn validate_access_level(level: &str) -> HealthResult<()> { + match level { + "summary" | "full" | "limited" => Ok(()), + _ => Err(HealthError::Validation(format!( + "无效的访问级别: {},允许值: summary, full, limited", level + ))), + } +} + +fn to_family_member_resp( + state: &HealthState, + m: patient_family_member::Model, +) -> FamilyMemberResp { + let kek = state.crypto.kek(); + let decrypted_phone = m.phone.as_ref() + .map(|p| erp_core::crypto::decrypt(kek, p).unwrap_or_else(|_| p.clone())); + + FamilyMemberResp { + id: m.id, + patient_id: m.patient_id, + name: m.name, + relationship: m.relationship, + phone: decrypted_phone, + birth_date: m.birth_date, + notes: m.notes, + user_id: m.user_id, + consent_status: m.consent_status, + access_level: m.access_level, + consented_at: m.consented_at, + created_at: m.created_at, + updated_at: m.updated_at, + version: m.version, + } +} + +async fn get_latest_vital_signs_summary( + db: &sea_orm::DatabaseConnection, + tenant_id: Uuid, + patient_id: Uuid, +) -> HealthResult> { + use crate::entity::vital_signs; + + let latest = vital_signs::Entity::find() + .filter(vital_signs::Column::TenantId.eq(tenant_id)) + .filter(vital_signs::Column::PatientId.eq(patient_id)) + .filter(vital_signs::Column::DeletedAt.is_null()) + .order_by_desc(vital_signs::Column::RecordDate) + .one(db) + .await?; + + Ok(latest.map(|v| serde_json::json!({ + "record_date": v.record_date, + "systolic_bp_morning": v.systolic_bp_morning, + "diastolic_bp_morning": v.diastolic_bp_morning, + "systolic_bp_evening": v.systolic_bp_evening, + "diastolic_bp_evening": v.diastolic_bp_evening, + "heart_rate": v.heart_rate, + "blood_sugar": v.blood_sugar, + "spo2": v.spo2, + "body_temperature": v.body_temperature, + "weight": v.weight, + }))) +} + +async fn get_active_care_plan_summary( + db: &sea_orm::DatabaseConnection, + tenant_id: Uuid, + patient_id: Uuid, +) -> HealthResult> { + use crate::entity::care_plan; + + let plan = care_plan::Entity::find() + .filter(care_plan::Column::TenantId.eq(tenant_id)) + .filter(care_plan::Column::PatientId.eq(patient_id)) + .filter(care_plan::Column::Status.eq("active")) + .filter(care_plan::Column::DeletedAt.is_null()) + .order_by_desc(care_plan::Column::CreatedAt) + .one(db) + .await?; + + Ok(plan.map(|p| serde_json::json!({ + "id": p.id, + "title": p.title, + "status": p.status, + "start_date": p.start_date, + "end_date": p.end_date, + }))) +} + +async fn count_recent_alerts( + db: &sea_orm::DatabaseConnection, + tenant_id: Uuid, + patient_id: Uuid, +) -> HealthResult { + use crate::entity::alerts; + + let count = alerts::Entity::find() + .filter(alerts::Column::TenantId.eq(tenant_id)) + .filter(alerts::Column::PatientId.eq(patient_id)) + .filter(alerts::Column::Status.eq("active")) + .count(db) + .await?; + + Ok(count as i64) +} + +async fn get_next_appointment_summary( + db: &sea_orm::DatabaseConnection, + tenant_id: Uuid, + patient_id: Uuid, +) -> HealthResult> { + use crate::entity::appointment; + + let today = chrono::Utc::now().date_naive(); + let apt = appointment::Entity::find() + .filter(appointment::Column::TenantId.eq(tenant_id)) + .filter(appointment::Column::PatientId.eq(patient_id)) + .filter(appointment::Column::Status.eq("confirmed")) + .filter(appointment::Column::AppointmentDate.gte(today)) + .filter(appointment::Column::DeletedAt.is_null()) + .order_by_asc(appointment::Column::AppointmentDate) + .one(db) + .await?; + + Ok(apt.map(|a| serde_json::json!({ + "id": a.id, + "appointment_date": a.appointment_date, + "start_time": a.start_time, + "end_time": a.end_time, + "type": a.appointment_type, + }))) +} diff --git a/crates/erp-health/src/service/mod.rs b/crates/erp-health/src/service/mod.rs index 871135e..7ece60a 100644 --- a/crates/erp-health/src/service/mod.rs +++ b/crates/erp-health/src/service/mod.rs @@ -19,6 +19,7 @@ pub mod daily_monitoring_service; pub mod device_reading_service; pub mod device_service; pub mod diagnosis_service; +pub mod family_proxy_service; pub mod medication_record_service; pub mod medication_reminder_service; pub mod doctor_service; diff --git a/crates/erp-health/src/service/patient_service/relation.rs b/crates/erp-health/src/service/patient_service/relation.rs index 242d5cf..520b9a0 100644 --- a/crates/erp-health/src/service/patient_service/relation.rs +++ b/crates/erp-health/src/service/patient_service/relation.rs @@ -194,6 +194,10 @@ pub async fn list_family_members( phone, birth_date: m.birth_date, notes: m.notes, + user_id: m.user_id, + consent_status: m.consent_status, + access_level: m.access_level, + consented_at: m.consented_at, created_at: m.created_at, updated_at: m.updated_at, version: m.version, @@ -235,6 +239,11 @@ pub async fn create_family_member( phone_hash: Set(phone_hash), birth_date: Set(req.birth_date), notes: Set(req.notes), + user_id: Set(None), + consent_status: Set("none".into()), + access_level: Set("none".into()), + consented_at: Set(None), + consent_revoked_at: Set(None), created_at: Set(now), updated_at: Set(now), created_by: Set(operator_id), @@ -263,6 +272,10 @@ pub async fn create_family_member( phone: decrypted_phone, birth_date: model.birth_date, notes: model.notes, + user_id: model.user_id, + consent_status: model.consent_status, + access_level: model.access_level, + consented_at: model.consented_at, created_at: model.created_at, updated_at: model.updated_at, version: model.version, @@ -347,6 +360,10 @@ pub async fn update_family_member( phone: decrypted_phone, birth_date: updated.birth_date, notes: updated.notes, + user_id: updated.user_id, + consent_status: updated.consent_status, + access_level: updated.access_level, + consented_at: updated.consented_at, created_at: updated.created_at, updated_at: updated.updated_at, version: updated.version, diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index b83215d..5cd9050 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -114,6 +114,7 @@ mod m20260505_000111_create_care_plan; mod m20260505_000112_create_shift_management; mod m20260505_000113_create_ble_gateways; mod m20260505_000114_dialysis_record_add_workflow_instance; +mod m20260505_000115_family_member_health_proxy; pub struct Migrator; @@ -235,6 +236,7 @@ impl MigratorTrait for Migrator { Box::new(m20260505_000112_create_shift_management::Migration), Box::new(m20260505_000113_create_ble_gateways::Migration), Box::new(m20260505_000114_dialysis_record_add_workflow_instance::Migration), + Box::new(m20260505_000115_family_member_health_proxy::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260505_000115_family_member_health_proxy.rs b/crates/erp-server/migration/src/m20260505_000115_family_member_health_proxy.rs new file mode 100644 index 0000000..16964c5 --- /dev/null +++ b/crates/erp-server/migration/src/m20260505_000115_family_member_health_proxy.rs @@ -0,0 +1,120 @@ +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> { + manager + .alter_table( + Table::alter() + .table(Alias::new("patient_family_member")) + .add_column( + ColumnDef::new(Alias::new("user_id")) + .uuid() + .null() + .to_owned(), + ) + .add_column( + ColumnDef::new(Alias::new("consent_status")) + .string() + .not_null() + .default("none") + .to_owned(), + ) + .add_column( + ColumnDef::new(Alias::new("access_level")) + .string() + .not_null() + .default("none") + .to_owned(), + ) + .add_column( + ColumnDef::new(Alias::new("consented_at")) + .timestamp_with_time_zone() + .null() + .to_owned(), + ) + .add_column( + ColumnDef::new(Alias::new("consent_revoked_at")) + .timestamp_with_time_zone() + .null() + .to_owned(), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_family_member_user_id") + .table(Alias::new("patient_family_member")) + .col(Alias::new("user_id")) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_family_member_consent_status") + .table(Alias::new("patient_family_member")) + .col(Alias::new("consent_status")) + .to_owned(), + ) + .await?; + + manager + .create_foreign_key( + ForeignKey::create() + .name("fk_family_member_user") + .from(Alias::new("patient_family_member"), Alias::new("user_id")) + .to(Alias::new("users"), Alias::new("id")) + .on_delete(ForeignKeyAction::SetNull) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_foreign_key( + ForeignKey::drop() + .name("fk_family_member_user") + .table(Alias::new("patient_family_member")) + .to_owned(), + ) + .await?; + + manager + .drop_index( + Index::drop() + .name("idx_family_member_consent_status") + .to_owned(), + ) + .await?; + + manager + .drop_index(Index::drop().name("idx_family_member_user_id").to_owned()) + .await?; + + manager + .alter_table( + Table::alter() + .table(Alias::new("patient_family_member")) + .drop_column(Alias::new("consent_revoked_at")) + .drop_column(Alias::new("consented_at")) + .drop_column(Alias::new("access_level")) + .drop_column(Alias::new("consent_status")) + .drop_column(Alias::new("user_id")) + .to_owned(), + ) + .await?; + + Ok(()) + } +}