normogen/backend/src/db/mongodb_impl.rs
goose b6be945855 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.
2026-06-28 10:32:28 -03:00

341 lines
12 KiB
Rust

use anyhow::Result;
use mongodb::bson::oid::ObjectId;
use mongodb::{bson::doc, options::ClientOptions, Client, Collection, Database};
use std::time::Duration;
use crate::models::{
medication::{Medication, MedicationDose, MedicationRepository, UpdateMedicationRequest},
permission::Permission,
share::{Share, ShareRepository},
user::{User, UserRepository},
};
#[derive(Clone)]
pub struct MongoDb {
database: Database,
pub users: Collection<User>,
pub shares: Collection<Share>,
pub medications: Collection<Medication>,
pub medication_doses: Collection<MedicationDose>,
}
impl MongoDb {
pub async fn new(uri: &str, db_name: &str) -> Result<Self> {
eprintln!("[MongoDB] Starting connection to: {}", uri);
// Parse the URI first
let mut client_options = match ClientOptions::parse(uri).await {
Ok(opts) => {
eprintln!("[MongoDB] URI parsed successfully");
opts
}
Err(e) => {
eprintln!("[MongoDB] ERROR: Failed to parse URI: {}", e);
let error_msg = e.to_string().to_lowercase();
if error_msg.contains("dns")
|| error_msg.contains("resolution")
|| error_msg.contains("lookup")
{
eprintln!("[MongoDB] DNS RESOLUTION ERROR DETECTED!");
eprintln!("[MongoDB] Cannot resolve hostname in: {}", uri);
eprintln!("[MongoDB] Error: {}", e);
}
eprintln!(
"[MongoDB] Will continue in degraded mode (database operations will fail)"
);
// Create a minimal configuration that will allow the server to start
// but database operations will fail gracefully
let mut opts = ClientOptions::parse("mongodb://localhost:27017")
.await
.map_err(|e| {
anyhow::anyhow!("Failed to create fallback client options: {}", e)
})?;
opts.server_selection_timeout = Some(Duration::from_secs(1));
opts.connect_timeout = Some(Duration::from_secs(1));
let client = Client::with_options(opts)
.map_err(|e| anyhow::anyhow!("Failed to create MongoDB client: {}", e))?;
let database = client.database(db_name);
return Ok(Self {
users: database.collection("users"),
shares: database.collection("shares"),
medications: database.collection("medications"),
medication_doses: database.collection("medication_doses"),
database,
});
}
};
// Set connection timeout with retry logic
client_options.server_selection_timeout = Some(Duration::from_secs(10));
client_options.connect_timeout = Some(Duration::from_secs(10));
eprintln!("[MongoDB] Connecting to server...");
let client = match Client::with_options(client_options) {
Ok(c) => {
eprintln!("[MongoDB] Client created successfully");
c
}
Err(e) => {
eprintln!("[MongoDB] ERROR: Failed to create client: {}", e);
eprintln!("[MongoDB] Will continue in degraded mode");
// Create a fallback client
let fallback_opts = ClientOptions::parse("mongodb://localhost:27017")
.await
.map_err(|e| {
anyhow::anyhow!("Failed to create fallback client options: {}", e)
})?;
let fallback_client = Client::with_options(fallback_opts)
.map_err(|e| anyhow::anyhow!("Failed to create MongoDB client: {}", e))?;
let database = fallback_client.database(db_name);
return Ok(Self {
users: database.collection("users"),
shares: database.collection("shares"),
medications: database.collection("medications"),
medication_doses: database.collection("medication_doses"),
database,
});
}
};
eprintln!("[MongoDB] Client created, selecting database...");
let database = client.database(db_name);
eprintln!("[MongoDB] Database selected: {}", db_name);
Ok(Self {
users: database.collection("users"),
shares: database.collection("shares"),
medications: database.collection("medications"),
medication_doses: database.collection("medication_doses"),
database,
})
}
pub async fn health_check(&self) -> Result<String> {
eprintln!("[MongoDB] Health check: pinging database...");
match self.database.run_command(doc! { "ping": 1 }, None).await {
Ok(_) => {
eprintln!("[MongoDB] Health check: OK");
Ok("OK".to_string())
}
Err(e) => {
eprintln!("[MongoDB] Health check: FAILED - {}", e);
// Return OK anyway to allow the server to continue running
// The actual database operations will fail when needed
Ok("DEGRADED".to_string())
}
}
}
/// Get a reference to the underlying MongoDB Database
/// This is needed for security services in Phase 2.6
pub fn get_database(&self) -> Database {
self.database.clone()
}
// ===== User Methods =====
pub async fn create_user(&self, user: &User) -> Result<Option<ObjectId>> {
let repo = UserRepository::new(self.users.clone());
Ok(repo.create(user).await?)
}
pub async fn find_user_by_email(&self, email: &str) -> Result<Option<User>> {
let repo = UserRepository::new(self.users.clone());
Ok(repo.find_by_email(email).await?)
}
pub async fn find_user_by_id(&self, id: &ObjectId) -> Result<Option<User>> {
let repo = UserRepository::new(self.users.clone());
Ok(repo.find_by_id(id).await?)
}
pub async fn update_user(&self, user: &User) -> Result<()> {
let repo = UserRepository::new(self.users.clone());
repo.update(user).await?;
Ok(())
}
pub async fn update_last_active(&self, user_id: &ObjectId) -> Result<()> {
let repo = UserRepository::new(self.users.clone());
repo.update_last_active(user_id).await?;
Ok(())
}
pub async fn delete_user(&self, user_id: &ObjectId) -> Result<()> {
let repo = UserRepository::new(self.users.clone());
repo.delete(user_id).await?;
Ok(())
}
// ===== Share Methods =====
pub async fn create_share(&self, share: &Share) -> Result<Option<ObjectId>> {
let repo = ShareRepository::new(self.shares.clone());
Ok(repo.create(share).await?)
}
pub async fn get_share(&self, id: &str) -> Result<Option<Share>> {
let object_id = ObjectId::parse_str(id)?;
let repo = ShareRepository::new(self.shares.clone());
Ok(repo.find_by_id(&object_id).await?)
}
pub async fn list_shares_for_user(&self, user_id: &str) -> Result<Vec<Share>> {
let object_id = ObjectId::parse_str(user_id)?;
let repo = ShareRepository::new(self.shares.clone());
Ok(repo.find_by_target(&object_id).await?)
}
pub async fn update_share(&self, share: &Share) -> Result<()> {
let repo = ShareRepository::new(self.shares.clone());
repo.update(share).await?;
Ok(())
}
pub async fn delete_share(&self, id: &str) -> Result<()> {
let object_id = ObjectId::parse_str(id)?;
let repo = ShareRepository::new(self.shares.clone());
repo.delete(&object_id).await?;
Ok(())
}
// ===== Permission Methods =====
pub async fn check_user_permission(
&self,
user_id: &str,
resource_type: &str,
resource_id: &str,
permission: &str,
) -> Result<bool> {
let user_oid = ObjectId::parse_str(user_id)?;
let resource_oid = ObjectId::parse_str(resource_id)?;
let repo = ShareRepository::new(self.shares.clone());
let shares = repo.find_by_target(&user_oid).await?;
for share in shares {
if share.resource_type == resource_type
&& share.resource_id.as_ref() == Some(&resource_oid)
&& share.active
&& !share.is_expired()
{
// Check if share has the required permission
let perm = match permission.to_lowercase().as_str() {
"read" => Permission::Read,
"write" => Permission::Write,
"delete" => Permission::Delete,
"share" => Permission::Share,
"admin" => Permission::Admin,
_ => return Ok(false),
};
if share.has_permission(&perm) {
return Ok(true);
}
}
}
Ok(false)
}
/// Check permission using a simplified interface
pub async fn check_permission(
&self,
user_id: &str,
resource_id: &str,
permission: &str,
) -> Result<bool> {
// For now, check all resource types
let resource_types = ["profiles", "health_data", "lab_results", "medications"];
for resource_type in resource_types {
if self
.check_user_permission(user_id, resource_type, resource_id, permission)
.await?
{
return Ok(true);
}
}
Ok(false)
}
// ===== Medication Methods (Fixed for Phase 2.8) =====
pub async fn create_medication(&self, medication: &Medication) -> Result<Option<ObjectId>> {
let repo = MedicationRepository::new(self.medications.clone());
let created = repo
.create(medication.clone())
.await
.map_err(|e| anyhow::anyhow!("Failed to create medication: {}", e))?;
Ok(created.id)
}
pub async fn get_medication(&self, id: &str) -> Result<Option<Medication>> {
let object_id = ObjectId::parse_str(id)?;
let repo = MedicationRepository::new(self.medications.clone());
repo.find_by_id(&object_id)
.await
.map_err(|e| anyhow::anyhow!("Failed to get medication: {}", e))
}
pub async fn list_medications(
&self,
user_id: &str,
profile_id: Option<&str>,
) -> Result<Vec<Medication>> {
let repo = MedicationRepository::new(self.medications.clone());
if let Some(profile_id) = profile_id {
Ok(repo
.find_by_user_and_profile(user_id, profile_id)
.await
.map_err(|e| anyhow::anyhow!("Failed to list medications by profile: {}", e))?)
} else {
Ok(repo
.find_by_user(user_id)
.await
.map_err(|e| anyhow::anyhow!("Failed to list medications: {}", e))?)
}
}
pub async fn update_medication(
&self,
id: &str,
updates: UpdateMedicationRequest,
) -> Result<Option<Medication>> {
let object_id = ObjectId::parse_str(id)?;
let repo = MedicationRepository::new(self.medications.clone());
repo.update(&object_id, updates)
.await
.map_err(|e| anyhow::anyhow!("Failed to update medication: {}", e))
}
pub async fn delete_medication(&self, id: &str) -> Result<bool> {
let object_id = ObjectId::parse_str(id)?;
let repo = MedicationRepository::new(self.medications.clone());
repo.delete(&object_id)
.await
.map_err(|e| anyhow::anyhow!("Failed to delete medication: {}", e))
}
pub async fn log_medication_dose(&self, dose: &MedicationDose) -> Result<Option<ObjectId>> {
// Insert the dose into the medication_doses collection
let result = self
.medication_doses
.insert_one(dose.clone(), None)
.await
.map_err(|e| anyhow::anyhow!("Failed to log dose: {}", e))?;
Ok(result.inserted_id.as_object_id())
}
}