Compare commits
2 commits
057303a8d0
...
1301473bfe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1301473bfe | ||
|
|
149ce37654 |
15 changed files with 747 additions and 696 deletions
|
|
@ -33,23 +33,12 @@ pub async fn create_appointment(
|
||||||
let now = SystemTime::now();
|
let now = SystemTime::now();
|
||||||
let appointment_id = uuid::Uuid::new_v4().to_string();
|
let appointment_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
// Pack the request fields into the data blob (camelCase keys).
|
// Zero-knowledge: store the client's opaque encrypted blob verbatim.
|
||||||
let appointment_data = EncryptedField {
|
let appointment_data = EncryptedField {
|
||||||
data: serde_json::json!({
|
data: req.encrypted_data.data,
|
||||||
"title": req.title,
|
encrypted: true,
|
||||||
"provider": req.provider,
|
iv: req.encrypted_data.iv,
|
||||||
"appointmentType": req.appointment_type,
|
auth_tag: req.encrypted_data.auth_tag,
|
||||||
"dateTime": req.date_time,
|
|
||||||
"location": req.location,
|
|
||||||
"durationMinutes": req.duration_minutes,
|
|
||||||
"reason": req.reason,
|
|
||||||
"notes": req.notes,
|
|
||||||
"status": req.status.unwrap_or_else(|| "upcoming".to_string()),
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
encrypted: false,
|
|
||||||
iv: String::new(),
|
|
||||||
auth_tag: String::new(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let appointment = crate::models::appointment::Appointment {
|
let appointment = crate::models::appointment::Appointment {
|
||||||
|
|
@ -59,6 +48,7 @@ pub async fn create_appointment(
|
||||||
profile_id: req.profile_id.clone(),
|
profile_id: req.profile_id.clone(),
|
||||||
appointment_data,
|
appointment_data,
|
||||||
reminders: vec![],
|
reminders: vec![],
|
||||||
|
status: req.status,
|
||||||
created_at: DateTime::from_system_time(now),
|
created_at: DateTime::from_system_time(now),
|
||||||
updated_at: DateTime::from_system_time(now),
|
updated_at: DateTime::from_system_time(now),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -31,28 +31,13 @@ pub async fn create_medication(
|
||||||
let now = SystemTime::now();
|
let now = SystemTime::now();
|
||||||
let medication_id = uuid::Uuid::new_v4().to_string();
|
let medication_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
// Build medication data JSON
|
// Zero-knowledge: the client sends an already-encrypted blob; the server
|
||||||
let medication_data_value = serde_json::json!({
|
// stores it verbatim and never inspects the contents.
|
||||||
"name": req.name,
|
|
||||||
"dosage": req.dosage,
|
|
||||||
"frequency": req.frequency,
|
|
||||||
"route": req.route,
|
|
||||||
"reason": req.reason,
|
|
||||||
"instructions": req.instructions,
|
|
||||||
"sideEffects": req.side_effects.unwrap_or_default(),
|
|
||||||
"prescribedBy": req.prescribed_by,
|
|
||||||
"prescribedDate": req.prescribed_date,
|
|
||||||
"startDate": req.start_date,
|
|
||||||
"endDate": req.end_date,
|
|
||||||
"notes": req.notes,
|
|
||||||
"tags": req.tags.unwrap_or_default(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let medication_data = crate::models::health_data::EncryptedField {
|
let medication_data = crate::models::health_data::EncryptedField {
|
||||||
data: medication_data_value.to_string(),
|
data: req.encrypted_data.data,
|
||||||
encrypted: false,
|
encrypted: true,
|
||||||
iv: String::new(),
|
iv: req.encrypted_data.iv,
|
||||||
auth_tag: String::new(),
|
auth_tag: req.encrypted_data.auth_tag,
|
||||||
};
|
};
|
||||||
|
|
||||||
let medication = Medication {
|
let medication = Medication {
|
||||||
|
|
@ -61,19 +46,11 @@ pub async fn create_medication(
|
||||||
user_id: claims.sub,
|
user_id: claims.sub,
|
||||||
profile_id: req.profile_id.clone(),
|
profile_id: req.profile_id.clone(),
|
||||||
medication_data,
|
medication_data,
|
||||||
reminders: req
|
reminders: vec![],
|
||||||
.reminder_times
|
|
||||||
.unwrap_or_default()
|
|
||||||
.into_iter()
|
|
||||||
.map(|time| crate::models::medication::MedicationReminder {
|
|
||||||
reminder_id: uuid::Uuid::new_v4().to_string(),
|
|
||||||
scheduled_time: time,
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
created_at: now.into(),
|
created_at: now.into(),
|
||||||
updated_at: now.into(),
|
updated_at: now.into(),
|
||||||
active: req.active,
|
active: req.active,
|
||||||
pill_identification: req.pill_identification, // Phase 2.8
|
pill_identification: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
match repo.create(medication).await {
|
match repo.create(medication).await {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ use axum::{
|
||||||
};
|
};
|
||||||
use mongodb::bson::DateTime;
|
use mongodb::bson::DateTime;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use validator::Validate;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::jwt::Claims,
|
auth::jwt::Claims,
|
||||||
|
|
@ -13,12 +12,15 @@ use crate::{
|
||||||
models::profile::{Profile, ProfileRepository},
|
models::profile::{Profile, ProfileRepository},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The profile as exposed to clients (omits the internal encryption fields).
|
/// The profile as exposed to clients. The display name is returned as an opaque
|
||||||
|
/// client-encrypted blob (the server cannot read it).
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct ProfileResponse {
|
pub struct ProfileResponse {
|
||||||
pub profile_id: String,
|
pub profile_id: String,
|
||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
pub name: String,
|
/// Opaque client-encrypted display name.
|
||||||
|
pub name_data: String,
|
||||||
|
pub name_iv: String,
|
||||||
pub role: String,
|
pub role: String,
|
||||||
pub permissions: Vec<String>,
|
pub permissions: Vec<String>,
|
||||||
pub created_at: DateTime,
|
pub created_at: DateTime,
|
||||||
|
|
@ -30,7 +32,8 @@ impl From<Profile> for ProfileResponse {
|
||||||
Self {
|
Self {
|
||||||
profile_id: p.profile_id,
|
profile_id: p.profile_id,
|
||||||
user_id: p.user_id,
|
user_id: p.user_id,
|
||||||
name: p.name,
|
name_data: p.name,
|
||||||
|
name_iv: p.name_iv,
|
||||||
role: p.role,
|
role: p.role,
|
||||||
permissions: p.permissions,
|
permissions: p.permissions,
|
||||||
created_at: p.created_at,
|
created_at: p.created_at,
|
||||||
|
|
@ -41,17 +44,17 @@ impl From<Profile> for ProfileResponse {
|
||||||
|
|
||||||
/// Create the default "patient" profile for a freshly registered user. Called
|
/// Create the default "patient" profile for a freshly registered user. Called
|
||||||
/// from `register`. The profile_id is deterministic: `profile_<user_id>` — this
|
/// from `register`. The profile_id is deterministic: `profile_<user_id>` — this
|
||||||
/// is the contract the frontend relies on for medication creation.
|
/// is the contract the frontend relies on for medication creation. The name is
|
||||||
pub fn build_default_profile(user_id: &str, name: &str) -> Profile {
|
/// empty (not encrypted) because the server has no encryption key; the client
|
||||||
|
/// sets it via PUT /profiles/me after deriving its key.
|
||||||
|
pub fn build_default_profile(user_id: &str, _name: &str) -> Profile {
|
||||||
let now = DateTime::now();
|
let now = DateTime::now();
|
||||||
Profile {
|
Profile {
|
||||||
id: None,
|
id: None,
|
||||||
profile_id: format!("profile_{user_id}"),
|
profile_id: format!("profile_{user_id}"),
|
||||||
user_id: user_id.to_string(),
|
user_id: user_id.to_string(),
|
||||||
family_id: None,
|
family_id: None,
|
||||||
// TODO: encrypt the name (the model anticipates nameIv/nameAuthTag, but
|
name: String::new(),
|
||||||
// no crypto layer is implemented yet).
|
|
||||||
name: name.to_string(),
|
|
||||||
name_iv: String::new(),
|
name_iv: String::new(),
|
||||||
name_auth_tag: String::new(),
|
name_auth_tag: String::new(),
|
||||||
role: "patient".to_string(),
|
role: "patient".to_string(),
|
||||||
|
|
@ -61,10 +64,13 @@ pub fn build_default_profile(user_id: &str, name: &str) -> Profile {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Validate)]
|
/// PUT /profiles/me body — the client sends the encrypted name blob.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdateProfileNameRequest {
|
pub struct UpdateProfileNameRequest {
|
||||||
#[validate(length(min = 1, max = 100))]
|
pub name_data: String,
|
||||||
pub name: String,
|
pub name_iv: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub name_auth_tag: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/profiles/me — the current user's profile. If for some reason the
|
/// GET /api/profiles/me — the current user's profile. If for some reason the
|
||||||
|
|
@ -102,25 +108,24 @@ pub async fn get_my_profile(
|
||||||
Ok(Json(ProfileResponse::from(profile)))
|
Ok(Json(ProfileResponse::from(profile)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PUT /api/profiles/me — update the profile's display name.
|
/// PUT /api/profiles/me — update the profile's encrypted display-name blob.
|
||||||
pub async fn update_my_profile(
|
pub async fn update_my_profile(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(claims): Extension<Claims>,
|
Extension(claims): Extension<Claims>,
|
||||||
Json(req): Json<UpdateProfileNameRequest>,
|
Json(req): Json<UpdateProfileNameRequest>,
|
||||||
) -> Result<Json<ProfileResponse>, (StatusCode, Json<serde_json::Value>)> {
|
) -> 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 database = state.db.get_database();
|
||||||
let repo = ProfileRepository::new(database.collection("profiles"));
|
let repo = ProfileRepository::new(database.collection("profiles"));
|
||||||
|
|
||||||
match repo.update_name(&claims.sub, &req.name).await {
|
match repo
|
||||||
|
.update_encrypted_name(
|
||||||
|
&claims.sub,
|
||||||
|
&req.name_data,
|
||||||
|
&req.name_iv,
|
||||||
|
&req.name_auth_tag,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(Some(updated)) => Ok(Json(ProfileResponse::from(updated))),
|
Ok(Some(updated)) => Ok(Json(ProfileResponse::from(updated))),
|
||||||
Ok(None) => Err((
|
Ok(None) => Err((
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
|
|
|
||||||
|
|
@ -4,74 +4,37 @@ use mongodb::bson::{doc, oid::ObjectId, DateTime};
|
||||||
use mongodb::Collection;
|
use mongodb::Collection;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// APPOINTMENT MODEL
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/// The structured content stored (as a JSON string) inside
|
|
||||||
/// `appointment_data.data`. Deserializes from the camelCase keys the create
|
|
||||||
/// handler packs in. Mirrors the MedicationData pattern.
|
|
||||||
#[derive(Debug, Clone, Deserialize, Default)]
|
|
||||||
pub struct AppointmentData {
|
|
||||||
pub title: String,
|
|
||||||
pub provider: String,
|
|
||||||
#[serde(rename = "appointmentType")]
|
|
||||||
pub appointment_type: String,
|
|
||||||
#[serde(rename = "dateTime")]
|
|
||||||
pub date_time: String,
|
|
||||||
pub location: Option<String>,
|
|
||||||
#[serde(rename = "durationMinutes")]
|
|
||||||
pub duration_minutes: Option<i64>,
|
|
||||||
pub reason: Option<String>,
|
|
||||||
pub notes: Option<String>,
|
|
||||||
#[serde(default = "default_status")]
|
|
||||||
pub status: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_status() -> String {
|
fn default_status() -> String {
|
||||||
"upcoming".to_string()
|
"upcoming".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The flat, snake_case appointment representation returned by the API. Built
|
/// Zero-knowledge wire response. The server returns ONLY metadata + the opaque
|
||||||
/// from a stored `Appointment` by deserializing its `appointment_data.data` blob.
|
/// encrypted data blob + the top-level `status` (filterable without decrypting).
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct AppointmentResponse {
|
pub struct AppointmentResponse {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub appointment_id: String,
|
pub appointment_id: String,
|
||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
pub profile_id: String,
|
pub profile_id: String,
|
||||||
pub title: String,
|
|
||||||
pub provider: String,
|
|
||||||
pub appointment_type: String,
|
|
||||||
pub date_time: String,
|
|
||||||
pub location: Option<String>,
|
|
||||||
pub duration_minutes: Option<i64>,
|
|
||||||
pub reason: Option<String>,
|
|
||||||
pub notes: Option<String>,
|
|
||||||
pub status: String,
|
pub status: String,
|
||||||
|
pub encrypted_data: crate::models::medication::EncryptedFieldWire,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::convert::From<Appointment> for AppointmentResponse {
|
impl std::convert::From<Appointment> for AppointmentResponse {
|
||||||
fn from(a: Appointment) -> Self {
|
fn from(a: Appointment) -> Self {
|
||||||
let data: AppointmentData =
|
|
||||||
serde_json::from_str(&a.appointment_data.data).unwrap_or_default();
|
|
||||||
|
|
||||||
AppointmentResponse {
|
AppointmentResponse {
|
||||||
id: a.id.map(|o| o.to_hex()).unwrap_or_default(),
|
id: a.id.map(|o| o.to_hex()).unwrap_or_default(),
|
||||||
appointment_id: a.appointment_id,
|
appointment_id: a.appointment_id,
|
||||||
user_id: a.user_id,
|
user_id: a.user_id,
|
||||||
profile_id: a.profile_id,
|
profile_id: a.profile_id,
|
||||||
title: data.title,
|
status: a.status,
|
||||||
provider: data.provider,
|
encrypted_data: crate::models::medication::EncryptedFieldWire {
|
||||||
appointment_type: data.appointment_type,
|
data: a.appointment_data.data,
|
||||||
date_time: data.date_time,
|
iv: a.appointment_data.iv,
|
||||||
location: data.location,
|
auth_tag: a.appointment_data.auth_tag,
|
||||||
duration_minutes: data.duration_minutes,
|
},
|
||||||
reason: data.reason,
|
|
||||||
notes: data.notes,
|
|
||||||
status: data.status,
|
|
||||||
created_at: system_time_to_rfc3339(a.created_at.to_system_time()),
|
created_at: system_time_to_rfc3339(a.created_at.to_system_time()),
|
||||||
updated_at: system_time_to_rfc3339(a.updated_at.to_system_time()),
|
updated_at: system_time_to_rfc3339(a.updated_at.to_system_time()),
|
||||||
}
|
}
|
||||||
|
|
@ -103,6 +66,9 @@ pub struct Appointment {
|
||||||
pub appointment_data: EncryptedField,
|
pub appointment_data: EncryptedField,
|
||||||
#[serde(rename = "reminders")]
|
#[serde(rename = "reminders")]
|
||||||
pub reminders: Vec<AppointmentReminder>,
|
pub reminders: Vec<AppointmentReminder>,
|
||||||
|
/// Top-level status so it can be filtered without decrypting the blob.
|
||||||
|
#[serde(rename = "status", default = "default_status")]
|
||||||
|
pub status: String,
|
||||||
#[serde(rename = "createdAt")]
|
#[serde(rename = "createdAt")]
|
||||||
pub created_at: DateTime,
|
pub created_at: DateTime,
|
||||||
#[serde(rename = "updatedAt")]
|
#[serde(rename = "updatedAt")]
|
||||||
|
|
@ -118,34 +84,21 @@ pub struct AppointmentReminder {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// REQUEST TYPES
|
// REQUEST TYPES — opaque blobs; the client encrypts, the server stores verbatim.
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CreateAppointmentRequest {
|
pub struct CreateAppointmentRequest {
|
||||||
pub title: String,
|
|
||||||
pub provider: String,
|
|
||||||
pub appointment_type: String,
|
|
||||||
pub date_time: String,
|
|
||||||
pub profile_id: String,
|
pub profile_id: String,
|
||||||
pub location: Option<String>,
|
pub encrypted_data: crate::models::medication::EncryptedFieldWire,
|
||||||
pub duration_minutes: Option<i64>,
|
/// Defaults to "upcoming".
|
||||||
pub reason: Option<String>,
|
#[serde(default = "default_status")]
|
||||||
pub notes: Option<String>,
|
pub status: String,
|
||||||
/// Defaults to "upcoming" when omitted.
|
|
||||||
pub status: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdateAppointmentRequest {
|
pub struct UpdateAppointmentRequest {
|
||||||
pub title: Option<String>,
|
pub encrypted_data: Option<crate::models::medication::EncryptedFieldWire>,
|
||||||
pub provider: Option<String>,
|
|
||||||
pub appointment_type: Option<String>,
|
|
||||||
pub date_time: Option<String>,
|
|
||||||
pub location: Option<String>,
|
|
||||||
pub duration_minutes: Option<i64>,
|
|
||||||
pub reason: Option<String>,
|
|
||||||
pub notes: Option<String>,
|
|
||||||
pub status: Option<String>,
|
pub status: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -173,27 +126,21 @@ impl AppointmentRepository {
|
||||||
Ok(appointment)
|
Ok(appointment)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List a user's appointments, optionally filtered by status (a field
|
/// List a user's appointments, optionally filtered by top-level `status`.
|
||||||
/// inside the data blob — so filter post-query in Rust).
|
|
||||||
pub async fn find_by_user_filtered(
|
pub async fn find_by_user_filtered(
|
||||||
&self,
|
&self,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
status: Option<&str>,
|
status: Option<&str>,
|
||||||
) -> Result<Vec<Appointment>, Box<dyn std::error::Error>> {
|
) -> Result<Vec<Appointment>, Box<dyn std::error::Error>> {
|
||||||
let filter = doc! { "userId": user_id };
|
let mut filter = doc! { "userId": user_id };
|
||||||
|
if let Some(s) = status {
|
||||||
|
filter.insert("status", s);
|
||||||
|
}
|
||||||
let mut cursor = self.collection.find(filter, None).await?;
|
let mut cursor = self.collection.find(filter, None).await?;
|
||||||
let mut appointments = Vec::new();
|
let mut appointments = Vec::new();
|
||||||
while let Some(appt) = cursor.next().await {
|
while let Some(appt) = cursor.next().await {
|
||||||
appointments.push(appt?);
|
appointments.push(appt?);
|
||||||
}
|
}
|
||||||
// status lives inside the data blob; filter here.
|
|
||||||
if let Some(wanted) = status {
|
|
||||||
appointments.retain(|a| {
|
|
||||||
serde_json::from_str::<AppointmentData>(&a.appointment_data.data)
|
|
||||||
.map(|d| d.status == wanted)
|
|
||||||
.unwrap_or(false)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(appointments)
|
Ok(appointments)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -205,65 +152,23 @@ impl AppointmentRepository {
|
||||||
Ok(self.collection.find_one(filter, None).await?)
|
Ok(self.collection.find_one(filter, None).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whole-blob replace — the client re-encrypts and sends the new blob.
|
||||||
pub async fn update_by_appointment_id(
|
pub async fn update_by_appointment_id(
|
||||||
&self,
|
&self,
|
||||||
appointment_id: &str,
|
appointment_id: &str,
|
||||||
updates: UpdateAppointmentRequest,
|
updates: UpdateAppointmentRequest,
|
||||||
) -> Result<Option<Appointment>, Box<dyn std::error::Error>> {
|
) -> Result<Option<Appointment>, Box<dyn std::error::Error>> {
|
||||||
let filter = doc! { "appointmentId": appointment_id };
|
let filter = doc! { "appointmentId": appointment_id };
|
||||||
let existing = self.collection.find_one(filter.clone(), None).await?;
|
let mut update_doc = doc! { "updatedAt": DateTime::now() };
|
||||||
let Some(existing) = existing else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut data: AppointmentData =
|
if let Some(blob) = updates.encrypted_data {
|
||||||
serde_json::from_str(&existing.appointment_data.data).unwrap_or_default();
|
update_doc.insert("appointmentData.data", blob.data);
|
||||||
|
update_doc.insert("appointmentData.iv", blob.iv);
|
||||||
if let Some(v) = updates.title {
|
update_doc.insert("appointmentData.authTag", blob.auth_tag);
|
||||||
data.title = v;
|
|
||||||
}
|
}
|
||||||
if let Some(v) = updates.provider {
|
if let Some(status) = updates.status {
|
||||||
data.provider = v;
|
update_doc.insert("status", status);
|
||||||
}
|
}
|
||||||
if let Some(v) = updates.appointment_type {
|
|
||||||
data.appointment_type = v;
|
|
||||||
}
|
|
||||||
if let Some(v) = updates.date_time {
|
|
||||||
data.date_time = v;
|
|
||||||
}
|
|
||||||
if let Some(v) = updates.location {
|
|
||||||
data.location = Some(v);
|
|
||||||
}
|
|
||||||
if let Some(v) = updates.duration_minutes {
|
|
||||||
data.duration_minutes = Some(v);
|
|
||||||
}
|
|
||||||
if let Some(v) = updates.reason {
|
|
||||||
data.reason = Some(v);
|
|
||||||
}
|
|
||||||
if let Some(v) = updates.notes {
|
|
||||||
data.notes = Some(v);
|
|
||||||
}
|
|
||||||
if let Some(v) = updates.status {
|
|
||||||
data.status = v;
|
|
||||||
}
|
|
||||||
|
|
||||||
let data_json = serde_json::json!({
|
|
||||||
"title": data.title,
|
|
||||||
"provider": data.provider,
|
|
||||||
"appointmentType": data.appointment_type,
|
|
||||||
"dateTime": data.date_time,
|
|
||||||
"location": data.location,
|
|
||||||
"durationMinutes": data.duration_minutes,
|
|
||||||
"reason": data.reason,
|
|
||||||
"notes": data.notes,
|
|
||||||
"status": data.status,
|
|
||||||
})
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let update_doc = doc! {
|
|
||||||
"appointmentData.data": data_json,
|
|
||||||
"updatedAt": DateTime::now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(self
|
Ok(self
|
||||||
.collection
|
.collection
|
||||||
|
|
@ -286,60 +191,28 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn appointment_data_deserializes_camel_case_blob() {
|
fn appointment_response_echoes_opaque_blob_and_status() {
|
||||||
let json = r#"{
|
|
||||||
"title": "Cardiology follow-up",
|
|
||||||
"provider": "Dr. House",
|
|
||||||
"appointmentType": "in-person",
|
|
||||||
"dateTime": "2026-07-01T10:00:00Z",
|
|
||||||
"location": "Main Clinic",
|
|
||||||
"durationMinutes": 30,
|
|
||||||
"reason": "check-up",
|
|
||||||
"notes": "bring results",
|
|
||||||
"status": "upcoming"
|
|
||||||
}"#;
|
|
||||||
let data: AppointmentData = serde_json::from_str(json).unwrap();
|
|
||||||
assert_eq!(data.title, "Cardiology follow-up");
|
|
||||||
assert_eq!(data.appointment_type, "in-person");
|
|
||||||
assert_eq!(data.duration_minutes, Some(30));
|
|
||||||
assert_eq!(data.status, "upcoming");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn appointment_data_defaults_status_when_missing() {
|
|
||||||
let json = r#"{"title":"X","provider":"Y","appointmentType":"lab","dateTime":"2026-07-01T00:00:00Z"}"#;
|
|
||||||
let data: AppointmentData = serde_json::from_str(json).unwrap();
|
|
||||||
assert_eq!(data.status, "upcoming");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn appointment_response_flattens() {
|
|
||||||
let blob = serde_json::json!({
|
|
||||||
"title": "Dentist", "provider": "Dr. Molar", "appointmentType": "in-person",
|
|
||||||
"dateTime": "2026-08-01T09:00:00Z", "location": null,
|
|
||||||
"durationMinutes": null, "reason": null, "notes": null, "status": "completed"
|
|
||||||
})
|
|
||||||
.to_string();
|
|
||||||
let appt = Appointment {
|
let appt = Appointment {
|
||||||
id: Some(ObjectId::new()),
|
id: Some(ObjectId::new()),
|
||||||
appointment_id: "apt-1".to_string(),
|
appointment_id: "apt-1".to_string(),
|
||||||
user_id: "user-1".to_string(),
|
user_id: "user-1".to_string(),
|
||||||
profile_id: "profile_user-1".to_string(),
|
profile_id: "profile_user-1".to_string(),
|
||||||
appointment_data: EncryptedField {
|
appointment_data: EncryptedField {
|
||||||
data: blob,
|
data: "opaque-ciphertext".to_string(),
|
||||||
encrypted: false,
|
encrypted: true,
|
||||||
iv: String::new(),
|
iv: "base64-iv".to_string(),
|
||||||
auth_tag: String::new(),
|
auth_tag: String::new(),
|
||||||
},
|
},
|
||||||
reminders: vec![],
|
reminders: vec![],
|
||||||
|
status: "completed".to_string(),
|
||||||
created_at: DateTime::now(),
|
created_at: DateTime::now(),
|
||||||
updated_at: DateTime::now(),
|
updated_at: DateTime::now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let resp: AppointmentResponse = appt.into();
|
let resp: AppointmentResponse = appt.into();
|
||||||
assert_eq!(resp.appointment_id, "apt-1");
|
assert_eq!(resp.appointment_id, "apt-1");
|
||||||
assert_eq!(resp.title, "Dentist");
|
|
||||||
assert_eq!(resp.appointment_type, "in-person");
|
|
||||||
assert_eq!(resp.status, "completed");
|
assert_eq!(resp.status, "completed");
|
||||||
|
assert_eq!(resp.encrypted_data.data, "opaque-ciphertext");
|
||||||
assert!(!resp.id.is_empty());
|
assert!(!resp.id.is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,85 +96,47 @@ pub struct AdherenceStats {
|
||||||
// MEDICATION MODEL (Existing + Phase 2.8 updates)
|
// MEDICATION MODEL (Existing + Phase 2.8 updates)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/// The structured content stored (as a JSON string) inside `medication_data.data`.
|
/// Zero-knowledge wire response. The server returns ONLY metadata + the opaque
|
||||||
/// Deserializes from the camelCase keys the create handler packs in. This is the
|
/// encrypted data blob (which the client decrypts). The server cannot read
|
||||||
/// intermediate type used to flatten a `Medication` into a `MedicationResponse`.
|
/// name/dosage/etc. — those live encrypted inside `encrypted_data`.
|
||||||
#[derive(Debug, Clone, Deserialize, Default)]
|
|
||||||
pub struct MedicationData {
|
|
||||||
pub name: String,
|
|
||||||
pub dosage: String,
|
|
||||||
pub frequency: String,
|
|
||||||
pub route: String,
|
|
||||||
pub reason: Option<String>,
|
|
||||||
pub instructions: Option<String>,
|
|
||||||
#[serde(rename = "sideEffects", default)]
|
|
||||||
pub side_effects: Vec<String>,
|
|
||||||
#[serde(rename = "prescribedBy")]
|
|
||||||
pub prescribed_by: Option<String>,
|
|
||||||
#[serde(rename = "prescribedDate")]
|
|
||||||
pub prescribed_date: Option<String>,
|
|
||||||
#[serde(rename = "startDate")]
|
|
||||||
pub start_date: Option<String>,
|
|
||||||
#[serde(rename = "endDate")]
|
|
||||||
pub end_date: Option<String>,
|
|
||||||
pub notes: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub tags: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The flat, snake_case medication representation returned by the API. Built from
|
|
||||||
/// a stored `Medication` by deserializing its `medication_data.data` blob.
|
|
||||||
/// Timestamps are ISO 8601 strings; `active` reflects the persisted flag.
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct MedicationResponse {
|
pub struct MedicationResponse {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub medication_id: String,
|
pub medication_id: String,
|
||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
pub profile_id: String,
|
pub profile_id: String,
|
||||||
pub name: String,
|
|
||||||
pub dosage: String,
|
|
||||||
pub frequency: String,
|
|
||||||
pub route: String,
|
|
||||||
pub reason: Option<String>,
|
|
||||||
pub instructions: Option<String>,
|
|
||||||
pub side_effects: Vec<String>,
|
|
||||||
pub prescribed_by: Option<String>,
|
|
||||||
pub prescribed_date: Option<String>,
|
|
||||||
pub start_date: Option<String>,
|
|
||||||
pub end_date: Option<String>,
|
|
||||||
pub notes: Option<String>,
|
|
||||||
pub tags: Vec<String>,
|
|
||||||
pub active: bool,
|
pub active: bool,
|
||||||
|
/// Opaque client-encrypted blob (base64 ciphertext + iv). The server stores
|
||||||
|
/// and returns this verbatim; only the client can decrypt it.
|
||||||
|
pub encrypted_data: EncryptedFieldWire,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The opaque-encryption wire shape for an `EncryptedField`, matching what the
|
||||||
|
/// client sends and what the server stores (no `encrypted` bool needed on the
|
||||||
|
/// wire — everything is now ciphertext).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct EncryptedFieldWire {
|
||||||
|
pub data: String,
|
||||||
|
pub iv: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub auth_tag: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl std::convert::From<Medication> for MedicationResponse {
|
impl std::convert::From<Medication> for MedicationResponse {
|
||||||
fn from(m: Medication) -> Self {
|
fn from(m: Medication) -> Self {
|
||||||
// The stored data is a JSON string; tolerate old/malformed rows by
|
|
||||||
// falling back to empty defaults rather than failing the whole response.
|
|
||||||
let data: MedicationData =
|
|
||||||
serde_json::from_str(&m.medication_data.data).unwrap_or_default();
|
|
||||||
|
|
||||||
MedicationResponse {
|
MedicationResponse {
|
||||||
id: m.id.map(|o| o.to_hex()).unwrap_or_default(),
|
id: m.id.map(|o| o.to_hex()).unwrap_or_default(),
|
||||||
medication_id: m.medication_id,
|
medication_id: m.medication_id,
|
||||||
user_id: m.user_id,
|
user_id: m.user_id,
|
||||||
profile_id: m.profile_id,
|
profile_id: m.profile_id,
|
||||||
name: data.name,
|
|
||||||
dosage: data.dosage,
|
|
||||||
frequency: data.frequency,
|
|
||||||
route: data.route,
|
|
||||||
reason: data.reason,
|
|
||||||
instructions: data.instructions,
|
|
||||||
side_effects: data.side_effects,
|
|
||||||
prescribed_by: data.prescribed_by,
|
|
||||||
prescribed_date: data.prescribed_date,
|
|
||||||
start_date: data.start_date,
|
|
||||||
end_date: data.end_date,
|
|
||||||
notes: data.notes,
|
|
||||||
tags: data.tags,
|
|
||||||
active: m.active,
|
active: m.active,
|
||||||
|
encrypted_data: EncryptedFieldWire {
|
||||||
|
data: m.medication_data.data,
|
||||||
|
iv: m.medication_data.iv,
|
||||||
|
auth_tag: m.medication_data.auth_tag,
|
||||||
|
},
|
||||||
created_at: system_time_to_rfc3339(m.created_at.to_system_time()),
|
created_at: system_time_to_rfc3339(m.created_at.to_system_time()),
|
||||||
updated_at: system_time_to_rfc3339(m.updated_at.to_system_time()),
|
updated_at: system_time_to_rfc3339(m.updated_at.to_system_time()),
|
||||||
}
|
}
|
||||||
|
|
@ -258,52 +220,21 @@ pub struct MedicationDose {
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct CreateMedicationRequest {
|
pub struct CreateMedicationRequest {
|
||||||
pub name: String,
|
|
||||||
pub dosage: String,
|
|
||||||
pub frequency: String,
|
|
||||||
pub route: String,
|
|
||||||
pub reason: Option<String>,
|
|
||||||
pub instructions: Option<String>,
|
|
||||||
pub side_effects: Option<Vec<String>>,
|
|
||||||
pub prescribed_by: Option<String>,
|
|
||||||
pub prescribed_date: Option<String>,
|
|
||||||
pub start_date: Option<String>,
|
|
||||||
pub end_date: Option<String>,
|
|
||||||
pub notes: Option<String>,
|
|
||||||
pub tags: Option<Vec<String>>,
|
|
||||||
pub reminder_times: Option<Vec<String>>,
|
|
||||||
pub profile_id: String,
|
pub profile_id: String,
|
||||||
|
/// Opaque client-encrypted data blob. The server stores this verbatim.
|
||||||
|
pub encrypted_data: EncryptedFieldWire,
|
||||||
/// Whether the medication is active on creation (defaults to true).
|
/// Whether the medication is active on creation (defaults to true).
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub active: bool,
|
pub active: bool,
|
||||||
|
|
||||||
/// Pill identification (Phase 2.8 - optional)
|
|
||||||
#[serde(rename = "pill_identification")]
|
|
||||||
pub pill_identification: Option<PillIdentification>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdateMedicationRequest {
|
pub struct UpdateMedicationRequest {
|
||||||
pub name: Option<String>,
|
/// Replacement opaque client-encrypted data blob (whole-blob replace — the
|
||||||
pub dosage: Option<String>,
|
/// client re-encrypts the full record on edit).
|
||||||
pub frequency: Option<String>,
|
pub encrypted_data: Option<EncryptedFieldWire>,
|
||||||
pub route: Option<String>,
|
|
||||||
pub reason: Option<String>,
|
|
||||||
pub instructions: Option<String>,
|
|
||||||
pub side_effects: Option<Vec<String>>,
|
|
||||||
pub prescribed_by: Option<String>,
|
|
||||||
pub prescribed_date: Option<String>,
|
|
||||||
pub start_date: Option<String>,
|
|
||||||
pub end_date: Option<String>,
|
|
||||||
pub notes: Option<String>,
|
|
||||||
pub tags: Option<Vec<String>>,
|
|
||||||
pub reminder_times: Option<Vec<String>>,
|
|
||||||
/// Set the medication active/inactive.
|
/// Set the medication active/inactive.
|
||||||
pub active: Option<bool>,
|
pub active: Option<bool>,
|
||||||
|
|
||||||
/// Pill identification (Phase 2.8 - optional)
|
|
||||||
#[serde(rename = "pill_identification")]
|
|
||||||
pub pill_identification: Option<PillIdentification>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -411,90 +342,24 @@ impl MedicationRepository {
|
||||||
|
|
||||||
/// Update by the application-level medication_id (UUID). The API exposes
|
/// Update by the application-level medication_id (UUID). The API exposes
|
||||||
/// medication_id in URLs, not the Mongo _id.
|
/// medication_id in URLs, not the Mongo _id.
|
||||||
|
/// Whole-blob replace by medication_id (UUID). The client re-encrypts the
|
||||||
|
/// full record and sends the new blob; the server never inspects its contents.
|
||||||
pub async fn update_by_medication_id(
|
pub async fn update_by_medication_id(
|
||||||
&self,
|
&self,
|
||||||
medication_id: &str,
|
medication_id: &str,
|
||||||
updates: UpdateMedicationRequest,
|
updates: UpdateMedicationRequest,
|
||||||
) -> Result<Option<Medication>, Box<dyn std::error::Error>> {
|
) -> Result<Option<Medication>, Box<dyn std::error::Error>> {
|
||||||
let filter = doc! { "medicationId": medication_id };
|
let filter = doc! { "medicationId": medication_id };
|
||||||
let existing = self.collection.find_one(filter.clone(), None).await?;
|
let mut update_doc = doc! { "updatedAt": DateTime::now() };
|
||||||
let Some(existing) = existing else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut data: MedicationData =
|
if let Some(blob) = updates.encrypted_data {
|
||||||
serde_json::from_str(&existing.medication_data.data).unwrap_or_default();
|
update_doc.insert("medicationData.data", blob.data);
|
||||||
|
update_doc.insert("medicationData.iv", blob.iv);
|
||||||
if let Some(v) = updates.name {
|
update_doc.insert("medicationData.authTag", blob.auth_tag);
|
||||||
data.name = v;
|
|
||||||
}
|
}
|
||||||
if let Some(v) = updates.dosage {
|
|
||||||
data.dosage = v;
|
|
||||||
}
|
|
||||||
if let Some(v) = updates.frequency {
|
|
||||||
data.frequency = v;
|
|
||||||
}
|
|
||||||
if let Some(v) = updates.route {
|
|
||||||
data.route = v;
|
|
||||||
}
|
|
||||||
if let Some(v) = updates.reason {
|
|
||||||
data.reason = Some(v);
|
|
||||||
}
|
|
||||||
if let Some(v) = updates.instructions {
|
|
||||||
data.instructions = Some(v);
|
|
||||||
}
|
|
||||||
if let Some(v) = updates.side_effects {
|
|
||||||
data.side_effects = v;
|
|
||||||
}
|
|
||||||
if let Some(v) = updates.prescribed_by {
|
|
||||||
data.prescribed_by = Some(v);
|
|
||||||
}
|
|
||||||
if let Some(v) = updates.prescribed_date {
|
|
||||||
data.prescribed_date = Some(v);
|
|
||||||
}
|
|
||||||
if let Some(v) = updates.start_date {
|
|
||||||
data.start_date = Some(v);
|
|
||||||
}
|
|
||||||
if let Some(v) = updates.end_date {
|
|
||||||
data.end_date = Some(v);
|
|
||||||
}
|
|
||||||
if let Some(v) = updates.notes {
|
|
||||||
data.notes = Some(v);
|
|
||||||
}
|
|
||||||
if let Some(v) = updates.tags {
|
|
||||||
data.tags = v;
|
|
||||||
}
|
|
||||||
// `active` lives on the document, not in the data blob.
|
|
||||||
|
|
||||||
let data_json = serde_json::json!({
|
|
||||||
"name": data.name,
|
|
||||||
"dosage": data.dosage,
|
|
||||||
"frequency": data.frequency,
|
|
||||||
"route": data.route,
|
|
||||||
"reason": data.reason,
|
|
||||||
"instructions": data.instructions,
|
|
||||||
"sideEffects": data.side_effects,
|
|
||||||
"prescribedBy": data.prescribed_by,
|
|
||||||
"prescribedDate": data.prescribed_date,
|
|
||||||
"startDate": data.start_date,
|
|
||||||
"endDate": data.end_date,
|
|
||||||
"notes": data.notes,
|
|
||||||
"tags": data.tags,
|
|
||||||
})
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let mut update_doc = doc! {
|
|
||||||
"medicationData.data": data_json,
|
|
||||||
"updatedAt": DateTime::now(),
|
|
||||||
};
|
|
||||||
if let Some(active) = updates.active {
|
if let Some(active) = updates.active {
|
||||||
update_doc.insert("active", active);
|
update_doc.insert("active", active);
|
||||||
}
|
}
|
||||||
if let Some(pill_identification) = updates.pill_identification {
|
|
||||||
if let Ok(pill_doc) = mongodb::bson::to_document(&pill_identification) {
|
|
||||||
update_doc.insert("pill_identification", pill_doc);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let medication = self
|
let medication = self
|
||||||
.collection
|
.collection
|
||||||
|
|
@ -513,97 +378,23 @@ impl MedicationRepository {
|
||||||
Ok(result.deleted_count > 0)
|
Ok(result.deleted_count > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whole-blob replace by Mongo _id (unused by routes but kept for completeness).
|
||||||
pub async fn update(
|
pub async fn update(
|
||||||
&self,
|
&self,
|
||||||
id: &ObjectId,
|
id: &ObjectId,
|
||||||
updates: UpdateMedicationRequest,
|
updates: UpdateMedicationRequest,
|
||||||
) -> Result<Option<Medication>, Box<dyn std::error::Error>> {
|
) -> Result<Option<Medication>, Box<dyn std::error::Error>> {
|
||||||
// The stored shape is `medicationData: { data: "<JSON string>", ... }`.
|
|
||||||
// The previous implementation used dot-notation ("medicationData.name")
|
|
||||||
// which wrote into phantom paths. Instead: load the existing document,
|
|
||||||
// deserialize its data blob, apply the overrides, re-serialize, and
|
|
||||||
// $set the whole `medicationData.data` string.
|
|
||||||
let filter = doc! { "_id": id };
|
let filter = doc! { "_id": id };
|
||||||
let existing = self.collection.find_one(filter.clone(), None).await?;
|
let mut update_doc = doc! { "updatedAt": DateTime::now() };
|
||||||
let Some(existing) = existing else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut data: MedicationData =
|
if let Some(blob) = updates.encrypted_data {
|
||||||
serde_json::from_str(&existing.medication_data.data).unwrap_or_default();
|
update_doc.insert("medicationData.data", blob.data);
|
||||||
|
update_doc.insert("medicationData.iv", blob.iv);
|
||||||
if let Some(v) = updates.name {
|
update_doc.insert("medicationData.authTag", blob.auth_tag);
|
||||||
data.name = v;
|
|
||||||
}
|
}
|
||||||
if let Some(v) = updates.dosage {
|
|
||||||
data.dosage = v;
|
|
||||||
}
|
|
||||||
if let Some(v) = updates.frequency {
|
|
||||||
data.frequency = v;
|
|
||||||
}
|
|
||||||
if let Some(v) = updates.route {
|
|
||||||
data.route = v;
|
|
||||||
}
|
|
||||||
if let Some(v) = updates.reason {
|
|
||||||
data.reason = Some(v);
|
|
||||||
}
|
|
||||||
if let Some(v) = updates.instructions {
|
|
||||||
data.instructions = Some(v);
|
|
||||||
}
|
|
||||||
if let Some(v) = updates.side_effects {
|
|
||||||
data.side_effects = v;
|
|
||||||
}
|
|
||||||
if let Some(v) = updates.prescribed_by {
|
|
||||||
data.prescribed_by = Some(v);
|
|
||||||
}
|
|
||||||
if let Some(v) = updates.prescribed_date {
|
|
||||||
data.prescribed_date = Some(v);
|
|
||||||
}
|
|
||||||
if let Some(v) = updates.start_date {
|
|
||||||
data.start_date = Some(v);
|
|
||||||
}
|
|
||||||
if let Some(v) = updates.end_date {
|
|
||||||
data.end_date = Some(v);
|
|
||||||
}
|
|
||||||
if let Some(v) = updates.notes {
|
|
||||||
data.notes = Some(v);
|
|
||||||
}
|
|
||||||
if let Some(v) = updates.tags {
|
|
||||||
data.tags = v;
|
|
||||||
}
|
|
||||||
// `active` lives on the document, not in the data blob.
|
|
||||||
|
|
||||||
// Re-serialize with the SAME camelCase keys the create handler uses, so
|
|
||||||
// reads stay consistent.
|
|
||||||
let data_json = serde_json::json!({
|
|
||||||
"name": data.name,
|
|
||||||
"dosage": data.dosage,
|
|
||||||
"frequency": data.frequency,
|
|
||||||
"route": data.route,
|
|
||||||
"reason": data.reason,
|
|
||||||
"instructions": data.instructions,
|
|
||||||
"sideEffects": data.side_effects,
|
|
||||||
"prescribedBy": data.prescribed_by,
|
|
||||||
"prescribedDate": data.prescribed_date,
|
|
||||||
"startDate": data.start_date,
|
|
||||||
"endDate": data.end_date,
|
|
||||||
"notes": data.notes,
|
|
||||||
"tags": data.tags,
|
|
||||||
})
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let mut update_doc = doc! {
|
|
||||||
"medicationData.data": data_json,
|
|
||||||
"updatedAt": DateTime::now(),
|
|
||||||
};
|
|
||||||
if let Some(active) = updates.active {
|
if let Some(active) = updates.active {
|
||||||
update_doc.insert("active", active);
|
update_doc.insert("active", active);
|
||||||
}
|
}
|
||||||
if let Some(pill_identification) = updates.pill_identification {
|
|
||||||
if let Ok(pill_doc) = mongodb::bson::to_document(&pill_identification) {
|
|
||||||
update_doc.insert("pill_identification", pill_doc);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let medication = self
|
let medication = self
|
||||||
.collection
|
.collection
|
||||||
|
|
@ -629,59 +420,18 @@ mod tests {
|
||||||
use crate::models::health_data::EncryptedField;
|
use crate::models::health_data::EncryptedField;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn medication_data_deserializes_camel_case_blob() {
|
fn medication_response_echoes_opaque_blob_unchanged() {
|
||||||
// This is the exact shape the create handler packs into the data string.
|
// Zero-knowledge: the server returns the ciphertext blob verbatim; it
|
||||||
let json = r#"{
|
// never inspects the contents. The blob here is opaque base64 ciphertext.
|
||||||
"name": "Ibuprofen",
|
|
||||||
"dosage": "200mg",
|
|
||||||
"frequency": "as needed",
|
|
||||||
"route": "oral",
|
|
||||||
"reason": null,
|
|
||||||
"instructions": "with food",
|
|
||||||
"sideEffects": ["nausea"],
|
|
||||||
"prescribedBy": "Dr. House",
|
|
||||||
"prescribedDate": "2026-01-01",
|
|
||||||
"startDate": "2026-01-02",
|
|
||||||
"endDate": null,
|
|
||||||
"notes": "take care",
|
|
||||||
"tags": ["pain"]
|
|
||||||
}"#;
|
|
||||||
let data: MedicationData = serde_json::from_str(json).unwrap();
|
|
||||||
assert_eq!(data.name, "Ibuprofen");
|
|
||||||
assert_eq!(data.side_effects, vec!["nausea".to_string()]);
|
|
||||||
assert_eq!(data.prescribed_by.as_deref(), Some("Dr. House"));
|
|
||||||
assert_eq!(data.tags, vec!["pain".to_string()]);
|
|
||||||
assert_eq!(data.route, "oral");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn medication_data_defaults_empty_arrays_for_missing_keys() {
|
|
||||||
// Old rows may lack sideEffects/tags; the #[default] should yield empty vecs.
|
|
||||||
let json = r#"{"name":"X","dosage":"1","frequency":"daily","route":"oral"}"#;
|
|
||||||
let data: MedicationData = serde_json::from_str(json).unwrap();
|
|
||||||
assert!(data.side_effects.is_empty());
|
|
||||||
assert!(data.tags.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn medication_response_flattens_and_uses_snake_case_fields() {
|
|
||||||
let blob = serde_json::json!({
|
|
||||||
"name": "Aspirin", "dosage": "100mg", "frequency": "daily", "route": "oral",
|
|
||||||
"reason": null, "instructions": null,
|
|
||||||
"sideEffects": ["bleeding"], "prescribedBy": null, "prescribedDate": null,
|
|
||||||
"startDate": null, "endDate": null, "notes": null, "tags": []
|
|
||||||
})
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let med = Medication {
|
let med = Medication {
|
||||||
id: Some(ObjectId::new()),
|
id: Some(ObjectId::new()),
|
||||||
medication_id: "med-123".to_string(),
|
medication_id: "med-123".to_string(),
|
||||||
user_id: "user-1".to_string(),
|
user_id: "user-1".to_string(),
|
||||||
profile_id: "profile_user-1".to_string(),
|
profile_id: "profile_user-1".to_string(),
|
||||||
medication_data: EncryptedField {
|
medication_data: EncryptedField {
|
||||||
data: blob,
|
data: "opaque-base64-ciphertext".to_string(),
|
||||||
encrypted: false,
|
encrypted: true,
|
||||||
iv: String::new(),
|
iv: "base64-iv".to_string(),
|
||||||
auth_tag: String::new(),
|
auth_tag: String::new(),
|
||||||
},
|
},
|
||||||
reminders: vec![],
|
reminders: vec![],
|
||||||
|
|
@ -693,35 +443,10 @@ mod tests {
|
||||||
|
|
||||||
let resp: MedicationResponse = med.into();
|
let resp: MedicationResponse = med.into();
|
||||||
assert_eq!(resp.medication_id, "med-123");
|
assert_eq!(resp.medication_id, "med-123");
|
||||||
assert_eq!(resp.name, "Aspirin");
|
assert_eq!(resp.encrypted_data.data, "opaque-base64-ciphertext");
|
||||||
assert_eq!(resp.side_effects, vec!["bleeding".to_string()]);
|
assert_eq!(resp.encrypted_data.iv, "base64-iv");
|
||||||
assert!(resp.active);
|
assert!(resp.active);
|
||||||
assert!(!resp.id.is_empty());
|
assert!(!resp.id.is_empty());
|
||||||
assert!(!resp.created_at.is_empty()); // ISO string
|
assert!(!resp.created_at.is_empty()); // ISO string
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn medication_response_tolerates_malformed_data_blob() {
|
|
||||||
let med = Medication {
|
|
||||||
id: None,
|
|
||||||
medication_id: "m".to_string(),
|
|
||||||
user_id: "u".to_string(),
|
|
||||||
profile_id: "p".to_string(),
|
|
||||||
medication_data: EncryptedField {
|
|
||||||
data: "not valid json".to_string(),
|
|
||||||
encrypted: false,
|
|
||||||
iv: String::new(),
|
|
||||||
auth_tag: String::new(),
|
|
||||||
},
|
|
||||||
reminders: vec![],
|
|
||||||
created_at: DateTime::now(),
|
|
||||||
updated_at: DateTime::now(),
|
|
||||||
active: true,
|
|
||||||
pill_identification: None,
|
|
||||||
};
|
|
||||||
// Should NOT panic — falls back to defaults.
|
|
||||||
let resp: MedicationResponse = med.into();
|
|
||||||
assert_eq!(resp.name, "");
|
|
||||||
assert!(resp.active);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,21 +60,21 @@ impl ProfileRepository {
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update the profile's display name. NOTE: name is currently stored
|
/// Update the profile's display name (opaque client-encrypted blob).
|
||||||
/// plaintext — the model's nameIv/nameAuthTag fields anticipate encryption
|
pub async fn update_encrypted_name(
|
||||||
/// that isn't implemented yet (TODO).
|
|
||||||
pub async fn update_name(
|
|
||||||
&self,
|
&self,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
name: &str,
|
name_data: &str,
|
||||||
|
name_iv: &str,
|
||||||
|
name_auth_tag: &str,
|
||||||
) -> mongodb::error::Result<Option<Profile>> {
|
) -> mongodb::error::Result<Option<Profile>> {
|
||||||
self.collection
|
self.collection
|
||||||
.find_one_and_update(
|
.find_one_and_update(
|
||||||
doc! { "userId": user_id },
|
doc! { "userId": user_id },
|
||||||
doc! { "$set": {
|
doc! { "$set": {
|
||||||
"name": name,
|
"name": name_data,
|
||||||
"nameIv": "",
|
"nameIv": name_iv,
|
||||||
"nameAuthTag": "",
|
"nameAuthTag": name_auth_tag,
|
||||||
"updatedAt": DateTime::now()
|
"updatedAt": DateTime::now()
|
||||||
}},
|
}},
|
||||||
None,
|
None,
|
||||||
|
|
|
||||||
|
|
@ -25,22 +25,32 @@ import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { useAppointmentStore, useAuthStore } from '../../store/useStore';
|
import { useAppointmentStore, useAuthStore } from '../../store/useStore';
|
||||||
import type {
|
import type { Appointment } from '../../types/api';
|
||||||
Appointment,
|
|
||||||
CreateAppointmentRequest,
|
|
||||||
UpdateAppointmentRequest,
|
|
||||||
} from '../../types/api';
|
|
||||||
|
|
||||||
const APPT_TYPES = ['in-person', 'telehealth', 'lab', 'test', 'other'];
|
const APPT_TYPES = ['in-person', 'telehealth', 'lab', 'test', 'other'];
|
||||||
const STATUSES = ['upcoming', 'completed', 'cancelled'];
|
const STATUSES = ['upcoming', 'completed', 'cancelled'];
|
||||||
|
|
||||||
|
// Domain form type (decrypted fields the UI collects; the store encrypts them).
|
||||||
|
interface ApptFormData {
|
||||||
|
title: string;
|
||||||
|
provider: string;
|
||||||
|
appointment_type: string;
|
||||||
|
date_time: string;
|
||||||
|
location?: string;
|
||||||
|
duration_minutes?: number;
|
||||||
|
reason?: string;
|
||||||
|
notes?: string;
|
||||||
|
status?: string;
|
||||||
|
profile_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const statusColor = (status: string): 'success' | 'default' | 'error' => {
|
const statusColor = (status: string): 'success' | 'default' | 'error' => {
|
||||||
if (status === 'upcoming') return 'success';
|
if (status === 'upcoming') return 'success';
|
||||||
if (status === 'cancelled') return 'error';
|
if (status === 'cancelled') return 'error';
|
||||||
return 'default';
|
return 'default';
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyCreate: CreateAppointmentRequest = {
|
const emptyCreate: ApptFormData = {
|
||||||
title: '',
|
title: '',
|
||||||
provider: '',
|
provider: '',
|
||||||
appointment_type: 'in-person',
|
appointment_type: 'in-person',
|
||||||
|
|
@ -67,9 +77,9 @@ export const AppointmentsManager: FC = () => {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [createForm, setCreateForm] = useState<CreateAppointmentRequest>(emptyCreate);
|
const [createForm, setCreateForm] = useState<ApptFormData>(emptyCreate);
|
||||||
const [editTarget, setEditTarget] = useState<Appointment | null>(null);
|
const [editTarget, setEditTarget] = useState<Appointment | null>(null);
|
||||||
const [editForm, setEditForm] = useState<UpdateAppointmentRequest>({});
|
const [editForm, setEditForm] = useState<Partial<ApptFormData>>({});
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Appointment | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Appointment | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
|
@ -88,7 +98,7 @@ export const AppointmentsManager: FC = () => {
|
||||||
const submitCreate = async () => {
|
const submitCreate = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await createAppointment(createForm);
|
await createAppointment(createForm as unknown as Record<string, unknown>);
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
} catch {
|
} catch {
|
||||||
/* error surfaced via store */
|
/* error surfaced via store */
|
||||||
|
|
@ -116,7 +126,7 @@ export const AppointmentsManager: FC = () => {
|
||||||
if (!editTarget?.appointment_id) return;
|
if (!editTarget?.appointment_id) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await updateAppointment(editTarget.appointment_id, editForm);
|
await updateAppointment(editTarget.appointment_id, editForm as unknown as Record<string, unknown>);
|
||||||
setEditTarget(null);
|
setEditTarget(null);
|
||||||
} catch {
|
} catch {
|
||||||
/* error surfaced via store */
|
/* error surfaced via store */
|
||||||
|
|
|
||||||
|
|
@ -25,16 +25,23 @@ import EditIcon from '@mui/icons-material/Edit';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import { useMedicationStore, useAuthStore } from '../../store/useStore';
|
import { useMedicationStore, useAuthStore } from '../../store/useStore';
|
||||||
import type {
|
import type { Medication } from '../../types/api';
|
||||||
Medication,
|
|
||||||
CreateMedicationRequest,
|
|
||||||
UpdateMedicationRequest,
|
|
||||||
} from '../../types/api';
|
|
||||||
import { DoseLogger } from './DoseLogger';
|
import { DoseLogger } from './DoseLogger';
|
||||||
|
|
||||||
const ROUTES = ['oral', 'topical', 'injection', 'inhalation', 'other'] as const;
|
const ROUTES = ['oral', 'topical', 'injection', 'inhalation', 'other'] as const;
|
||||||
|
|
||||||
const emptyCreate: CreateMedicationRequest = {
|
// Domain form types (decrypted fields the UI collects; the store encrypts them).
|
||||||
|
interface MedFormData {
|
||||||
|
name: string;
|
||||||
|
dosage: string;
|
||||||
|
frequency: string;
|
||||||
|
route: string;
|
||||||
|
instructions?: string;
|
||||||
|
profile_id?: string;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyCreate: MedFormData = {
|
||||||
name: '',
|
name: '',
|
||||||
dosage: '',
|
dosage: '',
|
||||||
frequency: '',
|
frequency: '',
|
||||||
|
|
@ -56,9 +63,9 @@ export const MedicationManager: FC = () => {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [createForm, setCreateForm] = useState<CreateMedicationRequest>(emptyCreate);
|
const [createForm, setCreateForm] = useState<MedFormData>(emptyCreate);
|
||||||
const [editTarget, setEditTarget] = useState<Medication | null>(null);
|
const [editTarget, setEditTarget] = useState<Medication | null>(null);
|
||||||
const [editForm, setEditForm] = useState<UpdateMedicationRequest>({});
|
const [editForm, setEditForm] = useState<Partial<MedFormData>>({});
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Medication | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Medication | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
|
@ -238,7 +245,7 @@ export const MedicationManager: FC = () => {
|
||||||
fullWidth
|
fullWidth
|
||||||
value={createForm.route}
|
value={createForm.route}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setCreateForm({ ...createForm, route: e.target.value as CreateMedicationRequest['route'] })
|
setCreateForm({ ...createForm, route: e.target.value })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{ROUTES.map((r) => (
|
{ROUTES.map((r) => (
|
||||||
|
|
|
||||||
81
web/normogen-web/src/crypto/cipher.ts
Normal file
81
web/normogen-web/src/crypto/cipher.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
/**
|
||||||
|
* AES-GCM encrypt/decrypt over the Web Crypto API (no dependencies).
|
||||||
|
*
|
||||||
|
* Ciphertext and IV are returned as base64 strings so they map directly onto
|
||||||
|
* the backend's `EncryptedField { data, iv, auth_tag }` wire shape.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
const b64 = {
|
||||||
|
encode(bytes: Uint8Array): string {
|
||||||
|
let bin = '';
|
||||||
|
for (const b of bytes) bin += String.fromCharCode(b);
|
||||||
|
return btoa(bin);
|
||||||
|
},
|
||||||
|
decode(str: string): Uint8Array {
|
||||||
|
const bin = atob(str);
|
||||||
|
const bytes = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||||
|
return bytes;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Random 12-byte IV for AES-GCM. */
|
||||||
|
function randomIv(): Uint8Array {
|
||||||
|
return crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CipherPayload {
|
||||||
|
/** base64 ciphertext */
|
||||||
|
data: string;
|
||||||
|
/** base64 12-byte IV */
|
||||||
|
iv: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encrypt a UTF-8 string under the given AES-GCM key. */
|
||||||
|
export async function encrypt(
|
||||||
|
plaintext: string,
|
||||||
|
key: CryptoKey,
|
||||||
|
): Promise<CipherPayload> {
|
||||||
|
const iv = randomIv();
|
||||||
|
const ciphertext = await crypto.subtle.encrypt(
|
||||||
|
{ name: 'AES-GCM', iv: iv as BufferSource },
|
||||||
|
key,
|
||||||
|
encoder.encode(plaintext) as BufferSource,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
data: b64.encode(new Uint8Array(ciphertext)),
|
||||||
|
iv: b64.encode(iv),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decrypt a base64 payload. Throws on tamper / wrong key. */
|
||||||
|
export async function decrypt(
|
||||||
|
payload: CipherPayload,
|
||||||
|
key: CryptoKey,
|
||||||
|
): Promise<string> {
|
||||||
|
const iv = b64.decode(payload.iv);
|
||||||
|
const plaintext = await crypto.subtle.decrypt(
|
||||||
|
{ name: 'AES-GCM', iv: iv as BufferSource },
|
||||||
|
key,
|
||||||
|
b64.decode(payload.data) as BufferSource,
|
||||||
|
);
|
||||||
|
return decoder.decode(plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encrypt a JSON-serializable object. */
|
||||||
|
export async function encryptJson<T>(
|
||||||
|
obj: T,
|
||||||
|
key: CryptoKey,
|
||||||
|
): Promise<CipherPayload> {
|
||||||
|
return encrypt(JSON.stringify(obj), key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decrypt a payload and JSON.parse into T. Throws on tamper / wrong key / bad JSON. */
|
||||||
|
export async function decryptJson<T>(
|
||||||
|
payload: CipherPayload,
|
||||||
|
key: CryptoKey,
|
||||||
|
): Promise<T> {
|
||||||
|
return JSON.parse(await decrypt(payload, key)) as T;
|
||||||
|
}
|
||||||
16
web/normogen-web/src/crypto/index.ts
Normal file
16
web/normogen-web/src/crypto/index.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
export {
|
||||||
|
deriveAuthAndEncKeys,
|
||||||
|
setEncKey,
|
||||||
|
getEncKey,
|
||||||
|
clearEncKey,
|
||||||
|
hasEncKey,
|
||||||
|
AUTH_SALT,
|
||||||
|
ENC_SALT,
|
||||||
|
} from './keys';
|
||||||
|
export {
|
||||||
|
encrypt,
|
||||||
|
decrypt,
|
||||||
|
encryptJson,
|
||||||
|
decryptJson,
|
||||||
|
type CipherPayload,
|
||||||
|
} from './cipher';
|
||||||
98
web/normogen-web/src/crypto/keys.ts
Normal file
98
web/normogen-web/src/crypto/keys.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
/**
|
||||||
|
* Zero-knowledge key derivation.
|
||||||
|
*
|
||||||
|
* From the user's password we derive TWO independent values via PBKDF2:
|
||||||
|
*
|
||||||
|
* - `authSecret`: base64 bytes sent to the server as the "password". The
|
||||||
|
* server PBKDF2-hashes it (as it always has). The server can never derive
|
||||||
|
* the encryption key from this value.
|
||||||
|
* - `encKey`: an AES-GCM CryptoKey kept in memory only (never persisted,
|
||||||
|
* never transmitted). Used to encrypt/decrypt all user data.
|
||||||
|
*
|
||||||
|
* Two separate PBKDF2 passes with app-wide domain-separation salts prevent the
|
||||||
|
* server from deriving `encKey` even if it logs or leaks `authSecret`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PBKDF2_ITERATIONS = 150_000;
|
||||||
|
const KEY_BITS = 256; // AES-256
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
export const AUTH_SALT = 'normogen-auth-v1';
|
||||||
|
export const ENC_SALT = 'normogen-enc-v1';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the auth secret (base64) and encryption key (CryptoKey) from a password.
|
||||||
|
*/
|
||||||
|
export async function deriveAuthAndEncKeys(password: string): Promise<{
|
||||||
|
authSecret: string;
|
||||||
|
encKey: CryptoKey;
|
||||||
|
}> {
|
||||||
|
const passwordKey = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encoder.encode(password),
|
||||||
|
'PBKDF2',
|
||||||
|
false,
|
||||||
|
['deriveBits'],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auth secret: 32 raw bytes, base64-encoded for transport.
|
||||||
|
const authBits = await crypto.subtle.deriveBits(
|
||||||
|
{ name: 'PBKDF2', salt: encoder.encode(AUTH_SALT), iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
|
||||||
|
passwordKey,
|
||||||
|
KEY_BITS,
|
||||||
|
);
|
||||||
|
const authSecret = base64(new Uint8Array(authBits));
|
||||||
|
|
||||||
|
// Encryption key: importable AES-GCM key, non-extractable.
|
||||||
|
const encBits = await crypto.subtle.deriveBits(
|
||||||
|
{ name: 'PBKDF2', salt: encoder.encode(ENC_SALT), iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
|
||||||
|
passwordKey,
|
||||||
|
KEY_BITS,
|
||||||
|
);
|
||||||
|
const encKey = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encBits,
|
||||||
|
{ name: 'AES-GCM' },
|
||||||
|
false,
|
||||||
|
['encrypt', 'decrypt'],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { authSecret, encKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64(bytes: Uint8Array): string {
|
||||||
|
let bin = '';
|
||||||
|
for (const b of bytes) bin += String.fromCharCode(b);
|
||||||
|
return btoa(bin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// In-memory encryption-key store.
|
||||||
|
//
|
||||||
|
// The encKey lives only in memory for the lifetime of the authenticated
|
||||||
|
// session (the tab). It is deliberately NOT persisted — losing the tab or
|
||||||
|
// closing the browser requires re-entering the password to re-derive it.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let currentEncKey: CryptoKey | null = null;
|
||||||
|
|
||||||
|
/** Set the session encryption key (called on login/register). */
|
||||||
|
export function setEncKey(key: CryptoKey): void {
|
||||||
|
currentEncKey = key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the session encryption key, or null if not authenticated. */
|
||||||
|
export function getEncKey(): CryptoKey | null {
|
||||||
|
return currentEncKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the session encryption key (called on logout). */
|
||||||
|
export function clearEncKey(): void {
|
||||||
|
currentEncKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if a session encryption key is available (i.e. the user can encrypt/decrypt). */
|
||||||
|
export function hasEncKey(): boolean {
|
||||||
|
return currentEncKey !== null;
|
||||||
|
}
|
||||||
|
|
@ -17,8 +17,10 @@ import {
|
||||||
DoseLog,
|
DoseLog,
|
||||||
LogDoseRequest,
|
LogDoseRequest,
|
||||||
AdherenceStats,
|
AdherenceStats,
|
||||||
Profile,
|
MedicationWireResponse,
|
||||||
Appointment,
|
AppointmentWireResponse,
|
||||||
|
ProfileWireResponse,
|
||||||
|
EncryptedFieldWire,
|
||||||
CreateAppointmentRequest,
|
CreateAppointmentRequest,
|
||||||
UpdateAppointmentRequest,
|
UpdateAppointmentRequest,
|
||||||
} from '../types/api';
|
} from '../types/api';
|
||||||
|
|
@ -213,47 +215,48 @@ class ApiService {
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Profile (Phase 3c) ----
|
// ---- Profile (zero-knowledge: opaque encrypted name) ----
|
||||||
|
|
||||||
async getProfile(): Promise<Profile> {
|
async getProfile(): Promise<ProfileWireResponse> {
|
||||||
const response = await this.client.get<Profile>('/profiles/me');
|
const response = await this.client.get<ProfileWireResponse>('/profiles/me');
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateProfileName(name: string): Promise<Profile> {
|
async updateProfileName(nameData: string, nameIv: string): Promise<ProfileWireResponse> {
|
||||||
const response = await this.client.put<Profile>('/profiles/me', { name });
|
const response = await this.client.put<ProfileWireResponse>('/profiles/me', {
|
||||||
|
name_data: nameData,
|
||||||
|
name_iv: nameIv,
|
||||||
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Medications ----
|
// ---- Medications (zero-knowledge: opaque encrypted blobs) ----
|
||||||
|
|
||||||
async getMedications(): Promise<Medication[]> {
|
async getMedications(): Promise<MedicationWireResponse[]> {
|
||||||
const response = await this.client.get<Medication[]>('/medications');
|
const response = await this.client.get<MedicationWireResponse[]>('/medications');
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMedication(id: string): Promise<Medication> {
|
async getMedication(id: string): Promise<MedicationWireResponse> {
|
||||||
const response = await this.client.get<Medication>(`/medications/${id}`);
|
const response = await this.client.get<MedicationWireResponse>(`/medications/${id}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createMedication(data: CreateMedicationRequest): Promise<Medication> {
|
async createMedication(data: CreateMedicationRequest): Promise<MedicationWireResponse> {
|
||||||
const response = await this.client.post<Medication>('/medications', data);
|
const response = await this.client.post<MedicationWireResponse>('/medications', data);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateMedication(id: string, data: UpdateMedicationRequest): Promise<Medication> {
|
async updateMedication(id: string, data: UpdateMedicationRequest): Promise<MedicationWireResponse> {
|
||||||
// Backend update is POST /:id (not PUT).
|
const response = await this.client.post<MedicationWireResponse>(`/medications/${id}`, data);
|
||||||
const response = await this.client.post<Medication>(`/medications/${id}`, data);
|
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteMedication(id: string): Promise<void> {
|
async deleteMedication(id: string): Promise<void> {
|
||||||
// Backend delete is POST /:id/delete (not DELETE /:id).
|
|
||||||
await this.client.post(`/medications/${id}/delete`);
|
await this.client.post(`/medications/${id}/delete`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Dose logging + adherence (Phase 3c) ----
|
// ---- Dose logging + adherence (not encrypted — counts/flags only) ----
|
||||||
|
|
||||||
async logDose(medicationId: string, req: LogDoseRequest): Promise<DoseLog> {
|
async logDose(medicationId: string, req: LogDoseRequest): Promise<DoseLog> {
|
||||||
const response = await this.client.post<DoseLog>(`/medications/${medicationId}/log`, req);
|
const response = await this.client.post<DoseLog>(`/medications/${medicationId}/log`, req);
|
||||||
|
|
@ -265,27 +268,27 @@ class ApiService {
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Appointments ----
|
// ---- Appointments (zero-knowledge: opaque encrypted blobs) ----
|
||||||
|
|
||||||
async getAppointments(status?: string): Promise<Appointment[]> {
|
async getAppointments(status?: string): Promise<AppointmentWireResponse[]> {
|
||||||
const response = await this.client.get<Appointment[]>('/appointments', {
|
const response = await this.client.get<AppointmentWireResponse[]>('/appointments', {
|
||||||
params: status ? { status } : undefined,
|
params: status ? { status } : undefined,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAppointment(id: string): Promise<Appointment> {
|
async getAppointment(id: string): Promise<AppointmentWireResponse> {
|
||||||
const response = await this.client.get<Appointment>(`/appointments/${id}`);
|
const response = await this.client.get<AppointmentWireResponse>(`/appointments/${id}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createAppointment(data: CreateAppointmentRequest): Promise<Appointment> {
|
async createAppointment(data: CreateAppointmentRequest): Promise<AppointmentWireResponse> {
|
||||||
const response = await this.client.post<Appointment>('/appointments', data);
|
const response = await this.client.post<AppointmentWireResponse>('/appointments', data);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateAppointment(id: string, data: UpdateAppointmentRequest): Promise<Appointment> {
|
async updateAppointment(id: string, data: UpdateAppointmentRequest): Promise<AppointmentWireResponse> {
|
||||||
const response = await this.client.post<Appointment>(`/appointments/${id}`, data);
|
const response = await this.client.post<AppointmentWireResponse>(`/appointments/${id}`, data);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { deriveAuthAndEncKeys, setEncKey, clearEncKey } from '../crypto';
|
||||||
|
import { encryptJson } from '../crypto';
|
||||||
|
|
||||||
// Mock the api client so the store's real reducer logic is exercised without HTTP.
|
// Mock the api client so the store's real reducer logic is exercised without HTTP.
|
||||||
const apiMock = {
|
const apiMock = {
|
||||||
|
|
@ -13,52 +15,87 @@ vi.mock('../services/api', () => ({ default: apiMock }));
|
||||||
const { useMedicationStore } = await import('./useStore');
|
const { useMedicationStore } = await import('./useStore');
|
||||||
|
|
||||||
describe('useMedicationStore', () => {
|
describe('useMedicationStore', () => {
|
||||||
beforeEach(() => {
|
let encKey: CryptoKey;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Derive a real encryption key so the store can encrypt/decrypt.
|
||||||
|
const { encKey: key } = await deriveAuthAndEncKeys('test-password');
|
||||||
|
encKey = key;
|
||||||
|
setEncKey(encKey);
|
||||||
|
|
||||||
useMedicationStore.setState({
|
useMedicationStore.setState({
|
||||||
medications: [],
|
medications: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
adherence: {},
|
adherence: {},
|
||||||
|
selectedMedication: null,
|
||||||
});
|
});
|
||||||
apiMock.getMedications.mockReset();
|
apiMock.getMedications.mockReset();
|
||||||
apiMock.createMedication.mockReset();
|
apiMock.createMedication.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loadMedications populates state from the api client', async () => {
|
afterEach(() => {
|
||||||
const meds = [{ medication_id: 'm1', name: 'Aspirin', dosage: '100mg' }];
|
clearEncKey();
|
||||||
apiMock.getMedications.mockResolvedValue(meds);
|
});
|
||||||
|
|
||||||
|
it('loadMedications decrypts wire responses into domain medications', async () => {
|
||||||
|
// The API returns opaque wire blobs; the store decrypts them.
|
||||||
|
const blob = await encryptJson({ name: 'Aspirin', dosage: '100mg', frequency: 'daily' }, encKey);
|
||||||
|
apiMock.getMedications.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'oid1',
|
||||||
|
medication_id: 'm1',
|
||||||
|
user_id: 'u1',
|
||||||
|
profile_id: 'p1',
|
||||||
|
active: true,
|
||||||
|
encrypted_data: blob,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
updated_at: '2026-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
await useMedicationStore.getState().loadMedications();
|
await useMedicationStore.getState().loadMedications();
|
||||||
|
|
||||||
expect(apiMock.getMedications).toHaveBeenCalledOnce();
|
expect(apiMock.getMedications).toHaveBeenCalledOnce();
|
||||||
expect(useMedicationStore.getState().medications).toEqual(meds);
|
const meds = useMedicationStore.getState().medications;
|
||||||
expect(useMedicationStore.getState().isLoading).toBe(false);
|
expect(meds).toHaveLength(1);
|
||||||
|
expect(meds[0].name).toBe('Aspirin');
|
||||||
|
expect(meds[0].dosage).toBe('100mg');
|
||||||
|
expect(meds[0].active).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loadMedications sets an error message on failure (and does not throw)', async () => {
|
it('loadMedications sets an error when no enc key is available', async () => {
|
||||||
apiMock.getMedications.mockRejectedValue(new Error('boom'));
|
clearEncKey();
|
||||||
|
|
||||||
// Should NOT throw — load actions swallow.
|
|
||||||
await useMedicationStore.getState().loadMedications();
|
await useMedicationStore.getState().loadMedications();
|
||||||
|
expect(useMedicationStore.getState().error).toMatch(/No encryption key/);
|
||||||
expect(useMedicationStore.getState().error).toBe('boom');
|
|
||||||
expect(useMedicationStore.getState().isLoading).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('createMedication appends the new medication and re-throws on error', async () => {
|
it('createMedication encrypts domain data and stores the decrypted result', async () => {
|
||||||
apiMock.createMedication.mockResolvedValue({ medication_id: 'm2', name: 'New' });
|
const blob = await encryptJson({ name: 'New', dosage: '50mg', frequency: 'daily' }, encKey);
|
||||||
|
apiMock.createMedication.mockResolvedValue({
|
||||||
|
id: 'oid2',
|
||||||
|
medication_id: 'm2',
|
||||||
|
user_id: 'u1',
|
||||||
|
profile_id: 'p1',
|
||||||
|
active: true,
|
||||||
|
encrypted_data: blob,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
updated_at: '2026-01-01T00:00:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
await useMedicationStore.getState().createMedication({ name: 'New' });
|
await useMedicationStore.getState().createMedication({
|
||||||
|
name: 'New',
|
||||||
|
dosage: '50mg',
|
||||||
|
frequency: 'daily',
|
||||||
|
route: 'oral',
|
||||||
|
profile_id: 'p1',
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
|
||||||
expect(useMedicationStore.getState().medications).toEqual([
|
const meds = useMedicationStore.getState().medications;
|
||||||
{ medication_id: 'm2', name: 'New' },
|
expect(meds).toHaveLength(1);
|
||||||
]);
|
expect(meds[0].name).toBe('New');
|
||||||
|
expect(meds[0].dosage).toBe('50mg');
|
||||||
// Now an error path: createMedication rejects -> store re-throws.
|
|
||||||
apiMock.createMedication.mockRejectedValue(new Error('nope'));
|
|
||||||
await expect(
|
|
||||||
useMedicationStore.getState().createMedication({ name: 'Bad' }),
|
|
||||||
).rejects.toThrow('nope');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('logDose logs the dose then refreshes adherence', async () => {
|
it('logDose logs the dose then refreshes adherence', async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { devtools, persist } from 'zustand/middleware';
|
import { devtools, persist } from 'zustand/middleware';
|
||||||
|
import {
|
||||||
|
deriveAuthAndEncKeys,
|
||||||
|
setEncKey,
|
||||||
|
clearEncKey,
|
||||||
|
getEncKey,
|
||||||
|
encryptJson,
|
||||||
|
decryptJson,
|
||||||
|
} from '../crypto';
|
||||||
import {
|
import {
|
||||||
User,
|
User,
|
||||||
Medication,
|
Medication,
|
||||||
|
|
@ -88,8 +96,10 @@ interface AppointmentState {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
loadAppointments: (status?: string) => Promise<void>;
|
loadAppointments: (status?: string) => Promise<void>;
|
||||||
createAppointment: (data: CreateAppointmentRequest) => Promise<void>;
|
// The store receives DOMAIN data (decrypted fields from the UI) and encrypts
|
||||||
updateAppointment: (id: string, data: UpdateAppointmentRequest) => Promise<void>;
|
// it internally before sending to the server.
|
||||||
|
createAppointment: (data: Record<string, unknown>) => Promise<void>;
|
||||||
|
updateAppointment: (id: string, data: Record<string, unknown>) => Promise<void>;
|
||||||
deleteAppointment: (id: string) => Promise<void>;
|
deleteAppointment: (id: string) => Promise<void>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -108,7 +118,11 @@ export const useAuthStore = create<AuthState>()(
|
||||||
login: async (email: string, password: string) => {
|
login: async (email: string, password: string) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const response = await apiService.login(email, password);
|
// Zero-knowledge: derive auth secret + encryption key from password.
|
||||||
|
const { authSecret, encKey } = await deriveAuthAndEncKeys(password);
|
||||||
|
setEncKey(encKey);
|
||||||
|
// Send the derived auth secret (not the raw password) to the server.
|
||||||
|
const response = await apiService.login(email, authSecret);
|
||||||
set({
|
set({
|
||||||
user: {
|
user: {
|
||||||
user_id: response.user_id,
|
user_id: response.user_id,
|
||||||
|
|
@ -121,6 +135,7 @@ export const useAuthStore = create<AuthState>()(
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
clearEncKey();
|
||||||
set({
|
set({
|
||||||
error: error.message || 'Login failed',
|
error: error.message || 'Login failed',
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
|
@ -133,11 +148,12 @@ export const useAuthStore = create<AuthState>()(
|
||||||
register: async (username: string, email: string, password: string) => {
|
register: async (username: string, email: string, password: string) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
|
const { authSecret, encKey } = await deriveAuthAndEncKeys(password);
|
||||||
|
setEncKey(encKey);
|
||||||
const response = await apiService.register({
|
const response = await apiService.register({
|
||||||
username,
|
username,
|
||||||
email,
|
email,
|
||||||
password,
|
password: authSecret,
|
||||||
role: 'patient',
|
|
||||||
});
|
});
|
||||||
set({
|
set({
|
||||||
user: {
|
user: {
|
||||||
|
|
@ -161,6 +177,7 @@ export const useAuthStore = create<AuthState>()(
|
||||||
},
|
},
|
||||||
|
|
||||||
logout: async () => {
|
logout: async () => {
|
||||||
|
clearEncKey();
|
||||||
await apiService.logout();
|
await apiService.logout();
|
||||||
set({
|
set({
|
||||||
user: null,
|
user: null,
|
||||||
|
|
@ -215,9 +232,42 @@ export const useMedicationStore = create<MedicationState>()(
|
||||||
adherence: {},
|
adherence: {},
|
||||||
|
|
||||||
loadMedications: async () => {
|
loadMedications: async () => {
|
||||||
|
const key = getEncKey();
|
||||||
|
if (!key) {
|
||||||
|
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const medications = await apiService.getMedications();
|
const wireMeds = await apiService.getMedications();
|
||||||
|
// Decrypt each opaque blob into domain Medication objects.
|
||||||
|
const medications = await Promise.all(
|
||||||
|
wireMeds.map(async (w) => {
|
||||||
|
const data = await decryptJson<Record<string, unknown>>(w.encrypted_data, key);
|
||||||
|
return {
|
||||||
|
id: w.id,
|
||||||
|
medication_id: w.medication_id,
|
||||||
|
user_id: w.user_id,
|
||||||
|
profile_id: w.profile_id,
|
||||||
|
name: (data.name as string) ?? '',
|
||||||
|
dosage: (data.dosage as string) ?? '',
|
||||||
|
frequency: (data.frequency as string) ?? '',
|
||||||
|
route: data.route as string | undefined,
|
||||||
|
reason: data.reason as string | undefined,
|
||||||
|
instructions: data.instructions as string | undefined,
|
||||||
|
side_effects: data.sideEffects as string[] | undefined,
|
||||||
|
prescribed_by: data.prescribedBy as string | undefined,
|
||||||
|
prescribed_date: data.prescribedDate as string | undefined,
|
||||||
|
start_date: data.startDate as string | undefined,
|
||||||
|
end_date: data.endDate as string | undefined,
|
||||||
|
notes: data.notes as string | undefined,
|
||||||
|
tags: data.tags as string[] | undefined,
|
||||||
|
active: w.active,
|
||||||
|
created_at: w.created_at,
|
||||||
|
updated_at: w.updated_at,
|
||||||
|
} as Medication;
|
||||||
|
}),
|
||||||
|
);
|
||||||
set({ medications, isLoading: false });
|
set({ medications, isLoading: false });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
set({
|
set({
|
||||||
|
|
@ -228,9 +278,42 @@ export const useMedicationStore = create<MedicationState>()(
|
||||||
},
|
},
|
||||||
|
|
||||||
createMedication: async (data: any) => {
|
createMedication: async (data: any) => {
|
||||||
|
const key = getEncKey();
|
||||||
|
if (!key) {
|
||||||
|
set({ error: 'No encryption key — cannot encrypt data' });
|
||||||
|
throw new Error('No encryption key');
|
||||||
|
}
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const medication = await apiService.createMedication(data);
|
// Encrypt the domain fields into an opaque blob.
|
||||||
|
const { name, dosage, frequency, route, reason, instructions,
|
||||||
|
side_effects, prescribed_by, prescribed_date, start_date,
|
||||||
|
end_date, notes, tags, ...rest } = data;
|
||||||
|
const encrypted_data = await encryptJson({
|
||||||
|
name, dosage, frequency, route, reason, instructions,
|
||||||
|
sideEffects: side_effects, prescribedBy: prescribed_by,
|
||||||
|
prescribedDate: prescribed_date, startDate: start_date,
|
||||||
|
endDate: end_date, notes, tags,
|
||||||
|
}, key);
|
||||||
|
const wire = await apiService.createMedication({
|
||||||
|
profile_id: data.profile_id,
|
||||||
|
encrypted_data,
|
||||||
|
active: data.active,
|
||||||
|
});
|
||||||
|
// Decrypt the response back into a domain Medication for the store.
|
||||||
|
const decrypted = await decryptJson<Record<string, unknown>>(wire.encrypted_data, key);
|
||||||
|
const medication = {
|
||||||
|
id: wire.id,
|
||||||
|
medication_id: wire.medication_id,
|
||||||
|
user_id: wire.user_id,
|
||||||
|
profile_id: wire.profile_id,
|
||||||
|
name: (decrypted.name as string) ?? '',
|
||||||
|
dosage: (decrypted.dosage as string) ?? '',
|
||||||
|
frequency: (decrypted.frequency as string) ?? '',
|
||||||
|
active: wire.active,
|
||||||
|
created_at: wire.created_at,
|
||||||
|
updated_at: wire.updated_at,
|
||||||
|
} as Medication;
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
medications: [...state.medications, medication],
|
medications: [...state.medications, medication],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
|
@ -245,12 +328,33 @@ export const useMedicationStore = create<MedicationState>()(
|
||||||
},
|
},
|
||||||
|
|
||||||
updateMedication: async (id: string, data: any) => {
|
updateMedication: async (id: string, data: any) => {
|
||||||
|
const key = getEncKey();
|
||||||
|
if (!key) {
|
||||||
|
set({ error: 'No encryption key — cannot encrypt data' });
|
||||||
|
throw new Error('No encryption key');
|
||||||
|
}
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const updated = await apiService.updateMedication(id, data);
|
// Encrypt the full updated domain record into a new blob.
|
||||||
|
const { name, dosage, frequency, route, reason, instructions,
|
||||||
|
side_effects, prescribed_by, prescribed_date, start_date,
|
||||||
|
end_date, notes, tags } = data;
|
||||||
|
const encrypted_data = await encryptJson({
|
||||||
|
name, dosage, frequency, route, reason, instructions,
|
||||||
|
sideEffects: side_effects, prescribedBy: prescribed_by,
|
||||||
|
prescribedDate: prescribed_date, startDate: start_date,
|
||||||
|
endDate: end_date, notes, tags,
|
||||||
|
}, key);
|
||||||
|
const wire = await apiService.updateMedication(id, {
|
||||||
|
encrypted_data,
|
||||||
|
active: data.active,
|
||||||
|
});
|
||||||
|
// Update the domain medication in the store.
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
medications: state.medications.map((med) =>
|
medications: state.medications.map((med) =>
|
||||||
med.medication_id === id ? updated : med
|
med.medication_id === id
|
||||||
|
? { ...med, ...data, updated_at: wire.updated_at }
|
||||||
|
: med,
|
||||||
),
|
),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
}));
|
}));
|
||||||
|
|
@ -451,9 +555,30 @@ export const useProfileStore = create<ProfileState>()(
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
loadProfile: async () => {
|
loadProfile: async () => {
|
||||||
|
const key = getEncKey();
|
||||||
|
if (!key) {
|
||||||
|
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const profile = await apiService.getProfile();
|
const wire = await apiService.getProfile();
|
||||||
|
// Decrypt the profile name.
|
||||||
|
let name = '';
|
||||||
|
if (wire.name_data && wire.name_iv) {
|
||||||
|
try {
|
||||||
|
name = await decryptJson<string>({ data: wire.name_data, iv: wire.name_iv }, key);
|
||||||
|
} catch { name = ''; }
|
||||||
|
}
|
||||||
|
const profile: Profile = {
|
||||||
|
profile_id: wire.profile_id,
|
||||||
|
user_id: wire.user_id,
|
||||||
|
name,
|
||||||
|
role: wire.role,
|
||||||
|
permissions: wire.permissions,
|
||||||
|
created_at: wire.created_at,
|
||||||
|
updated_at: wire.updated_at,
|
||||||
|
};
|
||||||
set({ profile, isLoading: false });
|
set({ profile, isLoading: false });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
set({
|
set({
|
||||||
|
|
@ -464,9 +589,28 @@ export const useProfileStore = create<ProfileState>()(
|
||||||
},
|
},
|
||||||
|
|
||||||
updateName: async (name) => {
|
updateName: async (name) => {
|
||||||
|
const key = getEncKey();
|
||||||
|
if (!key) {
|
||||||
|
set({ error: 'No encryption key — cannot encrypt data' });
|
||||||
|
throw new Error('No encryption key');
|
||||||
|
}
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const profile = await apiService.updateProfileName(name);
|
const blob = await encryptJson(name, key);
|
||||||
|
const wire = await apiService.updateProfileName(blob.data, blob.iv);
|
||||||
|
let decryptedName = name;
|
||||||
|
try {
|
||||||
|
decryptedName = await decryptJson<string>({ data: wire.name_data, iv: wire.name_iv }, key);
|
||||||
|
} catch { /* keep the input name */ }
|
||||||
|
const profile: Profile = {
|
||||||
|
profile_id: wire.profile_id,
|
||||||
|
user_id: wire.user_id,
|
||||||
|
name: decryptedName,
|
||||||
|
role: wire.role,
|
||||||
|
permissions: wire.permissions,
|
||||||
|
created_at: wire.created_at,
|
||||||
|
updated_at: wire.updated_at,
|
||||||
|
};
|
||||||
set({ profile, isLoading: false });
|
set({ profile, isLoading: false });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
set({
|
set({
|
||||||
|
|
@ -489,9 +633,36 @@ export const useAppointmentStore = create<AppointmentState>()(
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
loadAppointments: async (status) => {
|
loadAppointments: async (status) => {
|
||||||
|
const key = getEncKey();
|
||||||
|
if (!key) {
|
||||||
|
set({ error: 'No encryption key — log in to decrypt data', isLoading: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const appointments = await apiService.getAppointments(status);
|
const wireAppts = await apiService.getAppointments(status);
|
||||||
|
const appointments = await Promise.all(
|
||||||
|
wireAppts.map(async (w) => {
|
||||||
|
const data = await decryptJson<Record<string, unknown>>(w.encrypted_data, key);
|
||||||
|
return {
|
||||||
|
id: w.id,
|
||||||
|
appointment_id: w.appointment_id,
|
||||||
|
user_id: w.user_id,
|
||||||
|
profile_id: w.profile_id,
|
||||||
|
title: (data.title as string) ?? '',
|
||||||
|
provider: (data.provider as string) ?? '',
|
||||||
|
appointment_type: (data.appointmentType as string) ?? '',
|
||||||
|
date_time: (data.dateTime as string) ?? '',
|
||||||
|
location: data.location as string | undefined,
|
||||||
|
duration_minutes: data.durationMinutes as number | undefined,
|
||||||
|
reason: data.reason as string | undefined,
|
||||||
|
notes: data.notes as string | undefined,
|
||||||
|
status: w.status,
|
||||||
|
created_at: w.created_at,
|
||||||
|
updated_at: w.updated_at,
|
||||||
|
} as Appointment;
|
||||||
|
}),
|
||||||
|
);
|
||||||
set({ appointments, isLoading: false });
|
set({ appointments, isLoading: false });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
set({
|
set({
|
||||||
|
|
@ -502,11 +673,40 @@ export const useAppointmentStore = create<AppointmentState>()(
|
||||||
},
|
},
|
||||||
|
|
||||||
createAppointment: async (data) => {
|
createAppointment: async (data) => {
|
||||||
|
const key = getEncKey();
|
||||||
|
if (!key) {
|
||||||
|
set({ error: 'No encryption key — cannot encrypt data' });
|
||||||
|
throw new Error('No encryption key');
|
||||||
|
}
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const appt = await apiService.createAppointment(data);
|
const d = data as any;
|
||||||
|
const { title, provider, appointment_type, date_time, location,
|
||||||
|
duration_minutes, reason, notes } = d;
|
||||||
|
const encrypted_data = await encryptJson({
|
||||||
|
title, provider, appointmentType: appointment_type,
|
||||||
|
dateTime: date_time, location, durationMinutes: duration_minutes,
|
||||||
|
reason, notes,
|
||||||
|
}, key);
|
||||||
|
const wire = await apiService.createAppointment({
|
||||||
|
profile_id: d.profile_id,
|
||||||
|
encrypted_data,
|
||||||
|
status: d.status,
|
||||||
|
});
|
||||||
|
// Build the domain Appointment for the store.
|
||||||
|
const appointment: Appointment = {
|
||||||
|
appointment_id: wire.appointment_id,
|
||||||
|
id: wire.id,
|
||||||
|
user_id: wire.user_id,
|
||||||
|
profile_id: wire.profile_id,
|
||||||
|
title, provider, appointment_type, date_time,
|
||||||
|
location, duration_minutes, reason, notes,
|
||||||
|
status: wire.status,
|
||||||
|
created_at: wire.created_at,
|
||||||
|
updated_at: wire.updated_at,
|
||||||
|
};
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
appointments: [...state.appointments, appt],
|
appointments: [...state.appointments, appointment],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
}));
|
}));
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|
@ -519,11 +719,29 @@ export const useAppointmentStore = create<AppointmentState>()(
|
||||||
},
|
},
|
||||||
|
|
||||||
updateAppointment: async (id, data) => {
|
updateAppointment: async (id, data) => {
|
||||||
|
const key = getEncKey();
|
||||||
|
if (!key) {
|
||||||
|
set({ error: 'No encryption key — cannot encrypt data' });
|
||||||
|
throw new Error('No encryption key');
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const updated = await apiService.updateAppointment(id, data);
|
const d = data as any;
|
||||||
|
const { title, provider, appointment_type, date_time, location,
|
||||||
|
duration_minutes, reason, notes } = d;
|
||||||
|
const encrypted_data = await encryptJson({
|
||||||
|
title, provider, appointmentType: appointment_type,
|
||||||
|
dateTime: date_time, location, durationMinutes: duration_minutes,
|
||||||
|
reason, notes,
|
||||||
|
}, key);
|
||||||
|
const wire = await apiService.updateAppointment(id, {
|
||||||
|
encrypted_data,
|
||||||
|
status: d.status,
|
||||||
|
});
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
appointments: state.appointments.map((a) =>
|
appointments: state.appointments.map((a) =>
|
||||||
a.appointment_id === id ? updated : a,
|
a.appointment_id === id
|
||||||
|
? { ...a, ...data, status: wire.status, updated_at: wire.updated_at }
|
||||||
|
: a,
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,14 @@ export interface PillIdentification {
|
||||||
custom_color?: string;
|
custom_color?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Opaque encrypted blob wire types (zero-knowledge) ----
|
||||||
|
export interface EncryptedFieldWire {
|
||||||
|
data: string;
|
||||||
|
iv: string;
|
||||||
|
auth_tag?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Medication: domain type (decrypted, used by UI) ----
|
||||||
export interface Medication {
|
export interface Medication {
|
||||||
id?: string;
|
id?: string;
|
||||||
medication_id?: string;
|
medication_id?: string;
|
||||||
|
|
@ -129,39 +137,32 @@ export interface Medication {
|
||||||
notes?: string;
|
notes?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
active: boolean;
|
active: boolean;
|
||||||
pill_identification?: PillIdentification;
|
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateMedicationRequest {
|
// Medication wire response (opaque blob from the server)
|
||||||
name: string;
|
export interface MedicationWireResponse {
|
||||||
dosage: string;
|
id: string;
|
||||||
frequency: string;
|
medication_id: string;
|
||||||
route: string;
|
user_id: string;
|
||||||
profile_id: string;
|
profile_id: string;
|
||||||
reason?: string;
|
active: boolean;
|
||||||
instructions?: string;
|
encrypted_data: EncryptedFieldWire;
|
||||||
side_effects?: string[];
|
created_at: string;
|
||||||
prescribed_by?: string;
|
updated_at: string;
|
||||||
prescribed_date?: string;
|
}
|
||||||
start_date?: string;
|
|
||||||
end_date?: string;
|
// Medication create/update requests (send encrypted blob to the server)
|
||||||
notes?: string;
|
export interface CreateMedicationRequest {
|
||||||
tags?: string[];
|
profile_id: string;
|
||||||
reminder_times?: string[];
|
encrypted_data: EncryptedFieldWire;
|
||||||
pill_identification?: PillIdentification;
|
active?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateMedicationRequest {
|
export interface UpdateMedicationRequest {
|
||||||
name?: string;
|
encrypted_data?: EncryptedFieldWire;
|
||||||
dosage?: string;
|
|
||||||
frequency?: string;
|
|
||||||
start_date?: string;
|
|
||||||
end_date?: string;
|
|
||||||
instructions?: string;
|
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
pill_identification?: PillIdentification;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drug Interaction Types (Phase 2.8)
|
// Drug Interaction Types (Phase 2.8)
|
||||||
|
|
@ -244,7 +245,7 @@ export interface LabResult {
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Appointment Types — match backend AppointmentResponse (snake_case).
|
// Appointment: domain type (decrypted, used by UI)
|
||||||
export interface Appointment {
|
export interface Appointment {
|
||||||
id?: string;
|
id?: string;
|
||||||
appointment_id: string;
|
appointment_id: string;
|
||||||
|
|
@ -263,31 +264,41 @@ export interface Appointment {
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateAppointmentRequest {
|
// Appointment wire response (opaque blob)
|
||||||
title: string;
|
export interface AppointmentWireResponse {
|
||||||
provider: string;
|
id: string;
|
||||||
appointment_type: string;
|
appointment_id: string;
|
||||||
date_time: string;
|
user_id: string;
|
||||||
profile_id: string;
|
profile_id: string;
|
||||||
location?: string;
|
status: string;
|
||||||
duration_minutes?: number;
|
encrypted_data: EncryptedFieldWire;
|
||||||
reason?: string;
|
created_at: string;
|
||||||
notes?: string;
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAppointmentRequest {
|
||||||
|
profile_id: string;
|
||||||
|
encrypted_data: EncryptedFieldWire;
|
||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateAppointmentRequest {
|
export interface UpdateAppointmentRequest {
|
||||||
title?: string;
|
encrypted_data?: EncryptedFieldWire;
|
||||||
provider?: string;
|
|
||||||
appointment_type?: string;
|
|
||||||
date_time?: string;
|
|
||||||
location?: string;
|
|
||||||
duration_minutes?: number;
|
|
||||||
reason?: string;
|
|
||||||
notes?: string;
|
|
||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Profile wire response (opaque encrypted name)
|
||||||
|
export interface ProfileWireResponse {
|
||||||
|
profile_id: string;
|
||||||
|
user_id: string;
|
||||||
|
name_data: string;
|
||||||
|
name_iv: string;
|
||||||
|
role: string;
|
||||||
|
permissions: string[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Dose Log Types — match backend MedicationDose (camelCase serialization).
|
// Dose Log Types — match backend MedicationDose (camelCase serialization).
|
||||||
export interface DoseLog {
|
export interface DoseLog {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue