normogen/web/normogen-web/src/store/useStore.ts
goose 8ee8012aca
Some checks failed
Lint and Build / format (pull_request) Successful in 42s
Lint and Build / clippy (pull_request) Successful in 1m46s
Lint and Build / build (pull_request) Successful in 3m47s
Lint and Build / test (pull_request) Failing after 0s
feat(auth): add per-account X25519 identity keypair (#3)
Phase A1 of the multi-person sharing ADR
(docs/adr/multi-person-sharing.md). Each account now gets an X25519
keypair at registration: the public half stored plaintext, the private
half wrapped under the account DEK and stored as opaque ciphertext. The
keypair is generated client-side; the backend adds no crypto deps and
stores everything verbatim, preserving the zero-knowledge contract.

This change only introduces the keypair and threads it through the auth
flows — it is not consumed yet. It unblocks Phase B (profile sharing)
without touching the data model or the ~11 frontend encrypt call sites,
which is Phase A2 (per-profile DEKs).

Backend:
- User model: identity_public_key, identity_private_key_wrapped{,_iv}
  (all Option<String>, backward compatible).
- RegisterRequest/AuthResponse carry the 3 fields; register + login
  echo them. No changes to change_password/recover (DEK value is
  unchanged across both, so the wrapped private key is too).
- 2 new integration tests: round-trip through register/login, and
  optional-fields backward compat.

Frontend:
- crypto/keys.ts: generateIdentityKeyPair, wrapIdentityPrivateKey,
  unwrapIdentityPrivateKey + in-memory identity store mirroring the DEK.
- types/api.ts: AuthTokens + RegisterRequest extended (removes an
  existing `as any` cast).
- useStore register/login/logout + UnlockPage unwrap the private key
  alongside the DEK.
- 3 new crypto tests (X25519 lifecycle, wrong-DEK rejection, distinct
  shared secrets); skip gracefully where the runtime lacks X25519.

Backend: cargo test + clippy green. Frontend: npm test + tsc green.

Also gitignore .zcode/ (local tooling artifact).
2026-07-18 20:00:01 -03:00

972 lines
32 KiB
TypeScript

import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import {
deriveAuthAndEncKeys,
setupEncryption,
unlockWithPassword,
unlockWithRecovery,
rewrapDek,
generateIdentityKeyPair,
wrapIdentityPrivateKey,
unwrapIdentityPrivateKey,
setEncKey,
clearEncKey,
clearIdentityPrivate,
setIdentityPrivate,
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;
// 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 {
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,
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);
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,
});
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();
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 = 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 }),
})),
);