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:
goose 2026-06-28 10:26:58 -03:00
parent 71add3fe92
commit b6be945855
24 changed files with 1063 additions and 64 deletions

View file

@ -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))

View file

@ -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))
}
}

View file

@ -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) => {

View file

@ -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,
}))
}

View file

@ -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::{

View 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" })),
))
}
}
}

View file

@ -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).
}

View file

@ -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
}
}