From 872b6c8663e117bb2c4e1220d0ab595e0b229e5d Mon Sep 17 00:00:00 2001 From: Corey Blais Date: Sun, 19 Apr 2026 13:20:02 -0400 Subject: [PATCH] Finished medication tracking and UI enhancements --- backend/src/app.ts | 59 ++ backend/src/db/schema.ts | 15 + backend/src/repositories/birdRepository.ts | 61 +- backend/src/types.ts | 11 + frontend/src/App.tsx | 639 +++++++++++++-------- frontend/src/index.css | 57 +- 6 files changed, 589 insertions(+), 253 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index 842ead5..1b9a9d3 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -30,6 +30,7 @@ import { import { completePendingBirdTransfersForOwner, createBird, + upsertMedicationAdministrationForBird, createMedicationForBird, createPendingBirdTransfer, findBirdsByBandId, @@ -40,6 +41,7 @@ import { deleteVetVisitForBird, getBirdById, listBirds, + listMedicationAdministrationsForBird, listMedicationsForBird, listVetVisitsForBird, listWeightsForBird, @@ -83,6 +85,7 @@ import type { IntegrationTokenRow, LostBirdMatchRow, MedicationRow, + MedicationAdministrationRow, ProviderKey, RescueVerificationStatus, SubscriptionStatus, @@ -238,6 +241,12 @@ const medicationSchema = z path: ['endDate'], }); +const medicationAdministrationSchema = z.object({ + administeredOn: dateStringSchema, + status: z.enum(['administered', 'missed']), + notes: z.string().trim().max(500).optional().or(z.literal('')), +}); + const integrationTokenCreateSchema = z.object({ name: z.string().trim().min(1).max(160), scope: integrationTokenScopeSchema.default('read_write'), @@ -441,6 +450,17 @@ const normalizeMedication = (row: MedicationRow) => ({ notes: row.notes, }); +const normalizeMedicationAdministration = (row: MedicationAdministrationRow) => ({ + id: row.id, + medicationId: row.medication_id, + birdId: row.bird_id, + administeredOn: row.administered_on, + status: row.status, + notes: row.notes, + createdByUserId: row.created_by_user_id, + createdAt: row.created_at, +}); + const normalizeIntegrationToken = (row: IntegrationTokenRow) => ({ id: row.id, userId: row.user_id, @@ -2282,6 +2302,45 @@ app.delete('/api/birds/:birdId/medications/:medicationId', requireAuth, requireW } }); +app.get('/api/birds/:birdId/medication-administrations', requireAuth, async (req: Request, res: Response, next: NextFunction) => { + try { + const administrations = await listMedicationAdministrationsForBird(req.params.birdId, req.auth!.workspace.id); + res.json({ administrations: administrations.map(normalizeMedicationAdministration) }); + } catch (error) { + next(error); + } +}); + +app.post('/api/birds/:birdId/medications/:medicationId/administrations', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => { + const parsed = medicationAdministrationSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ error: 'Invalid medication administration payload', details: parsed.error.flatten() }); + return; + } + + try { + const administration = await upsertMedicationAdministrationForBird( + req.params.medicationId, + req.params.birdId, + req.auth!.workspace.id, + parsed.data.administeredOn, + parsed.data.status, + emptyToNull(parsed.data.notes), + req.auth!.user.id, + ); + + if (!administration) { + res.status(404).json({ error: 'Medication not found.' }); + return; + } + + res.status(201).json({ administration: normalizeMedicationAdministration(administration) }); + } catch (error) { + next(error); + } +}); + app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => { console.error(error); res.status(500).json({ error: error instanceof Error ? error.message : 'Internal server error' }); diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 4ec918a..833b531 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -295,6 +295,18 @@ export const ensureSchema = async (database: DatabaseClient = db) => { CHECK (end_date IS NULL OR end_date >= start_date) ); + CREATE TABLE IF NOT EXISTS medication_administrations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + medication_id UUID NOT NULL REFERENCES medications(id) ON DELETE CASCADE, + bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE, + administered_on DATE NOT NULL, + status VARCHAR(20) NOT NULL CHECK (status IN ('administered', 'missed')), + notes VARCHAR(500), + created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (medication_id, administered_on) + ); + CREATE INDEX IF NOT EXISTS idx_weight_records_bird_recorded_on ON weight_records (bird_id, recorded_on DESC); @@ -304,6 +316,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => { CREATE INDEX IF NOT EXISTS idx_medications_bird_start_date ON medications (bird_id, start_date DESC); + CREATE INDEX IF NOT EXISTS idx_medication_administrations_bird_administered_on + ON medication_administrations (bird_id, administered_on DESC); + DO $$ BEGIN IF EXISTS ( diff --git a/backend/src/repositories/birdRepository.ts b/backend/src/repositories/birdRepository.ts index f9e122f..d2b86e1 100644 --- a/backend/src/repositories/birdRepository.ts +++ b/backend/src/repositories/birdRepository.ts @@ -1,5 +1,14 @@ import { db } from '../db/client.js'; -import type { BirdGender, BirdRow, LostBirdMatchRow, MedicationRow, PendingBirdTransferRow, VetVisitRow, WeightRow } from '../types.js'; +import type { + BirdGender, + BirdRow, + LostBirdMatchRow, + MedicationAdministrationRow, + MedicationRow, + PendingBirdTransferRow, + VetVisitRow, + WeightRow, +} from '../types.js'; const birdSelectFields = ` birds.id, @@ -482,3 +491,53 @@ export const deleteMedicationForBird = async (medicationId: string, birdId: stri return (result.rowCount ?? 0) > 0; }; + +export const listMedicationAdministrationsForBird = async (birdId: string, workspaceId: number) => { + const result = await db.query( + `SELECT id, medication_id, bird_id, administered_on::text, status, notes, created_by_user_id, created_at + FROM medication_administrations + WHERE bird_id = $1 + AND EXISTS ( + SELECT 1 + FROM birds + WHERE birds.id = medication_administrations.bird_id + AND birds.workspace_id = $2 + ) + ORDER BY administered_on DESC, created_at DESC`, + [birdId, workspaceId], + ); + + return result.rows; +}; + +export const upsertMedicationAdministrationForBird = async ( + medicationId: string, + birdId: string, + workspaceId: number, + administeredOn: string, + status: 'administered' | 'missed', + notes: string | null, + createdByUserId: string | null, +) => { + const result = await db.query( + `INSERT INTO medication_administrations (medication_id, bird_id, administered_on, status, notes, created_by_user_id) + SELECT $1, $2, $4, $5, $6, $7 + WHERE EXISTS ( + SELECT 1 + FROM medications + JOIN birds ON birds.id = medications.bird_id + WHERE medications.id = $1 + AND medications.bird_id = $2 + AND birds.workspace_id = $3 + ) + ON CONFLICT (medication_id, administered_on) + DO UPDATE SET status = EXCLUDED.status, + notes = EXCLUDED.notes, + created_by_user_id = EXCLUDED.created_by_user_id, + created_at = CURRENT_TIMESTAMP + RETURNING id, medication_id, bird_id, administered_on::text, status, notes, created_by_user_id, created_at`, + [medicationId, birdId, workspaceId, administeredOn, status, notes, createdByUserId], + ); + + return result.rows[0] ?? null; +}; diff --git a/backend/src/types.ts b/backend/src/types.ts index 77c9de0..b4996f5 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -156,6 +156,17 @@ export type MedicationRow = { notes: string | null; }; +export type MedicationAdministrationRow = { + id: string; + medication_id: string; + bird_id: string; + administered_on: string; + status: 'administered' | 'missed'; + notes: string | null; + created_by_user_id: string | null; + created_at: string; +}; + export type AuthContext = { user: UserRow; session: AuthSessionRow; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4b03040..767e2dd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -59,6 +59,16 @@ type Medication = { notes: string | null; }; +type MedicationAdministration = { + id: string; + medicationId: string; + birdId: string; + administeredOn: string; + status: 'administered' | 'missed'; + notes: string | null; + createdAt: string; +}; + type Workspace = { id: number; name: string; @@ -981,6 +991,7 @@ function App() { const [weights, setWeights] = useState([]); const [vetVisits, setVetVisits] = useState([]); const [medications, setMedications] = useState([]); + const [medicationAdministrations, setMedicationAdministrations] = useState([]); const [allBirdWeights, setAllBirdWeights] = useState>({}); const [allBirdVetVisits, setAllBirdVetVisits] = useState>({}); const [dismissedAlerts, setDismissedAlerts] = useState({}); @@ -1048,6 +1059,7 @@ function App() { const [deletingVetVisitId, setDeletingVetVisitId] = useState(''); const [editingMedicationId, setEditingMedicationId] = useState(''); const [deletingMedicationId, setDeletingMedicationId] = useState(''); + const [savingMedicationAdministrationId, setSavingMedicationAdministrationId] = useState(''); const [removingWorkspaceMemberId, setRemovingWorkspaceMemberId] = useState(''); const [expandedSettingsSection, setExpandedSettingsSection] = useState(null); @@ -1491,6 +1503,7 @@ function App() { setWeights([]); setVetVisits([]); setMedications([]); + setMedicationAdministrations([]); setAllBirdWeights({}); setAllBirdVetVisits({}); setSelectedBirdId(''); @@ -1659,24 +1672,28 @@ function App() { setWeights([]); setVetVisits([]); setMedications([]); + setMedicationAdministrations([]); return; } const loadBirdDetail = async () => { try { - const [weightsResponse, visitsResponse, medicationsResponse] = await Promise.all([ + const [weightsResponse, visitsResponse, medicationsResponse, medicationAdministrationsResponse] = await Promise.all([ apiFetch(`/birds/${selectedBird.id}/weights?days=${OVERVIEW_HISTORY_DAYS}`, authToken), apiFetch(`/birds/${selectedBird.id}/vet-visits`, authToken), apiFetch(`/birds/${selectedBird.id}/medications`, authToken), + apiFetch(`/birds/${selectedBird.id}/medication-administrations`, authToken), ]); - if (!weightsResponse.ok || !visitsResponse.ok || !medicationsResponse.ok) { + if (!weightsResponse.ok || !visitsResponse.ok || !medicationsResponse.ok || !medicationAdministrationsResponse.ok) { throw new Error('Unable to load flock member details.'); } const weightsData = (await readJsonSafely<{ weights?: WeightRecord[] }>(weightsResponse)) ?? {}; const visitsData = (await readJsonSafely<{ vetVisits?: VetVisit[] }>(visitsResponse)) ?? {}; const medicationsData = (await readJsonSafely<{ medications?: Medication[] }>(medicationsResponse)) ?? {}; + const medicationAdministrationsData = + (await readJsonSafely<{ administrations?: MedicationAdministration[] }>(medicationAdministrationsResponse)) ?? {}; setWeights(weightsData.weights ?? []); const nextVetVisits = visitsData.vetVisits ?? []; @@ -1686,10 +1703,12 @@ function App() { [selectedBird.id]: nextVetVisits, })); setMedications(medicationsData.medications ?? []); + setMedicationAdministrations(medicationAdministrationsData.administrations ?? []); setEditingVetVisitId(''); setDeletingVetVisitId(''); setEditingMedicationId(''); setDeletingMedicationId(''); + setSavingMedicationAdministrationId(''); } catch (loadError) { setError(loadError instanceof Error ? loadError.message : 'Unable to load flock member details.'); } @@ -1914,6 +1933,7 @@ function App() { setWeights([]); setVetVisits([]); setMedications([]); + setMedicationAdministrations([]); setAllBirdVetVisits({}); setActivePage('overview'); } catch (switchError) { @@ -2618,6 +2638,7 @@ function App() { } setMedications((current) => current.filter((medication) => medication.id !== medicationId)); + setMedicationAdministrations((current) => current.filter((administration) => administration.medicationId !== medicationId)); if (editingMedicationId === medicationId) { handleCancelMedicationEdit(); } @@ -2628,6 +2649,49 @@ function App() { } }; + const handleMedicationAdministrationSubmit = async (medicationId: string, status: MedicationAdministration['status']) => { + if (!selectedBird || savingMedicationAdministrationId) { + return; + } + + setSavingMedicationAdministrationId(`${medicationId}-${status}`); + setError(''); + + try { + const administeredOn = new Date().toISOString().slice(0, 10); + const response = await apiFetch(`/birds/${selectedBird.id}/medications/${medicationId}/administrations`, authToken, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ administeredOn, status }), + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to update medication administration.')); + } + + const data = await readJsonSafely<{ administration: MedicationAdministration }>(response); + if (!data?.administration) { + throw new Error('Unable to update medication administration.'); + } + + setMedicationAdministrations((current) => + [data.administration, ...current.filter((administration) => administration.id !== data.administration.id)] + .filter( + (administration, index, all) => + all.findIndex( + (candidate) => + candidate.medicationId === administration.medicationId && candidate.administeredOn === administration.administeredOn, + ) === index, + ) + .sort((left, right) => right.administeredOn.localeCompare(left.administeredOn) || right.createdAt.localeCompare(left.createdAt)), + ); + } catch (submitError) { + setError(submitError instanceof Error ? submitError.message : 'Unable to update medication administration.'); + } finally { + setSavingMedicationAdministrationId(''); + } + }; + const handleRemoveBird = async () => { if (!selectedBird || deletingBird) { return; @@ -2669,10 +2733,12 @@ function App() { setWeights([]); setVetVisits([]); setMedications([]); + setMedicationAdministrations([]); setEditingVetVisitId(''); setDeletingVetVisitId(''); setEditingMedicationId(''); setDeletingMedicationId(''); + setSavingMedicationAdministrationId(''); if (editingBirdId === selectedBird.id) { setEditingBirdId(''); @@ -3226,6 +3292,93 @@ function App() { } const showWorkspaceSwitcher = authSession.workspaces.length > 1; + const todayDate = new Date().toISOString().slice(0, 10); + const renderMedicationCard = (medication: Medication, options: { showActions?: boolean; showAdministrationControls?: boolean }) => { + const latestAdministration = medicationAdministrations.find((administration) => administration.medicationId === medication.id); + const todayAdministration = medicationAdministrations.find( + (administration) => administration.medicationId === medication.id && administration.administeredOn === todayDate, + ); + const givenActionId = `${medication.id}-administered`; + const missedActionId = `${medication.id}-missed`; + + return ( +
+ {medication.name} + + {medication.dosage} • {medication.frequency} + {medication.route ? ` • ${medication.route}` : ''} + + + {formatDate(medication.startDate)} to {formatDate(medication.endDate)} + + {medication.notes || 'No notes recorded.'} + {latestAdministration ? ( + + Last update: {latestAdministration.status === 'administered' ? 'Given' : 'Missed'} on {formatShortDate(latestAdministration.administeredOn)} + + ) : null} + {options.showAdministrationControls ? ( +
+ + Today:{' '} + {todayAdministration + ? `${todayAdministration.status === 'administered' ? 'Given' : 'Missed'} on ${formatShortDate(todayAdministration.administeredOn)}` + : 'Not updated yet'} + +
+ + +
+
+ ) : null} + {options.showActions ? ( +
+ + {editingMedicationId === medication.id ? ( + + ) : null} +
+ ) : null} +
+ ); + }; + const renderMedicationList = (options: { showActions?: boolean; showAdministrationControls?: boolean }) => + medications.length ? ( + <> + {activeMedications.length ? Active medication : null} + {activeMedications.map((medication) => renderMedicationCard(medication, options))} + {pastMedications.length ? Past medication : null} + {pastMedications.map((medication) => renderMedicationCard(medication, options))} + + ) : ( +
+ No medication configured yet + Add medication here to make it visible on the flock member care page. +
+ ); return (
@@ -3975,158 +4128,17 @@ function App() { -
-
-
-

Medication

-

Per-bird medication log

+ {medications.length ? ( +
+
+
+

Medication

+

Medication schedule

+
-
- -
- - - - - - -