feat: Phase 3c — dose logging + adherence, profile management, tests
Three workstreams, all backend+frontend (per scope decisions): Dose logging + real adherence (backend + frontend): * log_dose now returns the created dose (201 + body) instead of an empty 201. * get_adherence implemented for real: queries the medication_doses collection over the last 30 days, counts taken vs total, computes the rate. The previous implementation hardcoded zeros. Removed the dead calculate_adherence stub. * Frontend: fixed DoseLog type to match backend MedicationDose (taken:bool, loggedAt, camelCase); added AdherenceStats + LogDoseRequest types; logDose() + getAdherence() in api.ts; loadAdherence/logDose actions in the medication store (adherence cache keyed by med id); new DoseLogger component (Taken/ Skipped buttons + LinearProgress adherence bar) embedded in each MedicationManager card. Profile management (backend + frontend): * New GET/PUT /api/profiles/me endpoints (ProfileResponse excludes encryption fields; find_by_user_id + update_name on ProfileRepository). * Register auto-creates a default 'patient' profile (deterministic profile_id = profile_<user_id>) — this is the contract the frontend relies on. * Frontend: Profile type; getProfile()/updateProfileName() in api.ts; useProfileStore; new ProfileEditor component (view/edit name, shows role) as a 4th Dashboard tab. * Resolved the MedicationManager profile_id TODO: now derives profile_<user_id> instead of the 'default' fallback. * NOTE: profile name is stored plaintext (the model anticipates encryption via nameIv/nameAuthTag but no crypto layer is implemented yet — TODO). Vitest tests: * Added @testing-library/user-event; setupTests clears localStorage + cleanup between tests; new test/mockStore.ts helper (mocks the co-located stores, handles both selector and no-selector call patterns). * 5 test files, 20 tests: SeverityChip (4), useMedicationStore actions incl. loadMedications/createMedication/logDose (4), MedicationManager render+dialog (5), InteractionsChecker selection+results (4), HealthStats table+dialog (3). Verified: backend cargo fmt/build/clippy 0 warnings, 19 unit tests pass; frontend npm build clean, 20 vitest tests pass. Solaria round-trip confirmed: profile auto-created on register (GET /profiles/me), PUT updates name, dose log returns the dose body, adherence computes 66.7% for 2-taken/1-skipped. KNOWN FOLLOW-UP (separate task): the backend Medication list response is deeply nested + camelCase + stores fields inside medicationData.data; the frontend Medication type assumes flat top-level snake_case fields. This pre-dates Phase 3c and affects the whole MedicationManager — needs a backend serialization fix or a frontend adapter.
This commit is contained in:
parent
71add3fe92
commit
b6be945855
24 changed files with 1063 additions and 64 deletions
|
|
@ -53,6 +53,9 @@ pub fn build_app(state: AppState) -> Router {
|
|||
.route("/api/shares/:id", delete(handlers::delete_share))
|
||||
// Permission checking
|
||||
.route("/api/permissions/check", post(handlers::check_permission))
|
||||
// Profile management (Phase 3c)
|
||||
.route("/api/profiles/me", get(handlers::get_my_profile))
|
||||
.route("/api/profiles/me", put(handlers::update_my_profile))
|
||||
// Session management (Phase 2.6)
|
||||
.route("/api/sessions", get(handlers::get_sessions))
|
||||
.route("/api/sessions/:id", delete(handlers::revoke_session))
|
||||
|
|
|
|||
|
|
@ -338,15 +338,4 @@ impl MongoDb {
|
|||
.map_err(|e| anyhow::anyhow!("Failed to log dose: {}", e))?;
|
||||
Ok(result.inserted_id.as_object_id())
|
||||
}
|
||||
|
||||
pub async fn get_medication_adherence(
|
||||
&self,
|
||||
medication_id: &str,
|
||||
days: i64,
|
||||
) -> Result<crate::models::medication::AdherenceStats> {
|
||||
let repo = MedicationRepository::new(self.medications.clone());
|
||||
repo.calculate_adherence(medication_id, days)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to calculate adherence: {}", e))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,6 +108,20 @@ pub async fn register(
|
|||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Auto-create a default profile for the new user. The profile_id is
|
||||
// deterministic (profile_<user_id>) and is the contract the frontend
|
||||
// uses for medication creation. Best-effort: registration still
|
||||
// succeeds if this fails (GET /profiles/me lazily creates one).
|
||||
let database = state.db.get_database();
|
||||
let profile_repo =
|
||||
crate::models::profile::ProfileRepository::new(database.collection("profiles"));
|
||||
let profile =
|
||||
crate::handlers::profile::build_default_profile(&id.to_string(), &req.username);
|
||||
if let Err(e) = profile_repo.create(&profile).await {
|
||||
tracing::warn!("Failed to auto-create profile for {}: {}", id, e);
|
||||
}
|
||||
|
||||
id
|
||||
}
|
||||
Ok(None) => {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use crate::{
|
|||
auth::jwt::Claims, // Fixed: import from auth::jwt instead of handlers::auth
|
||||
config::AppState,
|
||||
models::medication::{
|
||||
CreateMedicationRequest, LogDoseRequest, Medication, MedicationRepository,
|
||||
CreateMedicationRequest, LogDoseRequest, Medication, MedicationDose, MedicationRepository,
|
||||
UpdateMedicationRequest,
|
||||
},
|
||||
};
|
||||
|
|
@ -158,12 +158,12 @@ pub async fn log_dose(
|
|||
Extension(claims): Extension<Claims>,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<LogDoseRequest>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
) -> Result<(StatusCode, Json<MedicationDose>), StatusCode> {
|
||||
let database = state.db.get_database();
|
||||
|
||||
let now = SystemTime::now();
|
||||
|
||||
let dose = crate::models::medication::MedicationDose {
|
||||
let mut dose = crate::models::medication::MedicationDose {
|
||||
id: None,
|
||||
medication_id: id.clone(),
|
||||
user_id: claims.sub.clone(),
|
||||
|
|
@ -173,14 +173,16 @@ pub async fn log_dose(
|
|||
notes: req.notes,
|
||||
};
|
||||
|
||||
match database
|
||||
.collection("medication_doses")
|
||||
.insert_one(dose.clone(), None)
|
||||
let result = database
|
||||
.collection::<crate::models::medication::MedicationDose>("medication_doses")
|
||||
.insert_one(&dose, None)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(StatusCode::CREATED),
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// Populate the generated _id so the caller gets the persisted dose back.
|
||||
dose.id = result.inserted_id.as_object_id();
|
||||
|
||||
Ok((StatusCode::CREATED, Json(dose)))
|
||||
}
|
||||
|
||||
pub async fn get_adherence(
|
||||
|
|
@ -188,11 +190,50 @@ pub async fn get_adherence(
|
|||
Extension(_claims): Extension<Claims>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<crate::models::medication::AdherenceStats>, StatusCode> {
|
||||
let database = state.db.get_database();
|
||||
let repo = MedicationRepository::new(database.collection("medications"));
|
||||
use mongodb::bson::{doc, DateTime};
|
||||
|
||||
match repo.calculate_adherence(&id, 30).await {
|
||||
Ok(stats) => Ok(Json(stats)),
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
const PERIOD_DAYS: i64 = 30;
|
||||
|
||||
let database = state.db.get_database();
|
||||
let doses: mongodb::Collection<MedicationDose> = database.collection("medication_doses");
|
||||
|
||||
// Look at doses logged in the last PERIOD_DAYS days for this medication.
|
||||
let since = DateTime::from_system_time(
|
||||
SystemTime::now() - std::time::Duration::from_secs(PERIOD_DAYS as u64 * 86400),
|
||||
);
|
||||
let filter = doc! {
|
||||
"medicationId": &id,
|
||||
"loggedAt": { "$gte": since }
|
||||
};
|
||||
|
||||
let total = doses
|
||||
.count_documents(filter.clone(), None)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let taken_filter = doc! { "$and": [filter, doc! { "taken": true }] };
|
||||
let taken = doses
|
||||
.count_documents(taken_filter, None)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// Without a dose-schedule model, every logged dose counts as one scheduled
|
||||
// dose that was resolved (taken or intentionally skipped). Adherence is the
|
||||
// share that were marked taken. Real scheduling is future work.
|
||||
let missed = total.saturating_sub(taken);
|
||||
let rate = if total == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(taken as f64 / total as f64) * 100.0
|
||||
};
|
||||
|
||||
Ok(Json(crate::models::medication::AdherenceStats {
|
||||
medication_id: id,
|
||||
total_doses: total as i64,
|
||||
scheduled_doses: total as i64,
|
||||
taken_doses: taken as i64,
|
||||
missed_doses: missed as i64,
|
||||
adherence_rate: rate,
|
||||
period_days: PERIOD_DAYS,
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ pub mod health_stats;
|
|||
pub mod interactions;
|
||||
pub mod medications;
|
||||
pub mod permissions;
|
||||
pub mod profile;
|
||||
pub mod sessions;
|
||||
pub mod shares;
|
||||
pub mod users;
|
||||
|
|
@ -21,6 +22,7 @@ pub use medications::{
|
|||
log_dose, update_medication,
|
||||
};
|
||||
pub use permissions::check_permission;
|
||||
pub use profile::{get_my_profile, update_my_profile};
|
||||
pub use sessions::{get_sessions, revoke_all_sessions, revoke_session};
|
||||
pub use shares::{create_share, delete_share, list_shares, update_share};
|
||||
pub use users::{
|
||||
|
|
|
|||
137
backend/src/handlers/profile.rs
Normal file
137
backend/src/handlers/profile.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
use axum::{
|
||||
extract::{Extension, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use mongodb::bson::DateTime;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use validator::Validate;
|
||||
|
||||
use crate::{
|
||||
auth::jwt::Claims,
|
||||
config::AppState,
|
||||
models::profile::{Profile, ProfileRepository},
|
||||
};
|
||||
|
||||
/// The profile as exposed to clients (omits the internal encryption fields).
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ProfileResponse {
|
||||
pub profile_id: String,
|
||||
pub user_id: String,
|
||||
pub name: String,
|
||||
pub role: String,
|
||||
pub permissions: Vec<String>,
|
||||
pub created_at: DateTime,
|
||||
pub updated_at: DateTime,
|
||||
}
|
||||
|
||||
impl From<Profile> for ProfileResponse {
|
||||
fn from(p: Profile) -> Self {
|
||||
Self {
|
||||
profile_id: p.profile_id,
|
||||
user_id: p.user_id,
|
||||
name: p.name,
|
||||
role: p.role,
|
||||
permissions: p.permissions,
|
||||
created_at: p.created_at,
|
||||
updated_at: p.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the default "patient" profile for a freshly registered user. Called
|
||||
/// from `register`. The profile_id is deterministic: `profile_<user_id>` — this
|
||||
/// is the contract the frontend relies on for medication creation.
|
||||
pub fn build_default_profile(user_id: &str, name: &str) -> Profile {
|
||||
let now = DateTime::now();
|
||||
Profile {
|
||||
id: None,
|
||||
profile_id: format!("profile_{user_id}"),
|
||||
user_id: user_id.to_string(),
|
||||
family_id: None,
|
||||
// TODO: encrypt the name (the model anticipates nameIv/nameAuthTag, but
|
||||
// no crypto layer is implemented yet).
|
||||
name: name.to_string(),
|
||||
name_iv: String::new(),
|
||||
name_auth_tag: String::new(),
|
||||
role: "patient".to_string(),
|
||||
permissions: vec!["read:self".to_string(), "write:self".to_string()],
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct UpdateProfileNameRequest {
|
||||
#[validate(length(min = 1, max = 100))]
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// GET /api/profiles/me — the current user's profile. If for some reason the
|
||||
/// auto-created profile is missing, lazily create it.
|
||||
pub async fn get_my_profile(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
) -> Result<Json<ProfileResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
let database = state.db.get_database();
|
||||
let repo = ProfileRepository::new(database.collection("profiles"));
|
||||
|
||||
let profile = match repo.find_by_user_id(&claims.sub).await {
|
||||
Ok(Some(p)) => p,
|
||||
Ok(None) => {
|
||||
// Lazily create if missing (e.g. users registered before this code shipped).
|
||||
let p = build_default_profile(&claims.sub, &claims.sub);
|
||||
if let Err(e) = repo.create(&p).await {
|
||||
tracing::error!("Failed to lazily create profile: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "failed to load profile" })),
|
||||
));
|
||||
}
|
||||
p
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Profile lookup failed: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(ProfileResponse::from(profile)))
|
||||
}
|
||||
|
||||
/// PUT /api/profiles/me — update the profile's display name.
|
||||
pub async fn update_my_profile(
|
||||
State(state): State<AppState>,
|
||||
Extension(claims): Extension<Claims>,
|
||||
Json(req): Json<UpdateProfileNameRequest>,
|
||||
) -> Result<Json<ProfileResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||
if let Err(errors) = req.validate() {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
serde_json::json!({ "error": "validation failed", "details": errors.to_string() }),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let database = state.db.get_database();
|
||||
let repo = ProfileRepository::new(database.collection("profiles"));
|
||||
|
||||
match repo.update_name(&claims.sub, &req.name).await {
|
||||
Ok(Some(updated)) => Ok(Json(ProfileResponse::from(updated))),
|
||||
Ok(None) => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "profile not found" })),
|
||||
)),
|
||||
Err(e) => {
|
||||
tracing::error!("Profile update failed: {}", e);
|
||||
Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": "database error" })),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -335,21 +335,7 @@ impl MedicationRepository {
|
|||
Ok(result.deleted_count > 0)
|
||||
}
|
||||
|
||||
pub async fn calculate_adherence(
|
||||
&self,
|
||||
medication_id: &str,
|
||||
days: i64,
|
||||
) -> Result<AdherenceStats, Box<dyn std::error::Error>> {
|
||||
// For now, return a placeholder adherence calculation
|
||||
// In a full implementation, this would query the medication_doses collection
|
||||
Ok(AdherenceStats {
|
||||
medication_id: medication_id.to_string(),
|
||||
total_doses: 0,
|
||||
scheduled_doses: 0,
|
||||
taken_doses: 0,
|
||||
missed_doses: 0,
|
||||
adherence_rate: 100.0,
|
||||
period_days: days,
|
||||
})
|
||||
}
|
||||
// NOTE: adherence is computed in handlers::medications::get_adherence by
|
||||
// querying the medication_doses collection directly (it needs a different
|
||||
// collection than the medications this repository wraps).
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,4 +52,33 @@ impl ProfileRepository {
|
|||
.find_one(doc! { "profileId": profile_id }, None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Look up a profile by its owning user id.
|
||||
pub async fn find_by_user_id(&self, user_id: &str) -> mongodb::error::Result<Option<Profile>> {
|
||||
self.collection
|
||||
.find_one(doc! { "userId": user_id }, None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Update the profile's display name. NOTE: name is currently stored
|
||||
/// plaintext — the model's nameIv/nameAuthTag fields anticipate encryption
|
||||
/// that isn't implemented yet (TODO).
|
||||
pub async fn update_name(
|
||||
&self,
|
||||
user_id: &str,
|
||||
name: &str,
|
||||
) -> mongodb::error::Result<Option<Profile>> {
|
||||
self.collection
|
||||
.find_one_and_update(
|
||||
doc! { "userId": user_id },
|
||||
doc! { "$set": {
|
||||
"name": name,
|
||||
"nameIv": "",
|
||||
"nameAuthTag": "",
|
||||
"updatedAt": DateTime::now()
|
||||
}},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue