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.
1267 lines
44 KiB
TypeScript
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 }),
|
|
})),
|
|
);
|