normogen/backend/src/models/appointment.rs
goose eb2c2aa546
Some checks failed
Lint and Build / format (pull_request) Successful in 39s
Lint and Build / clippy (pull_request) Successful in 1m40s
Lint and Build / build (pull_request) Successful in 3m45s
Lint and Build / test (pull_request) Failing after 2m55s
feat: per-profile DEKs + multi-profile (Phase A2, #3)
Implements the 3-tier key model from the multi-person sharing ADR:
each account owns multiple profiles (a person or pet — a 'subject of
care'), and each profile has its own random AES-256-GCM DEK. All
health data is now encrypted under the active profile's DEK, not the
account-wide DEK. The account DEK wraps each profile DEK; the server
stores only opaque wrapped blobs.

Per the ADR: DB wipe, no migration (no real user data). This unblocks
Phase B (sharing) — there is now a per-profile key to wrap to a
recipient's X25519 public key.

Backend:
- Profile model: owner_account_id, kind (human/pet), relationship,
  wrapped_profile_dek + iv. ProfileRepository gains find_all_by_owner,
  find_by_profile_id_owned, update_profile, delete_profile — all
  ownership-scoped.
- Profile handlers: GET/POST /api/profiles, GET/PUT/DELETE
  /api/profiles/:id. Removed /api/profiles/me. Renamed users.rs
  get_profile/update_profile (the /api/users/me handlers) to
  get_account/update_account to resolve a name collision.
- Register accepts default_profile_* fields and auto-creates the self
  profile when the client provides a wrapped profile DEK.
- HealthStatistic + Appointment gain profile_id and ?profile_id=
  filtering (health stats previously had no profile binding).
- New profile_tests.rs: multi-profile CRUD + ownership isolation +
  register-with-default-profile. Fixed the zk health-stat test to
  send the now-required profile_id.

Frontend:
- crypto/keys.ts: generateProfileDek, wrapProfileDek, unwrapProfileDek
  + in-memory per-profile DEK store with an active-profile concept.
- useProfileStore rewritten: holds profiles[], activeProfileId;
  loadProfiles unwraps each profile DEK; create/update/delete. All 11
  encrypt/decrypt sites switched from getEncKey() to
  getActiveProfileDek(). load actions pass ?profile_id= so only the
  active profile's rows come back.
- ProfileEditor rewritten for the new store (edit active profile,
  create/delete). New ProfileSwitcher in the Dashboard AppBar.
- MedicationManager / AppointmentsManager use the active profile id
  instead of the hardcoded profile_<user_id>.
- 2 new crypto tests for per-profile DEK isolation; updated store +
  component tests for the active-profile-DEK model.

Verification: backend cargo build/clippy/fmt green, tests compile
(integration tests run in CI — Mongo is fixed there). Frontend
tsc clean, 30/30 tests pass.

Closes nothing yet (Phase B/C/D remain). Refs #3.
2026-07-18 23:45:01 -03:00

223 lines
7.4 KiB
Rust

use super::health_data::EncryptedField;
use futures::StreamExt;
use mongodb::bson::{doc, oid::ObjectId, DateTime};
use mongodb::Collection;
use serde::{Deserialize, Serialize};
fn default_status() -> String {
"upcoming".to_string()
}
/// Zero-knowledge wire response. The server returns ONLY metadata + the opaque
/// encrypted data blob + the top-level `status` (filterable without decrypting).
#[derive(Debug, Serialize)]
pub struct AppointmentResponse {
pub id: String,
pub appointment_id: String,
pub user_id: String,
pub profile_id: String,
pub status: String,
pub encrypted_data: crate::models::medication::EncryptedFieldWire,
pub created_at: String,
pub updated_at: String,
}
impl std::convert::From<Appointment> for AppointmentResponse {
fn from(a: Appointment) -> Self {
AppointmentResponse {
id: a.id.map(|o| o.to_hex()).unwrap_or_default(),
appointment_id: a.appointment_id,
user_id: a.user_id,
profile_id: a.profile_id,
status: a.status,
encrypted_data: crate::models::medication::EncryptedFieldWire {
data: a.appointment_data.data,
iv: a.appointment_data.iv,
auth_tag: a.appointment_data.auth_tag,
},
created_at: system_time_to_rfc3339(a.created_at.to_system_time()),
updated_at: system_time_to_rfc3339(a.updated_at.to_system_time()),
}
}
}
/// Format a SystemTime as an ISO 8601 (RFC 3339) string.
fn system_time_to_rfc3339(t: std::time::SystemTime) -> String {
let secs = t
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
chrono::DateTime::from_timestamp(secs, 0)
.map(|dt| dt.to_rfc3339())
.unwrap_or_default()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Appointment {
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
pub id: Option<ObjectId>,
#[serde(rename = "appointmentId")]
pub appointment_id: String,
#[serde(rename = "userId")]
pub user_id: String,
#[serde(rename = "profileId")]
pub profile_id: String,
#[serde(rename = "appointmentData")]
pub appointment_data: EncryptedField,
#[serde(rename = "reminders")]
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")]
pub created_at: DateTime,
#[serde(rename = "updatedAt")]
pub updated_at: DateTime,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppointmentReminder {
#[serde(rename = "reminderId")]
pub reminder_id: String,
#[serde(rename = "scheduledTime")]
pub scheduled_time: String,
}
// ============================================================================
// REQUEST TYPES — opaque blobs; the client encrypts, the server stores verbatim.
// ============================================================================
#[derive(Debug, Deserialize)]
pub struct CreateAppointmentRequest {
pub profile_id: String,
pub encrypted_data: crate::models::medication::EncryptedFieldWire,
/// Defaults to "upcoming".
#[serde(default = "default_status")]
pub status: String,
}
#[derive(Debug, Deserialize)]
pub struct UpdateAppointmentRequest {
pub encrypted_data: Option<crate::models::medication::EncryptedFieldWire>,
pub status: Option<String>,
}
// ============================================================================
// REPOSITORY
// ============================================================================
#[derive(Clone)]
pub struct AppointmentRepository {
collection: Collection<Appointment>,
}
impl AppointmentRepository {
pub fn new(collection: Collection<Appointment>) -> Self {
Self { collection }
}
pub async fn create(
&self,
appointment: Appointment,
) -> Result<Appointment, Box<dyn std::error::Error>> {
self.collection
.insert_one(appointment.clone(), None)
.await?;
Ok(appointment)
}
/// List a user's appointments, optionally filtered by top-level `status`
/// and/or `profileId`.
pub async fn find_by_user_filtered(
&self,
user_id: &str,
status: Option<&str>,
profile_id: Option<&str>,
) -> Result<Vec<Appointment>, Box<dyn std::error::Error>> {
let mut filter = doc! { "userId": user_id };
if let Some(s) = status {
filter.insert("status", s);
}
if let Some(p) = profile_id {
filter.insert("profileId", p);
}
let mut cursor = self.collection.find(filter, None).await?;
let mut appointments = Vec::new();
while let Some(appt) = cursor.next().await {
appointments.push(appt?);
}
Ok(appointments)
}
pub async fn find_by_appointment_id(
&self,
appointment_id: &str,
) -> Result<Option<Appointment>, Box<dyn std::error::Error>> {
let filter = doc! { "appointmentId": appointment_id };
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(
&self,
appointment_id: &str,
updates: UpdateAppointmentRequest,
) -> Result<Option<Appointment>, Box<dyn std::error::Error>> {
let filter = doc! { "appointmentId": appointment_id };
let mut update_doc = doc! { "updatedAt": DateTime::now() };
if let Some(blob) = updates.encrypted_data {
update_doc.insert("appointmentData.data", blob.data);
update_doc.insert("appointmentData.iv", blob.iv);
update_doc.insert("appointmentData.authTag", blob.auth_tag);
}
if let Some(status) = updates.status {
update_doc.insert("status", status);
}
Ok(self
.collection
.find_one_and_update(filter, doc! { "$set": update_doc }, None)
.await?)
}
pub async fn delete_by_appointment_id(
&self,
appointment_id: &str,
) -> Result<bool, Box<dyn std::error::Error>> {
let filter = doc! { "appointmentId": appointment_id };
let result = self.collection.delete_one(filter, None).await?;
Ok(result.deleted_count > 0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn appointment_response_echoes_opaque_blob_and_status() {
let appt = Appointment {
id: Some(ObjectId::new()),
appointment_id: "apt-1".to_string(),
user_id: "user-1".to_string(),
profile_id: "profile_user-1".to_string(),
appointment_data: EncryptedField {
data: "opaque-ciphertext".to_string(),
encrypted: true,
iv: "base64-iv".to_string(),
auth_tag: String::new(),
},
reminders: vec![],
status: "completed".to_string(),
created_at: DateTime::now(),
updated_at: DateTime::now(),
};
let resp: AppointmentResponse = appt.into();
assert_eq!(resp.appointment_id, "apt-1");
assert_eq!(resp.status, "completed");
assert_eq!(resp.encrypted_data.data, "opaque-ciphertext");
assert!(!resp.id.is_empty());
}
}