Dose scheduling: - DoseSchedule struct (times_per_day + days_of_week) as top-level field on Medication. get_adherence computes scheduled_doses from the schedule over the period; missed doses now reflected. Fallback to taken/total_logged when no schedule. Wired through create/update requests + MedicationResponse. - Frontend: dose_schedule field on Medication domain type + wire response; store reads it on load. (MedicationManager UI field deferred — the data flows correctly; a form field can be added when needed.) Health stats zero-knowledge: - HealthStatistic model now uses opaque encrypted_data blob (like medications). Only recorded_at stays plaintext. HealthStatResponse wire type. - Removed the trends endpoint (server can't compute on ciphertext); trends are now computed client-side in the health store after decryption. - Deleted the dead HealthData model (kept EncryptedField which it defined). - Frontend: health store decrypts on load + encrypts on write; trends computed client-side; HealthStats component uses domain form types. Verified: backend 24 tests 0 warnings; frontend build clean, 24 tests.
915 lines
30 KiB
TypeScript
915 lines
30 KiB
TypeScript
import { create } from 'zustand';
|
|
import { devtools, persist } from 'zustand/middleware';
|
|
import {
|
|
deriveAuthAndEncKeys,
|
|
setupEncryption,
|
|
unlockWithPassword,
|
|
unlockWithRecovery,
|
|
rewrapDek,
|
|
setEncKey,
|
|
clearEncKey,
|
|
getEncKey,
|
|
encryptJson,
|
|
decryptJson,
|
|
type CipherPayload,
|
|
} from '../crypto';
|
|
import {
|
|
User,
|
|
Medication,
|
|
HealthStat,
|
|
HealthStatType,
|
|
DrugInteraction,
|
|
AdherenceStats,
|
|
Profile,
|
|
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;
|
|
|
|
// 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 {
|
|
profile: Profile | null;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
loadProfile: () => Promise<void>;
|
|
updateName: (name: 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,
|
|
|
|
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);
|
|
} 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,
|
|
});
|
|
} 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);
|
|
|
|
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,
|
|
} as any);
|
|
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,
|
|
});
|
|
} 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();
|
|
await apiService.logout();
|
|
set({
|
|
user: null,
|
|
token: null,
|
|
isAuthenticated: false,
|
|
error: null,
|
|
wrapped_dek: null,
|
|
wrapped_dek_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,
|
|
}),
|
|
}
|
|
)
|
|
)
|
|
);
|
|
|
|
// Medication Store
|
|
export const useMedicationStore = create<MedicationState>()(
|
|
devtools((set, get) => ({
|
|
medications: [],
|
|
selectedMedication: null,
|
|
isLoading: false,
|
|
error: null,
|
|
adherence: {},
|
|
|
|
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 });
|
|
try {
|
|
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,
|
|
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 = getEncKey();
|
|
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 = getEncKey();
|
|
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 = getEncKey();
|
|
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();
|
|
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 = getEncKey();
|
|
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 encrypted_data = await encryptJson({
|
|
stat_type: d.stat_type,
|
|
value: d.value,
|
|
unit: d.unit,
|
|
notes: d.notes,
|
|
}, key);
|
|
const wire = await apiService.createHealthStat({
|
|
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 = getEncKey();
|
|
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 3c)
|
|
export const useProfileStore = create<ProfileState>()(
|
|
devtools((set, get) => ({
|
|
profile: null,
|
|
isLoading: false,
|
|
error: null,
|
|
|
|
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 });
|
|
try {
|
|
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 });
|
|
} catch (error: any) {
|
|
set({
|
|
error: error.message || 'Failed to load profile',
|
|
isLoading: false,
|
|
});
|
|
}
|
|
},
|
|
|
|
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 });
|
|
try {
|
|
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 });
|
|
} catch (error: any) {
|
|
set({
|
|
error: error.message || 'Failed to update profile',
|
|
isLoading: false,
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
clearError: () => set({ error: null }),
|
|
})),
|
|
);
|
|
|
|
// Appointment Store
|
|
export const useAppointmentStore = create<AppointmentState>()(
|
|
devtools((set) => ({
|
|
appointments: [],
|
|
isLoading: false,
|
|
error: null,
|
|
|
|
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 });
|
|
try {
|
|
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 });
|
|
} catch (error: any) {
|
|
set({
|
|
error: error.message || 'Failed to load appointments',
|
|
isLoading: false,
|
|
});
|
|
}
|
|
},
|
|
|
|
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 });
|
|
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 = getEncKey();
|
|
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 }),
|
|
})),
|
|
);
|