import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; import { deriveAuthAndEncKeys, setupEncryption, unlockWithPassword, unlockWithRecovery, rewrapDek, generateIdentityKeyPair, wrapIdentityPrivateKey, unwrapIdentityPrivateKey, generateProfileDek, wrapProfileDek, unwrapProfileDek, wrapProfileDekToRecipient, unwrapProfileDekFromShare, encrypt as encryptRaw, decrypt as decryptRaw, setEncKey, clearEncKey, clearIdentityPrivate, clearProfileDeks, setIdentityPrivate, getIdentityPrivate, getEncKey, getActiveProfileDek, getActiveProfileId, setActiveProfileId, setProfileDek, getProfileDek, encryptJson, decryptJson, type CipherPayload, } from '../crypto'; import { User, Medication, HealthStat, HealthStatType, DrugInteraction, AdherenceStats, Profile, ProfileShareListing, Appointment, CreateAppointmentRequest, UpdateAppointmentRequest, } from '../types/api'; import apiService from '../services/api'; interface AuthState { user: User | null; token: string | null; isAuthenticated: boolean; isLoading: boolean; error: string | null; // Persisted wrapped DEK — safe to store (AES-GCM ciphertext, useless without // the password). Used by the unlock screen to re-derive the in-memory DEK // on page reload without a full re-login. wrapped_dek: string | null; wrapped_dek_iv: string | null; // Account X25519 identity keypair (Phase A1). The public key is plaintext; // the private key is the DEK-wrapped ciphertext blob. Persisted for the same // reason as the wrapped DEK — the unlock screen unwraps it into memory. identity_public_key: string | null; identity_private_key_wrapped: string | null; identity_private_key_wrapped_iv: string | null; // Actions login: (email: string, password: string) => Promise; register: (username: string, email: string, password: string, recoveryPhrase?: string) => Promise; recover: (email: string, recoveryPhrase: string, newPassword: string) => Promise; logout: () => Promise; clearError: () => void; loadUser: () => Promise; } interface MedicationState { medications: Medication[]; selectedMedication: Medication | null; isLoading: boolean; error: string | null; // Adherence cache: medicationId -> stats. Loaded on demand per medication. adherence: Record; // Actions loadMedications: () => Promise; createMedication: (data: any) => Promise; updateMedication: (id: string, data: any) => Promise; deleteMedication: (id: string) => Promise; selectMedication: (medication: Medication | null) => void; clearError: () => void; loadAdherence: (medicationId: string) => Promise; logDose: (medicationId: string, taken: boolean, notes?: string) => Promise; } interface HealthState { stats: HealthStat[]; trends: any[]; isLoading: boolean; error: string | null; // Actions loadStats: () => Promise; createStat: (data: any) => Promise; updateStat: (id: string, data: any) => Promise; deleteStat: (id: string) => Promise; loadTrends: () => Promise; clearError: () => void; } interface InteractionState { interactions: DrugInteraction[]; isChecking: boolean; error: string | null; // Actions checkInteractions: (medications: string[]) => Promise; checkNewMedication: (name: string, dosage: string) => Promise; clearInteractions: () => void; clearError: () => void; } interface ProfileState { profiles: Profile[]; activeProfileId: string | null; isLoading: boolean; error: string | null; /** Fetch all owned profiles and unwrap each per-profile DEK under the account * DEK into the in-memory crypto store. Sets the first profile active. */ loadProfiles: () => Promise; /** Switch the active profile (whose DEK encrypts/decrypts the UI's data). */ setActiveProfile: (profileId: string) => void; createProfile: (input: { name: string; kind?: string; relationship?: string; }) => Promise; updateProfile: (profileId: string, input: { name: string; kind?: string; relationship?: string; }) => Promise; deleteProfile: (profileId: string) => Promise; /** Fetch profiles shared TO the current user and merge them into `profiles`, * unwrapping each share's profile DEK via the recipient's identity private * key. Called after loadProfiles on login/unlock. */ loadSharedWithMe: () => Promise; /** Owner: share a profile to a recipient by email. Fetches the recipient's * identity public key, wraps the profile DEK to it via ECDH, and POSTs. */ shareProfile: (profileId: string, recipientEmail: string) => Promise; /** Owner: revoke a share (delete the share record server-side). */ revokeShare: (profileId: string, recipientUserId: string) => Promise; /** Shares the current user has created for a profile (for the owner UI). */ profileShares: Record; loadProfileShares: (profileId: string) => Promise; clearError: () => void; } interface AppointmentState { appointments: Appointment[]; isLoading: boolean; error: string | null; loadAppointments: (status?: string) => Promise; // The store receives DOMAIN data (decrypted fields from the UI) and encrypts // it internally before sending to the server. createAppointment: (data: Record) => Promise; updateAppointment: (id: string, data: Record) => Promise; deleteAppointment: (id: string) => Promise; clearError: () => void; } // Auth Store export const useAuthStore = create()( devtools( persist( (set, get) => ({ user: null, token: null, isAuthenticated: false, isLoading: false, error: null, wrapped_dek: null, wrapped_dek_iv: null, identity_public_key: null, identity_private_key_wrapped: null, identity_private_key_wrapped_iv: null, login: async (email: string, password: string) => { set({ isLoading: true, error: null }); try { // Zero-knowledge: derive auth secret (sent to server) + encryption key. const { authSecret, encKey } = await deriveAuthAndEncKeys(password); setEncKey(encKey); const response = await apiService.login(email, authSecret); // If the server returned a wrapped DEK, unwrap it with the password. if (response.wrapped_dek && response.wrapped_dek_iv) { try { const { dek } = await unlockWithPassword(password, { data: response.wrapped_dek, iv: response.wrapped_dek_iv, }); setEncKey(dek); // Once the DEK is available, also unwrap the X25519 identity // private key (Phase A1) into in-memory storage. if ( response.identity_private_key_wrapped && response.identity_private_key_wrapped_iv ) { try { const identityPrivate = await unwrapIdentityPrivateKey( { data: response.identity_private_key_wrapped, iv: response.identity_private_key_wrapped_iv, }, dek, ); setIdentityPrivate(identityPrivate); } catch { // Wrapped private key present but failed to unwrap — // non-fatal; Phase B features will surface a re-setup need. } } } catch { // Fallback to the PBKDF2-derived key (Phase 1 compat). } } set({ user: { user_id: response.user_id, username: response.username, email: email, role: 'patient', }, token: response.token, isAuthenticated: true, isLoading: false, wrapped_dek: response.wrapped_dek ?? null, wrapped_dek_iv: response.wrapped_dek_iv ?? null, identity_public_key: response.identity_public_key ?? null, identity_private_key_wrapped: response.identity_private_key_wrapped ?? null, identity_private_key_wrapped_iv: response.identity_private_key_wrapped_iv ?? null, }); } catch (error: any) { clearEncKey(); set({ error: error.message || 'Login failed', isLoading: false, isAuthenticated: false, }); throw error; } }, register: async (username: string, email: string, password: string, recoveryPhrase?: string) => { set({ isLoading: true, error: null }); try { // Zero-knowledge: generate a DEK, wrap it under the password KEK // and (if provided) the recovery KEK. Send the wrapped forms + the // recovery proof to the server; the server stores them verbatim. const setup = await setupEncryption(password, recoveryPhrase); setEncKey(setup.dek); // Phase A1: generate the account X25519 identity keypair and wrap // the private half under the account DEK. The public key is sent // plaintext; the wrapped private key is opaque ciphertext. const { publicKey, privateKey } = await generateIdentityKeyPair(); const wrappedPrivate = await wrapIdentityPrivateKey(privateKey, setup.dek); setIdentityPrivate(privateKey); // Phase A2: generate the default "self" profile's DEK, wrap it // under the account DEK, and encrypt the display name (the chosen // username) under the profile DEK. The server stores both opaque // blobs verbatim and auto-creates the self profile. const defaultProfileDek = await generateProfileDek(); const defaultWrapped = await wrapProfileDek(defaultProfileDek, setup.dek); const defaultNameBlob = await encryptRaw(username, defaultProfileDek); const response = await apiService.register({ username, email, password: setup.authSecret, wrapped_dek: setup.passwordWrappedDek.data, wrapped_dek_iv: setup.passwordWrappedDek.iv, recovery_wrapped_dek: setup.recoveryWrappedDek?.data, recovery_wrapped_dek_iv: setup.recoveryWrappedDek?.iv, recovery_phrase: setup.recoveryKekHash, identity_public_key: publicKey, identity_private_key_wrapped: wrappedPrivate.data, identity_private_key_wrapped_iv: wrappedPrivate.iv, default_profile_name_data: defaultNameBlob.data, default_profile_name_iv: defaultNameBlob.iv, default_wrapped_profile_dek: defaultWrapped.data, default_wrapped_profile_dek_iv: defaultWrapped.iv, }); // Seed the in-memory profile-DEK store with the self profile and // make it the active profile. const selfProfileId = `profile_${response.user_id}`; setProfileDek(selfProfileId, defaultProfileDek); setActiveProfileId(selfProfileId); set({ user: { user_id: response.user_id, username: response.username, email: email, role: 'patient', }, token: response.token, isAuthenticated: true, isLoading: false, wrapped_dek: setup.passwordWrappedDek.data, wrapped_dek_iv: setup.passwordWrappedDek.iv, identity_public_key: publicKey, identity_private_key_wrapped: wrappedPrivate.data, identity_private_key_wrapped_iv: wrappedPrivate.iv, }); } catch (error: any) { set({ error: error.message || 'Registration failed', isLoading: false, isAuthenticated: false, }); throw error; } }, recover: async (email: string, recoveryPhrase: string, newPassword: string) => { set({ isLoading: true, error: null }); try { // 1. Fetch the recovery-wrapped DEK from the server. const info = await apiService.getRecoveryInfo(email); if (!info.recovery_enabled || !info.recovery_wrapped_dek || !info.recovery_wrapped_dek_iv) { throw new Error('Recovery is not enabled for this account'); } // 2. Unwrap the DEK using the recovery phrase. const dek = await unlockWithRecovery(recoveryPhrase, { data: info.recovery_wrapped_dek, iv: info.recovery_wrapped_dek_iv, }); // 3. Re-wrap the DEK under the new password. const newWrapped = await rewrapDek(dek, newPassword); // 4. Derive the new auth secret + recovery proof. const newAuthSecret = (await deriveAuthAndEncKeys(newPassword)).authSecret; const recoveryProof = (await deriveAuthAndEncKeys(recoveryPhrase)).authSecret; // 5. Send the recovery request. await apiService.recoverPassword( email, recoveryProof, newAuthSecret, newWrapped.data, newWrapped.iv, ); // Persist the new wrapped DEK so unlock works with the new password. set({ isLoading: false, wrapped_dek: newWrapped.data, wrapped_dek_iv: newWrapped.iv }); } catch (error: any) { clearEncKey(); set({ error: error.message || 'Recovery failed', isLoading: false, }); throw error; } }, logout: async () => { clearEncKey(); clearIdentityPrivate(); clearProfileDeks(); await apiService.logout(); set({ user: null, token: null, isAuthenticated: false, error: null, wrapped_dek: null, wrapped_dek_iv: null, identity_public_key: null, identity_private_key_wrapped: null, identity_private_key_wrapped_iv: null, }); }, clearError: () => set({ error: null }), loadUser: async () => { const token = get().token; if (!token) return; set({ isLoading: true }); try { const user = await apiService.getCurrentUser(); set({ user, isAuthenticated: true, isLoading: false, }); } catch (error: any) { set({ error: error.message, isLoading: false, isAuthenticated: false, }); } }, }), { name: 'normogen-auth', partialize: (state) => ({ token: state.token, user: state.user, isAuthenticated: state.isAuthenticated, wrapped_dek: state.wrapped_dek, wrapped_dek_iv: state.wrapped_dek_iv, identity_public_key: state.identity_public_key, identity_private_key_wrapped: state.identity_private_key_wrapped, identity_private_key_wrapped_iv: state.identity_private_key_wrapped_iv, }), } ) ) ); // Medication Store export const useMedicationStore = create()( devtools((set, get) => ({ medications: [], selectedMedication: null, isLoading: false, error: null, adherence: {}, loadMedications: async () => { const key = getActiveProfileDek(); if (!key) { set({ error: 'No encryption key — log in to decrypt data', isLoading: false }); return; } set({ isLoading: true, error: null }); try { const wireMeds = await apiService.getMedications(getActiveProfileId() ?? undefined); // Decrypt each opaque blob into domain Medication objects. const medications = await Promise.all( wireMeds.map(async (w) => { const data = await decryptJson>(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, dose_schedule: (w as any).dose_schedule ?? undefined, created_at: w.created_at, updated_at: w.updated_at, } as Medication; }), ); set({ medications, isLoading: false }); } catch (error: any) { set({ error: error.message || 'Failed to load medications', isLoading: false, }); } }, createMedication: async (data: any) => { const key = getActiveProfileDek(); if (!key) { set({ error: 'No encryption key — cannot encrypt data' }); throw new Error('No encryption key'); } set({ isLoading: true, error: null }); try { // 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>(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) => ({ medications: [...state.medications, medication], isLoading: false, })); } catch (error: any) { set({ error: error.message || 'Failed to create medication', isLoading: false, }); throw error; } }, updateMedication: async (id: string, data: any) => { const key = getActiveProfileDek(); if (!key) { set({ error: 'No encryption key — cannot encrypt data' }); throw new Error('No encryption key'); } set({ isLoading: true, error: null }); try { // 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) => ({ medications: state.medications.map((med) => med.medication_id === id ? { ...med, ...data, updated_at: wire.updated_at } : med, ), isLoading: false, })); } catch (error: any) { set({ error: error.message || 'Failed to update medication', isLoading: false, }); throw error; } }, deleteMedication: async (id: string) => { set({ isLoading: true, error: null }); try { await apiService.deleteMedication(id); set((state) => ({ medications: state.medications.filter( (med) => med.medication_id !== id ), isLoading: false, })); } catch (error: any) { set({ error: error.message || 'Failed to delete medication', isLoading: false, }); throw error; } }, selectMedication: (medication) => { set({ selectedMedication: medication }); }, clearError: () => set({ error: null }), loadAdherence: async (medicationId) => { try { const stats = await apiService.getAdherence(medicationId); set((state) => ({ adherence: { ...state.adherence, [medicationId]: stats }, })); } catch (error: any) { // Adherence is non-critical; surface nothing disruptive. set({ error: error.message || 'Failed to load adherence' }); } }, logDose: async (medicationId, taken, notes) => { try { await apiService.logDose(medicationId, { taken, notes }); // Refresh adherence for this med so the % reflects the new dose. const stats = await apiService.getAdherence(medicationId); set((state) => ({ adherence: { ...state.adherence, [medicationId]: stats }, })); } catch (error: any) { set({ error: error.message || 'Failed to log dose' }); throw error; } }, })) ); export const useHealthStore = create()( devtools((set, get) => ({ stats: [], trends: [], isLoading: false, error: null, loadStats: async () => { const key = getActiveProfileDek(); if (!key) { set({ error: 'No encryption key — log in to decrypt data', isLoading: false }); return; } set({ isLoading: true, error: null }); try { const wireStats = await apiService.getHealthStats(getActiveProfileId() ?? undefined); const stats = await Promise.all( wireStats.map(async (w) => { const data = await decryptJson>(w.encrypted_data, key); return { stat_id: w.id, user_id: w.user_id, stat_type: (data.stat_type as HealthStatType) ?? HealthStatType.Other, value: (data.value as number) ?? 0, unit: (data.unit as string) ?? '', measured_at: w.recorded_at, notes: data.notes as string | undefined, } as HealthStat; }), ); // Compute trends client-side (server can't compute on ciphertext). const computedTrends = computeTrends(stats); set({ stats, trends: computedTrends, isLoading: false }); } catch (error: any) { set({ error: error.message || 'Failed to load health stats', isLoading: false, }); } }, createStat: async (data: any) => { const key = getActiveProfileDek(); if (!key) { set({ error: 'No encryption key — cannot encrypt data' }); throw new Error('No encryption key'); } set({ isLoading: true, error: null }); try { const d = data as any; const profileId = getActiveProfileId(); const encrypted_data = await encryptJson({ stat_type: d.stat_type, value: d.value, unit: d.unit, notes: d.notes, }, key); const wire = await apiService.createHealthStat({ profile_id: profileId ?? '', encrypted_data, recorded_at: d.measured_at, }); const stat: HealthStat = { stat_id: wire.id, user_id: wire.user_id, stat_type: d.stat_type, value: d.value, unit: d.unit, measured_at: wire.recorded_at, notes: d.notes, }; set((state) => ({ stats: [...state.stats, stat], isLoading: false, })); } catch (error: any) { set({ error: error.message || 'Failed to create stat', isLoading: false, }); throw error; } }, updateStat: async (id: string, data: any) => { const key = getActiveProfileDek(); if (!key) { set({ error: 'No encryption key — cannot encrypt data' }); throw new Error('No encryption key'); } try { const d = data as any; const encrypted_data = await encryptJson({ stat_type: d.stat_type, value: d.value, unit: d.unit, notes: d.notes, }, key); await apiService.updateHealthStat(id, { encrypted_data }); set((state) => ({ stats: state.stats.map((s) => s.stat_id === id ? { ...s, ...d } : s, ), })); } catch (error: any) { set({ error: error.message || 'Failed to update stat' }); throw error; } }, deleteStat: async (id: string) => { try { await apiService.deleteHealthStat(id); set((state) => ({ stats: state.stats.filter((s) => s.stat_id !== id), })); } catch (error: any) { set({ error: error.message || 'Failed to delete stat' }); throw error; } }, loadTrends: async () => { // Trends are now computed client-side in loadStats. const computedTrends = computeTrends(get().stats); set({ trends: computedTrends }); }, clearError: () => set({ error: null }), })) ); /** Compute trend data from decrypted stats (client-side, since the server * can't compute trends on ciphertext). */ function computeTrends(stats: HealthStat[]): any[] { const byType: Record = {}; for (const s of stats) { const key = String(s.stat_type); (byType[key] ??= []).push(s); } const trends: any[] = []; for (const [type, items] of Object.entries(byType)) { const values = items.map((s) => s.value); if (values.length === 0) continue; const avg = values.reduce((a, b) => a + b, 0) / values.length; const min = Math.min(...values); const max = Math.max(...values); trends.push({ stat_type: type, average: avg, min, max, trend: 'stable' as const, data_points: items, }); } return trends; } // Interaction Store (Phase 2.8) export const useInteractionStore = create()( devtools((set, get) => ({ interactions: [], isChecking: false, error: null, checkInteractions: async (medications: string[]) => { set({ isChecking: true, error: null }); try { const interactions = await apiService.checkInteractions(medications); set({ interactions, isChecking: false }); } catch (error: any) { set({ error: error.message || 'Failed to check interactions', isChecking: false, }); } }, checkNewMedication: async (name: string, dosage: string) => { set({ isChecking: true, error: null }); try { const interactions = await apiService.checkNewMedication(name, dosage); set({ interactions, isChecking: false }); } catch (error: any) { set({ error: error.message || 'Failed to check medication', isChecking: false, }); } }, clearInteractions: () => set({ interactions: [] }), clearError: () => set({ error: null }), })) ); // Profile Store (Phase A2: multi-profile + per-profile DEKs) export const useProfileStore = create()( devtools((set, get) => ({ profiles: [], activeProfileId: null, isLoading: false, error: null, profileShares: {}, loadProfiles: async () => { const accountDek = getEncKey(); if (!accountDek) { set({ error: 'No account key — log in to decrypt profiles', isLoading: false }); return; } set({ isLoading: true, error: null }); try { const wireList = await apiService.listProfiles(); // Unwrap each profile's DEK under the account DEK, and decrypt each // profile's display name under that profile's DEK. const profiles: Profile[] = []; for (const wire of wireList) { let profileDek = getProfileDek(wire.profile_id); if (!profileDek) { try { profileDek = await unwrapProfileDek( { data: wire.wrapped_profile_dek, iv: wire.wrapped_profile_dek_iv }, accountDek, ); setProfileDek(wire.profile_id, profileDek); } catch { // Could not unwrap this profile's DEK — skip it; the UI will // show the profiles it could decrypt. continue; } } let name = ''; if (wire.name_data && wire.name_iv && profileDek) { try { name = await decryptRaw({ data: wire.name_data, iv: wire.name_iv }, profileDek); } catch { name = ''; } } profiles.push({ profile_id: wire.profile_id, owner_account_id: wire.owner_account_id, name, kind: wire.kind, relationship: wire.relationship, role: wire.role, permissions: wire.permissions, created_at: wire.created_at, updated_at: wire.updated_at, }); } // Set the first profile active if none is selected (or if the active // one is no longer present). const currentActive = getActiveProfileId(); const stillOwned = profiles.some((p) => p.profile_id === currentActive); const newActive = stillOwned ? currentActive : (profiles[0]?.profile_id ?? null); if (newActive) setActiveProfileId(newActive); set({ profiles, activeProfileId: newActive, isLoading: false }); } catch (error: any) { set({ error: error.message || 'Failed to load profiles', isLoading: false, }); } }, setActiveProfile: (profileId) => { setActiveProfileId(profileId); set({ activeProfileId: profileId }); }, createProfile: async ({ name, kind = 'human', relationship = '' }) => { const accountDek = getEncKey(); if (!accountDek) { set({ error: 'No account key — cannot create profile' }); throw new Error('No account key'); } set({ isLoading: true, error: null }); try { // Generate a fresh profile DEK, wrap it under the account DEK, and // encrypt the display name under the new profile DEK. const profileDek = await generateProfileDek(); const wrapped = await wrapProfileDek(profileDek, accountDek); const nameBlob = await encryptRaw(name, profileDek); const wire = await apiService.createProfile({ kind, relationship, name_data: nameBlob.data, name_iv: nameBlob.iv, wrapped_profile_dek: wrapped.data, wrapped_profile_dek_iv: wrapped.iv, }); // Cache the DEK in memory (we already have it) and set this profile // active — the user just created it. setProfileDek(wire.profile_id, profileDek); setActiveProfileId(wire.profile_id); const profile: Profile = { profile_id: wire.profile_id, owner_account_id: wire.owner_account_id, name, kind: wire.kind, relationship: wire.relationship, role: wire.role, permissions: wire.permissions, created_at: wire.created_at, updated_at: wire.updated_at, }; set((state) => ({ profiles: [...state.profiles, profile], activeProfileId: wire.profile_id, isLoading: false, })); } catch (error: any) { set({ error: error.message || 'Failed to create profile', isLoading: false, }); throw error; } }, updateProfile: async (profileId, { name, kind, relationship }) => { const profileDek = getProfileDek(profileId); if (!profileDek) { set({ error: 'No profile key — switch to or unlock this profile first' }); throw new Error('No profile key'); } set({ isLoading: true, error: null }); try { const nameBlob = await encryptRaw(name, profileDek); const wire = await apiService.updateProfile(profileId, { name_data: nameBlob.data, name_iv: nameBlob.iv, kind: kind ?? 'human', relationship: relationship ?? '', }); set((state) => ({ profiles: state.profiles.map((p) => p.profile_id === profileId ? { ...p, name, kind: wire.kind, relationship: wire.relationship, updated_at: wire.updated_at, } : p, ), isLoading: false, })); } catch (error: any) { set({ error: error.message || 'Failed to update profile', isLoading: false, }); throw error; } }, deleteProfile: async (profileId) => { set({ isLoading: true, error: null }); try { await apiService.deleteProfile(profileId); const remaining = get().profiles.filter((p) => p.profile_id !== profileId); // If we deleted the active profile, fall back to the first remaining. let newActive = getActiveProfileId(); if (newActive === profileId) { newActive = remaining[0]?.profile_id ?? null; if (newActive) setActiveProfileId(newActive); } set({ profiles: remaining, activeProfileId: newActive, isLoading: false }); } catch (error: any) { set({ error: error.message || 'Failed to delete profile', isLoading: false, }); throw error; } }, loadSharedWithMe: async () => { // Recipient path: profiles shared TO the current user. Each share's // profile DEK is wrapped under an ECDH-derived key; unwrap it with the // identity private key, then decrypt the display name under the profile // DEK. Merge into the profiles list with is_shared = true. const myIdentityPrivate = getIdentityPrivate(); if (!myIdentityPrivate) { // No identity key unlocked — can't unwrap shared profile DEKs. Silently // skip; the owned-profile list still loads. return; } try { const shared = await apiService.listSharedWithMe(); const sharedProfiles: Profile[] = []; for (const s of shared) { // Skip if we already have the DEK in memory (avoid re-unwrapping). let profileDek = getProfileDek(s.profile_id); if (!profileDek) { try { profileDek = await unwrapProfileDekFromShare( { data: s.wrapped_profile_dek, iv: s.wrapped_profile_dek_iv }, s.ephemeral_public_key, myIdentityPrivate, ); setProfileDek(s.profile_id, profileDek); } catch { continue; // couldn't unwrap — skip this share } } let name = ''; if (s.name_data && s.name_iv && profileDek) { try { name = await decryptRaw({ data: s.name_data, iv: s.name_iv }, profileDek); } catch { name = ''; } } sharedProfiles.push({ profile_id: s.profile_id, owner_account_id: s.owner_account_id, name, kind: s.kind, relationship: s.relationship, role: 'patient', permissions: s.permissions, created_at: s.created_at, updated_at: s.created_at, is_shared: true, }); } // Merge: replace any prior shared entries, keep owned ones intact. const owned = get().profiles.filter((p) => !p.is_shared); const merged = [...owned, ...sharedProfiles]; set({ profiles: merged }); } catch (error: any) { // Non-fatal: owned profiles still work. set({ error: error.message || 'Failed to load shared profiles' }); } }, shareProfile: async (profileId, recipientEmail) => { // Owner path: fetch recipient pubkey, wrap the profile DEK to it, POST. const myIdentityPrivate = getIdentityPrivate(); const profileDek = getProfileDek(profileId); if (!myIdentityPrivate) { set({ error: 'No identity key unlocked — cannot share' }); throw new Error('No identity key'); } if (!profileDek) { set({ error: 'No profile key — switch to the profile first' }); throw new Error('No profile key'); } set({ isLoading: true, error: null }); try { const { identity_public_key: recipientPub } = await apiService.getUserPublicKey(recipientEmail); const envelope = await wrapProfileDekToRecipient( profileDek, recipientPub, myIdentityPrivate, ); await apiService.createProfileShare(profileId, { recipient_email: recipientEmail, ephemeral_public_key: envelope.ephemeralPublicKey, wrapped_profile_dek: envelope.wrappedProfileDek.data, wrapped_profile_dek_iv: envelope.wrappedProfileDek.iv, permissions: ['read'], }); // Refresh the share listing for this profile. await get().loadProfileShares(profileId); set({ isLoading: false }); } catch (error: any) { set({ error: error.message || 'Failed to share profile', isLoading: false, }); throw error; } }, revokeShare: async (profileId, recipientUserId) => { set({ isLoading: true, error: null }); try { await apiService.deleteProfileShare(profileId, recipientUserId); await get().loadProfileShares(profileId); set({ isLoading: false }); } catch (error: any) { set({ error: error.message || 'Failed to revoke share', isLoading: false, }); throw error; } }, loadProfileShares: async (profileId) => { try { const shares = await apiService.listProfileShares(profileId); set((state) => ({ profileShares: { ...state.profileShares, [profileId]: shares }, })); } catch (error: any) { set({ error: error.message || 'Failed to load shares' }); } }, clearError: () => set({ error: null }), })), ); // Appointment Store export const useAppointmentStore = create()( devtools((set) => ({ appointments: [], isLoading: false, error: null, loadAppointments: async (status) => { const key = getActiveProfileDek(); if (!key) { set({ error: 'No encryption key — log in to decrypt data', isLoading: false }); return; } set({ isLoading: true, error: null }); try { const wireAppts = await apiService.getAppointments(status, getActiveProfileId() ?? undefined); const appointments = await Promise.all( wireAppts.map(async (w) => { const data = await decryptJson>(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 }); } catch (error: any) { set({ error: error.message || 'Failed to load appointments', isLoading: false, }); } }, createAppointment: async (data) => { const key = getActiveProfileDek(); if (!key) { set({ error: 'No encryption key — cannot encrypt data' }); throw new Error('No encryption key'); } set({ isLoading: true, error: null }); try { 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) => ({ appointments: [...state.appointments, appointment], isLoading: false, })); } catch (error: any) { set({ error: error.message || 'Failed to create appointment', isLoading: false, }); throw error; } }, updateAppointment: async (id, data) => { const key = getActiveProfileDek(); if (!key) { set({ error: 'No encryption key — cannot encrypt data' }); throw new Error('No encryption key'); } try { 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) => ({ appointments: state.appointments.map((a) => a.appointment_id === id ? { ...a, ...data, status: wire.status, updated_at: wire.updated_at } : a, ), })); } catch (error: any) { set({ error: error.message || 'Failed to update appointment' }); throw error; } }, deleteAppointment: async (id) => { try { await apiService.deleteAppointment(id); set((state) => ({ appointments: state.appointments.filter((a) => a.appointment_id !== id), })); } catch (error: any) { set({ error: error.message || 'Failed to delete appointment' }); throw error; } }, clearError: () => set({ error: null }), })), );