diff --git a/web/normogen-web/src/components/appointments/AppointmentsManager.tsx b/web/normogen-web/src/components/appointments/AppointmentsManager.tsx index 7391943..d3ee7b0 100644 --- a/web/normogen-web/src/components/appointments/AppointmentsManager.tsx +++ b/web/normogen-web/src/components/appointments/AppointmentsManager.tsx @@ -25,22 +25,32 @@ import DeleteIcon from '@mui/icons-material/Delete'; import AddIcon from '@mui/icons-material/Add'; import { format } from 'date-fns'; import { useAppointmentStore, useAuthStore } from '../../store/useStore'; -import type { - Appointment, - CreateAppointmentRequest, - UpdateAppointmentRequest, -} from '../../types/api'; +import type { Appointment } from '../../types/api'; const APPT_TYPES = ['in-person', 'telehealth', 'lab', 'test', 'other']; const STATUSES = ['upcoming', 'completed', 'cancelled']; +// Domain form type (decrypted fields the UI collects; the store encrypts them). +interface ApptFormData { + title: string; + provider: string; + appointment_type: string; + date_time: string; + location?: string; + duration_minutes?: number; + reason?: string; + notes?: string; + status?: string; + profile_id?: string; +} + const statusColor = (status: string): 'success' | 'default' | 'error' => { if (status === 'upcoming') return 'success'; if (status === 'cancelled') return 'error'; return 'default'; }; -const emptyCreate: CreateAppointmentRequest = { +const emptyCreate: ApptFormData = { title: '', provider: '', appointment_type: 'in-person', @@ -67,9 +77,9 @@ export const AppointmentsManager: FC = () => { const user = useAuthStore((s) => s.user); const [createOpen, setCreateOpen] = useState(false); - const [createForm, setCreateForm] = useState(emptyCreate); + const [createForm, setCreateForm] = useState(emptyCreate); const [editTarget, setEditTarget] = useState(null); - const [editForm, setEditForm] = useState({}); + const [editForm, setEditForm] = useState>({}); const [deleteTarget, setDeleteTarget] = useState(null); const [saving, setSaving] = useState(false); @@ -88,7 +98,7 @@ export const AppointmentsManager: FC = () => { const submitCreate = async () => { setSaving(true); try { - await createAppointment(createForm); + await createAppointment(createForm as unknown as Record); setCreateOpen(false); } catch { /* error surfaced via store */ @@ -116,7 +126,7 @@ export const AppointmentsManager: FC = () => { if (!editTarget?.appointment_id) return; setSaving(true); try { - await updateAppointment(editTarget.appointment_id, editForm); + await updateAppointment(editTarget.appointment_id, editForm as unknown as Record); setEditTarget(null); } catch { /* error surfaced via store */ diff --git a/web/normogen-web/src/components/medication/MedicationManager.tsx b/web/normogen-web/src/components/medication/MedicationManager.tsx index 90f411d..e80679c 100644 --- a/web/normogen-web/src/components/medication/MedicationManager.tsx +++ b/web/normogen-web/src/components/medication/MedicationManager.tsx @@ -25,16 +25,23 @@ import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; import AddIcon from '@mui/icons-material/Add'; import { useMedicationStore, useAuthStore } from '../../store/useStore'; -import type { - Medication, - CreateMedicationRequest, - UpdateMedicationRequest, -} from '../../types/api'; +import type { Medication } from '../../types/api'; import { DoseLogger } from './DoseLogger'; const ROUTES = ['oral', 'topical', 'injection', 'inhalation', 'other'] as const; -const emptyCreate: CreateMedicationRequest = { +// Domain form types (decrypted fields the UI collects; the store encrypts them). +interface MedFormData { + name: string; + dosage: string; + frequency: string; + route: string; + instructions?: string; + profile_id?: string; + active?: boolean; +} + +const emptyCreate: MedFormData = { name: '', dosage: '', frequency: '', @@ -56,9 +63,9 @@ export const MedicationManager: FC = () => { const user = useAuthStore((s) => s.user); const [createOpen, setCreateOpen] = useState(false); - const [createForm, setCreateForm] = useState(emptyCreate); + const [createForm, setCreateForm] = useState(emptyCreate); const [editTarget, setEditTarget] = useState(null); - const [editForm, setEditForm] = useState({}); + const [editForm, setEditForm] = useState>({}); const [deleteTarget, setDeleteTarget] = useState(null); const [saving, setSaving] = useState(false); @@ -238,7 +245,7 @@ export const MedicationManager: FC = () => { fullWidth value={createForm.route} onChange={(e) => - setCreateForm({ ...createForm, route: e.target.value as CreateMedicationRequest['route'] }) + setCreateForm({ ...createForm, route: e.target.value }) } > {ROUTES.map((r) => ( diff --git a/web/normogen-web/src/crypto/cipher.ts b/web/normogen-web/src/crypto/cipher.ts index c5f1658..c9a4bb3 100644 --- a/web/normogen-web/src/crypto/cipher.ts +++ b/web/normogen-web/src/crypto/cipher.ts @@ -40,9 +40,9 @@ export async function encrypt( ): Promise { const iv = randomIv(); const ciphertext = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, + { name: 'AES-GCM', iv: iv as BufferSource }, key, - encoder.encode(plaintext), + encoder.encode(plaintext) as BufferSource, ); return { data: b64.encode(new Uint8Array(ciphertext)), @@ -57,9 +57,9 @@ export async function decrypt( ): Promise { const iv = b64.decode(payload.iv); const plaintext = await crypto.subtle.decrypt( - { name: 'AES-GCM', iv }, + { name: 'AES-GCM', iv: iv as BufferSource }, key, - b64.decode(payload.data), + b64.decode(payload.data) as BufferSource, ); return decoder.decode(plaintext); } diff --git a/web/normogen-web/src/services/api.ts b/web/normogen-web/src/services/api.ts index 3cf213b..ca490d0 100644 --- a/web/normogen-web/src/services/api.ts +++ b/web/normogen-web/src/services/api.ts @@ -17,8 +17,10 @@ import { DoseLog, LogDoseRequest, AdherenceStats, - Profile, - Appointment, + MedicationWireResponse, + AppointmentWireResponse, + ProfileWireResponse, + EncryptedFieldWire, CreateAppointmentRequest, UpdateAppointmentRequest, } from '../types/api'; @@ -213,47 +215,48 @@ class ApiService { return response.data; } - // ---- Profile (Phase 3c) ---- + // ---- Profile (zero-knowledge: opaque encrypted name) ---- - async getProfile(): Promise { - const response = await this.client.get('/profiles/me'); + async getProfile(): Promise { + const response = await this.client.get('/profiles/me'); return response.data; } - async updateProfileName(name: string): Promise { - const response = await this.client.put('/profiles/me', { name }); + async updateProfileName(nameData: string, nameIv: string): Promise { + const response = await this.client.put('/profiles/me', { + name_data: nameData, + name_iv: nameIv, + }); return response.data; } - // ---- Medications ---- + // ---- Medications (zero-knowledge: opaque encrypted blobs) ---- - async getMedications(): Promise { - const response = await this.client.get('/medications'); + async getMedications(): Promise { + const response = await this.client.get('/medications'); return response.data; } - async getMedication(id: string): Promise { - const response = await this.client.get(`/medications/${id}`); + async getMedication(id: string): Promise { + const response = await this.client.get(`/medications/${id}`); return response.data; } - async createMedication(data: CreateMedicationRequest): Promise { - const response = await this.client.post('/medications', data); + async createMedication(data: CreateMedicationRequest): Promise { + const response = await this.client.post('/medications', data); return response.data; } - async updateMedication(id: string, data: UpdateMedicationRequest): Promise { - // Backend update is POST /:id (not PUT). - const response = await this.client.post(`/medications/${id}`, data); + async updateMedication(id: string, data: UpdateMedicationRequest): Promise { + const response = await this.client.post(`/medications/${id}`, data); return response.data; } async deleteMedication(id: string): Promise { - // Backend delete is POST /:id/delete (not DELETE /:id). await this.client.post(`/medications/${id}/delete`); } - // ---- Dose logging + adherence (Phase 3c) ---- + // ---- Dose logging + adherence (not encrypted — counts/flags only) ---- async logDose(medicationId: string, req: LogDoseRequest): Promise { const response = await this.client.post(`/medications/${medicationId}/log`, req); @@ -265,27 +268,27 @@ class ApiService { return response.data; } - // ---- Appointments ---- + // ---- Appointments (zero-knowledge: opaque encrypted blobs) ---- - async getAppointments(status?: string): Promise { - const response = await this.client.get('/appointments', { + async getAppointments(status?: string): Promise { + const response = await this.client.get('/appointments', { params: status ? { status } : undefined, }); return response.data; } - async getAppointment(id: string): Promise { - const response = await this.client.get(`/appointments/${id}`); + async getAppointment(id: string): Promise { + const response = await this.client.get(`/appointments/${id}`); return response.data; } - async createAppointment(data: CreateAppointmentRequest): Promise { - const response = await this.client.post('/appointments', data); + async createAppointment(data: CreateAppointmentRequest): Promise { + const response = await this.client.post('/appointments', data); return response.data; } - async updateAppointment(id: string, data: UpdateAppointmentRequest): Promise { - const response = await this.client.post(`/appointments/${id}`, data); + async updateAppointment(id: string, data: UpdateAppointmentRequest): Promise { + const response = await this.client.post(`/appointments/${id}`, data); return response.data; } diff --git a/web/normogen-web/src/store/useStore.test.ts b/web/normogen-web/src/store/useStore.test.ts index 2af1974..5024b07 100644 --- a/web/normogen-web/src/store/useStore.test.ts +++ b/web/normogen-web/src/store/useStore.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { deriveAuthAndEncKeys, setEncKey, clearEncKey } from '../crypto'; +import { encryptJson } from '../crypto'; // Mock the api client so the store's real reducer logic is exercised without HTTP. const apiMock = { @@ -13,52 +15,87 @@ vi.mock('../services/api', () => ({ default: apiMock })); const { useMedicationStore } = await import('./useStore'); describe('useMedicationStore', () => { - beforeEach(() => { + let encKey: CryptoKey; + + beforeEach(async () => { + // Derive a real encryption key so the store can encrypt/decrypt. + const { encKey: key } = await deriveAuthAndEncKeys('test-password'); + encKey = key; + setEncKey(encKey); + useMedicationStore.setState({ medications: [], isLoading: false, error: null, adherence: {}, + selectedMedication: null, }); apiMock.getMedications.mockReset(); apiMock.createMedication.mockReset(); }); - it('loadMedications populates state from the api client', async () => { - const meds = [{ medication_id: 'm1', name: 'Aspirin', dosage: '100mg' }]; - apiMock.getMedications.mockResolvedValue(meds); + afterEach(() => { + clearEncKey(); + }); + + it('loadMedications decrypts wire responses into domain medications', async () => { + // The API returns opaque wire blobs; the store decrypts them. + const blob = await encryptJson({ name: 'Aspirin', dosage: '100mg', frequency: 'daily' }, encKey); + apiMock.getMedications.mockResolvedValue([ + { + id: 'oid1', + medication_id: 'm1', + user_id: 'u1', + profile_id: 'p1', + active: true, + encrypted_data: blob, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + ]); await useMedicationStore.getState().loadMedications(); expect(apiMock.getMedications).toHaveBeenCalledOnce(); - expect(useMedicationStore.getState().medications).toEqual(meds); - expect(useMedicationStore.getState().isLoading).toBe(false); + const meds = useMedicationStore.getState().medications; + expect(meds).toHaveLength(1); + expect(meds[0].name).toBe('Aspirin'); + expect(meds[0].dosage).toBe('100mg'); + expect(meds[0].active).toBe(true); }); - it('loadMedications sets an error message on failure (and does not throw)', async () => { - apiMock.getMedications.mockRejectedValue(new Error('boom')); - - // Should NOT throw — load actions swallow. + it('loadMedications sets an error when no enc key is available', async () => { + clearEncKey(); await useMedicationStore.getState().loadMedications(); - - expect(useMedicationStore.getState().error).toBe('boom'); - expect(useMedicationStore.getState().isLoading).toBe(false); + expect(useMedicationStore.getState().error).toMatch(/No encryption key/); }); - it('createMedication appends the new medication and re-throws on error', async () => { - apiMock.createMedication.mockResolvedValue({ medication_id: 'm2', name: 'New' }); + it('createMedication encrypts domain data and stores the decrypted result', async () => { + const blob = await encryptJson({ name: 'New', dosage: '50mg', frequency: 'daily' }, encKey); + apiMock.createMedication.mockResolvedValue({ + id: 'oid2', + medication_id: 'm2', + user_id: 'u1', + profile_id: 'p1', + active: true, + encrypted_data: blob, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }); - await useMedicationStore.getState().createMedication({ name: 'New' }); + await useMedicationStore.getState().createMedication({ + name: 'New', + dosage: '50mg', + frequency: 'daily', + route: 'oral', + profile_id: 'p1', + active: true, + }); - expect(useMedicationStore.getState().medications).toEqual([ - { medication_id: 'm2', name: 'New' }, - ]); - - // Now an error path: createMedication rejects -> store re-throws. - apiMock.createMedication.mockRejectedValue(new Error('nope')); - await expect( - useMedicationStore.getState().createMedication({ name: 'Bad' }), - ).rejects.toThrow('nope'); + const meds = useMedicationStore.getState().medications; + expect(meds).toHaveLength(1); + expect(meds[0].name).toBe('New'); + expect(meds[0].dosage).toBe('50mg'); }); it('logDose logs the dose then refreshes adherence', async () => { diff --git a/web/normogen-web/src/store/useStore.ts b/web/normogen-web/src/store/useStore.ts index d471874..3e19a04 100644 --- a/web/normogen-web/src/store/useStore.ts +++ b/web/normogen-web/src/store/useStore.ts @@ -1,5 +1,13 @@ import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; +import { + deriveAuthAndEncKeys, + setEncKey, + clearEncKey, + getEncKey, + encryptJson, + decryptJson, +} from '../crypto'; import { User, Medication, @@ -88,8 +96,10 @@ interface AppointmentState { isLoading: boolean; error: string | null; loadAppointments: (status?: string) => Promise; - createAppointment: (data: CreateAppointmentRequest) => Promise; - updateAppointment: (id: string, data: UpdateAppointmentRequest) => Promise; + // The store receives DOMAIN data (decrypted fields from the UI) and encrypts + // it internally before sending to the server. + createAppointment: (data: Record) => Promise; + updateAppointment: (id: string, data: Record) => Promise; deleteAppointment: (id: string) => Promise; clearError: () => void; } @@ -108,7 +118,11 @@ export const useAuthStore = create()( login: async (email: string, password: string) => { set({ isLoading: true, error: null }); try { - const response = await apiService.login(email, password); + // Zero-knowledge: derive auth secret + encryption key from password. + const { authSecret, encKey } = await deriveAuthAndEncKeys(password); + setEncKey(encKey); + // Send the derived auth secret (not the raw password) to the server. + const response = await apiService.login(email, authSecret); set({ user: { user_id: response.user_id, @@ -121,6 +135,7 @@ export const useAuthStore = create()( isLoading: false, }); } catch (error: any) { + clearEncKey(); set({ error: error.message || 'Login failed', isLoading: false, @@ -133,11 +148,12 @@ export const useAuthStore = create()( register: async (username: string, email: string, password: string) => { set({ isLoading: true, error: null }); try { + const { authSecret, encKey } = await deriveAuthAndEncKeys(password); + setEncKey(encKey); const response = await apiService.register({ username, email, - password, - role: 'patient', + password: authSecret, }); set({ user: { @@ -161,6 +177,7 @@ export const useAuthStore = create()( }, logout: async () => { + clearEncKey(); await apiService.logout(); set({ user: null, @@ -215,9 +232,42 @@ export const useMedicationStore = create()( 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 medications = await apiService.getMedications(); + 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>(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, + created_at: w.created_at, + updated_at: w.updated_at, + } as Medication; + }), + ); set({ medications, isLoading: false }); } catch (error: any) { set({ @@ -228,9 +278,42 @@ export const useMedicationStore = create()( }, 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 { - const medication = await apiService.createMedication(data); + // Encrypt the domain fields into an opaque blob. + const { name, dosage, frequency, route, reason, instructions, + side_effects, prescribed_by, prescribed_date, start_date, + end_date, notes, tags, ...rest } = data; + const encrypted_data = await encryptJson({ + name, dosage, frequency, route, reason, instructions, + sideEffects: side_effects, prescribedBy: prescribed_by, + prescribedDate: prescribed_date, startDate: start_date, + endDate: end_date, notes, tags, + }, key); + const wire = await apiService.createMedication({ + profile_id: data.profile_id, + encrypted_data, + active: data.active, + }); + // Decrypt the response back into a domain Medication for the store. + const decrypted = await decryptJson>(wire.encrypted_data, key); + const medication = { + id: wire.id, + medication_id: wire.medication_id, + user_id: wire.user_id, + profile_id: wire.profile_id, + name: (decrypted.name as string) ?? '', + dosage: (decrypted.dosage as string) ?? '', + frequency: (decrypted.frequency as string) ?? '', + active: wire.active, + created_at: wire.created_at, + updated_at: wire.updated_at, + } as Medication; set((state) => ({ medications: [...state.medications, medication], isLoading: false, @@ -245,12 +328,33 @@ export const useMedicationStore = create()( }, 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 { - const updated = await apiService.updateMedication(id, data); + // 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 ? updated : med + med.medication_id === id + ? { ...med, ...data, updated_at: wire.updated_at } + : med, ), isLoading: false, })); @@ -451,9 +555,30 @@ export const useProfileStore = create()( 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 profile = await apiService.getProfile(); + const wire = await apiService.getProfile(); + // Decrypt the profile name. + let name = ''; + if (wire.name_data && wire.name_iv) { + try { + name = await decryptJson({ 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({ @@ -464,9 +589,28 @@ export const useProfileStore = create()( }, 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 profile = await apiService.updateProfileName(name); + const blob = await encryptJson(name, key); + const wire = await apiService.updateProfileName(blob.data, blob.iv); + let decryptedName = name; + try { + decryptedName = await decryptJson({ 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({ @@ -489,9 +633,36 @@ export const useAppointmentStore = create()( 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 appointments = await apiService.getAppointments(status); + const wireAppts = await apiService.getAppointments(status); + const appointments = await Promise.all( + wireAppts.map(async (w) => { + const data = await decryptJson>(w.encrypted_data, key); + return { + id: w.id, + appointment_id: w.appointment_id, + user_id: w.user_id, + profile_id: w.profile_id, + title: (data.title as string) ?? '', + provider: (data.provider as string) ?? '', + appointment_type: (data.appointmentType as string) ?? '', + date_time: (data.dateTime as string) ?? '', + location: data.location as string | undefined, + duration_minutes: data.durationMinutes as number | undefined, + reason: data.reason as string | undefined, + notes: data.notes as string | undefined, + status: w.status, + created_at: w.created_at, + updated_at: w.updated_at, + } as Appointment; + }), + ); set({ appointments, isLoading: false }); } catch (error: any) { set({ @@ -502,11 +673,40 @@ export const useAppointmentStore = create()( }, 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 appt = await apiService.createAppointment(data); + 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, appt], + appointments: [...state.appointments, appointment], isLoading: false, })); } catch (error: any) { @@ -519,11 +719,29 @@ export const useAppointmentStore = create()( }, 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 updated = await apiService.updateAppointment(id, data); + 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 ? updated : a, + a.appointment_id === id + ? { ...a, ...data, status: wire.status, updated_at: wire.updated_at } + : a, ), })); } catch (error: any) { diff --git a/web/normogen-web/src/types/api.ts b/web/normogen-web/src/types/api.ts index 6ba868e..62918ad 100644 --- a/web/normogen-web/src/types/api.ts +++ b/web/normogen-web/src/types/api.ts @@ -110,6 +110,14 @@ export interface PillIdentification { custom_color?: string; } +// ---- Opaque encrypted blob wire types (zero-knowledge) ---- +export interface EncryptedFieldWire { + data: string; + iv: string; + auth_tag?: string; +} + +// ---- Medication: domain type (decrypted, used by UI) ---- export interface Medication { id?: string; medication_id?: string; @@ -129,39 +137,32 @@ export interface Medication { notes?: string; tags?: string[]; active: boolean; - pill_identification?: PillIdentification; created_at?: string; updated_at?: string; } -export interface CreateMedicationRequest { - name: string; - dosage: string; - frequency: string; - route: string; +// Medication wire response (opaque blob from the server) +export interface MedicationWireResponse { + id: string; + medication_id: string; + user_id: string; profile_id: string; - reason?: string; - instructions?: string; - side_effects?: string[]; - prescribed_by?: string; - prescribed_date?: string; - start_date?: string; - end_date?: string; - notes?: string; - tags?: string[]; - reminder_times?: string[]; - pill_identification?: PillIdentification; + active: boolean; + encrypted_data: EncryptedFieldWire; + created_at: string; + updated_at: string; +} + +// Medication create/update requests (send encrypted blob to the server) +export interface CreateMedicationRequest { + profile_id: string; + encrypted_data: EncryptedFieldWire; + active?: boolean; } export interface UpdateMedicationRequest { - name?: string; - dosage?: string; - frequency?: string; - start_date?: string; - end_date?: string; - instructions?: string; + encrypted_data?: EncryptedFieldWire; active?: boolean; - pill_identification?: PillIdentification; } // Drug Interaction Types (Phase 2.8) @@ -244,7 +245,7 @@ export interface LabResult { created_at?: string; } -// Appointment Types — match backend AppointmentResponse (snake_case). +// Appointment: domain type (decrypted, used by UI) export interface Appointment { id?: string; appointment_id: string; @@ -263,31 +264,41 @@ export interface Appointment { updated_at?: string; } -export interface CreateAppointmentRequest { - title: string; - provider: string; - appointment_type: string; - date_time: string; +// Appointment wire response (opaque blob) +export interface AppointmentWireResponse { + id: string; + appointment_id: string; + user_id: string; profile_id: string; - location?: string; - duration_minutes?: number; - reason?: string; - notes?: string; + status: string; + encrypted_data: EncryptedFieldWire; + created_at: string; + updated_at: string; +} + +export interface CreateAppointmentRequest { + profile_id: string; + encrypted_data: EncryptedFieldWire; status?: string; } export interface UpdateAppointmentRequest { - title?: string; - provider?: string; - appointment_type?: string; - date_time?: string; - location?: string; - duration_minutes?: number; - reason?: string; - notes?: string; + encrypted_data?: EncryptedFieldWire; status?: string; } +// Profile wire response (opaque encrypted name) +export interface ProfileWireResponse { + profile_id: string; + user_id: string; + name_data: string; + name_iv: string; + role: string; + permissions: string[]; + created_at: string; + updated_at: string; +} + // Dose Log Types — match backend MedicationDose (camelCase serialization). export interface DoseLog { id?: string;