normogen/web/normogen-web/src/store/useStore.ts
goose 04520539aa
Some checks failed
Lint and Build / format (pull_request) Successful in 35s
Lint and Build / clippy (pull_request) Successful in 1m41s
Lint and Build / build (pull_request) Successful in 3m51s
Lint and Build / test (pull_request) Failing after 3m41s
feat: profile sharing via X25519 envelope (Phase B, #3)
An owner can now share a profile with another account; the recipient
reads the profile's metadata AND its data (medications, appointments,
health stats) under their own login. The server stays a blind store:
sharing uses an X25519 envelope — the owner wraps the profile DEK to
the recipient's identity public key via ECDH (fresh ephemeral key per
share), the recipient unwraps it with their identity private key.

Backend:
- models/profile_share.rs: ProfileShare + ProfileShareRepository
  (find_for_recipient, find_for_profile, find_active [checks active +
  expiry], delete, upsert). Indexed on (profileId, recipientUserId) and
  recipientUserId.
- handlers/profile_share.rs: POST/GET /profiles/:id/shares,
  DELETE /profiles/:id/shares/:recipient, GET /profiles/shared-with-me,
  GET /users/public-key (public), and authorize_profile_read — the
  share-gate that admits owner OR active-share recipient.
- The share-gate is wired into list/get for medications, appointments,
  and health stats: when profile_id is specified, resolve the owner via
  the gate and query as them. Data repos stay ownership-scoped.
- Removed the legacy Share system (ADR Open Q5): models/{share,
  permission}.rs, handlers/{shares,permissions}.rs, middleware/
  permission.rs (dead), the shares collection field + methods in
  mongodb_impl.rs, the shares index, and the 5 /api/shares +
  /api/permissions routes.
- share_tests.rs: full owner→recipient→revoke flow, ownership
  isolation, share-to-self/nonexistent/keyless rejections, expired
  share treated as absent.

Frontend:
- crypto/keys.ts: wrapProfileDekToRecipient /
  unwrapProfileDekFromShare (ECDH envelope, ephemeral key per share).
- useProfileStore: loadSharedWithMe (unwrap each share's DEK with the
  identity private key), shareProfile, revokeShare, loadProfileShares.
  Shared profiles merge into the list with is_shared=true.
- ProfileSwitcher: shows shared profiles with a 'shared' chip.
- ProfileSharing (new) + ProfileEditor: owner UI to add a recipient by
  email and revoke; shared profiles render read-only with owner info.
- ECDH round-trip test (owner wraps, recipient unwraps, stranger can't).

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

⚠️ Backend integration tests not run locally (no Mongo/Docker in this
sandbox); CI will run them — I'll fix any failures immediately.

Phases C (hard revoke / re-key) and D (graduation) remain. Refs #3.
2026-07-19 07:21:11 -03:00

1267 lines
44 KiB
TypeScript

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<void>;
register: (username: string, email: string, password: string, recoveryPhrase?: string) => Promise<void>;
recover: (email: string, recoveryPhrase: string, newPassword: string) => Promise<void>;
logout: () => Promise<void>;
clearError: () => void;
loadUser: () => Promise<void>;
}
interface MedicationState {
medications: Medication[];
selectedMedication: Medication | null;
isLoading: boolean;
error: string | null;
// Adherence cache: medicationId -> stats. Loaded on demand per medication.
adherence: Record<string, AdherenceStats>;
// Actions
loadMedications: () => Promise<void>;
createMedication: (data: any) => Promise<void>;
updateMedication: (id: string, data: any) => Promise<void>;
deleteMedication: (id: string) => Promise<void>;
selectMedication: (medication: Medication | null) => void;
clearError: () => void;
loadAdherence: (medicationId: string) => Promise<void>;
logDose: (medicationId: string, taken: boolean, notes?: string) => Promise<void>;
}
interface HealthState {
stats: HealthStat[];
trends: any[];
isLoading: boolean;
error: string | null;
// Actions
loadStats: () => Promise<void>;
createStat: (data: any) => Promise<void>;
updateStat: (id: string, data: any) => Promise<void>;
deleteStat: (id: string) => Promise<void>;
loadTrends: () => Promise<void>;
clearError: () => void;
}
interface InteractionState {
interactions: DrugInteraction[];
isChecking: boolean;
error: string | null;
// Actions
checkInteractions: (medications: string[]) => Promise<void>;
checkNewMedication: (name: string, dosage: string) => Promise<void>;
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<void>;
/** 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<void>;
updateProfile: (profileId: string, input: {
name: string;
kind?: string;
relationship?: string;
}) => Promise<void>;
deleteProfile: (profileId: string) => Promise<void>;
/** 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<void>;
/** 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<void>;
/** Owner: revoke a share (delete the share record server-side). */
revokeShare: (profileId: string, recipientUserId: string) => Promise<void>;
/** Shares the current user has created for a profile (for the owner UI). */
profileShares: Record<string, ProfileShareListing[]>;
loadProfileShares: (profileId: string) => Promise<void>;
clearError: () => void;
}
interface AppointmentState {
appointments: Appointment[];
isLoading: boolean;
error: string | null;
loadAppointments: (status?: string) => Promise<void>;
// The store receives DOMAIN data (decrypted fields from the UI) and encrypts
// 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>;
clearError: () => void;
}
// Auth Store
export const useAuthStore = create<AuthState>()(
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<MedicationState>()(
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<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,
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<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) => ({
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<HealthState>()(
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<Record<string, unknown>>(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<string, HealthStat[]> = {};
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<InteractionState>()(
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<ProfileState>()(
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<AppointmentState>()(
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<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 });
} 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 }),
})),
);