From 263b98d3d8678646a517ea21541ed88fa5033618 Mon Sep 17 00:00:00 2001 From: blaisadmin Date: Sun, 19 Apr 2026 02:30:22 -0400 Subject: [PATCH] medication tracking started --- backend/src/app.ts | 135 +++++++++ backend/src/db/schema.ts | 17 ++ backend/src/repositories/birdRepository.ts | 81 +++++- backend/src/types.ts | 12 + docs/API_REFERENCE.md | 70 +++++ frontend/src/App.tsx | 312 ++++++++++++++++++++- 6 files changed, 624 insertions(+), 3 deletions(-) diff --git a/backend/src/app.ts b/backend/src/app.ts index 102d4c2..842ead5 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -30,18 +30,22 @@ import { import { completePendingBirdTransfersForOwner, createBird, + createMedicationForBird, createPendingBirdTransfer, findBirdsByBandId, createVetVisitForBird, createWeightForBird, deleteBird, + deleteMedicationForBird, deleteVetVisitForBird, getBirdById, listBirds, + listMedicationsForBird, listVetVisitsForBird, listWeightsForBird, transferBirdToWorkspace, updateBird, + updateMedicationForBird, updateVetVisitForBird, } from './repositories/birdRepository.js'; import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js'; @@ -78,6 +82,7 @@ import type { BirdRow, IntegrationTokenRow, LostBirdMatchRow, + MedicationRow, ProviderKey, RescueVerificationStatus, SubscriptionStatus, @@ -218,6 +223,21 @@ const vetVisitSchema = z.object({ notes: z.string().trim().max(1000).optional().or(z.literal('')), }); +const medicationSchema = z + .object({ + name: z.string().trim().min(1).max(160), + dosage: z.string().trim().min(1).max(160), + frequency: z.string().trim().min(1).max(160), + route: z.string().trim().max(80).optional().or(z.literal('')), + startDate: dateStringSchema, + endDate: dateStringSchema.optional().or(z.literal('')), + notes: z.string().trim().max(1000).optional().or(z.literal('')), + }) + .refine((value) => !value.endDate || value.endDate >= value.startDate, { + message: 'End date must be on or after start date.', + path: ['endDate'], + }); + const integrationTokenCreateSchema = z.object({ name: z.string().trim().min(1).max(160), scope: integrationTokenScopeSchema.default('read_write'), @@ -409,6 +429,18 @@ const normalizeVetVisit = (row: VetVisitRow) => ({ notes: row.notes, }); +const normalizeMedication = (row: MedicationRow) => ({ + id: row.id, + birdId: row.bird_id, + name: row.name, + dosage: row.dosage, + frequency: row.frequency, + route: row.route, + startDate: row.start_date, + endDate: row.end_date, + notes: row.notes, +}); + const normalizeIntegrationToken = (row: IntegrationTokenRow) => ({ id: row.id, userId: row.user_id, @@ -2147,6 +2179,109 @@ app.delete('/api/birds/:birdId/vet-visits/:visitId', requireAuth, requireWriteAc } }); +app.get('/api/birds/:birdId/medications', requireAuth, async (req: Request, res: Response, next: NextFunction) => { + try { + const medications = await listMedicationsForBird(req.params.birdId, req.auth!.workspace.id); + res.json({ medications: medications.map(normalizeMedication) }); + } catch (error) { + next(error); + } +}); + +app.post('/api/birds/:birdId/medications', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => { + const parsed = medicationSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ error: 'Invalid medication payload', details: parsed.error.flatten() }); + return; + } + + try { + const bird = await getBirdById(req.params.birdId, req.auth!.workspace.id); + + if (!bird) { + res.status(404).json({ error: 'Bird not found.' }); + return; + } + + const medication = await createMedicationForBird( + req.params.birdId, + parsed.data.name, + parsed.data.dosage, + parsed.data.frequency, + emptyToNull(parsed.data.route), + parsed.data.startDate, + emptyToNull(parsed.data.endDate), + emptyToNull(parsed.data.notes), + ); + + res.status(201).json({ medication: normalizeMedication(medication!) }); + } catch (error) { + next(error); + } +}); + +app.put('/api/birds/:birdId/medications/:medicationId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => { + const parsed = medicationSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ error: 'Invalid medication payload', details: parsed.error.flatten() }); + return; + } + + try { + const bird = await getBirdById(req.params.birdId, req.auth!.workspace.id); + + if (!bird) { + res.status(404).json({ error: 'Bird not found.' }); + return; + } + + const medication = await updateMedicationForBird( + req.params.medicationId, + req.params.birdId, + parsed.data.name, + parsed.data.dosage, + parsed.data.frequency, + emptyToNull(parsed.data.route), + parsed.data.startDate, + emptyToNull(parsed.data.endDate), + emptyToNull(parsed.data.notes), + ); + + if (!medication) { + res.status(404).json({ error: 'Medication not found.' }); + return; + } + + res.json({ medication: normalizeMedication(medication) }); + } catch (error) { + next(error); + } +}); + +app.delete('/api/birds/:birdId/medications/:medicationId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant', 'caregiver']), async (req: Request, res: Response, next: NextFunction) => { + try { + const bird = await getBirdById(req.params.birdId, req.auth!.workspace.id); + + if (!bird) { + res.status(404).json({ error: 'Bird not found.' }); + return; + } + + const deleted = await deleteMedicationForBird(req.params.medicationId, req.params.birdId); + + if (!deleted) { + res.status(404).json({ error: 'Medication not found.' }); + return; + } + + res.status(204).send(); + } 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 4a5becd..4ec918a 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -281,12 +281,29 @@ export const ensureSchema = async (database: DatabaseClient = db) => { created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); + CREATE TABLE IF NOT EXISTS medications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE, + name VARCHAR(160) NOT NULL, + dosage VARCHAR(160) NOT NULL, + frequency VARCHAR(160) NOT NULL, + route VARCHAR(80), + start_date DATE NOT NULL, + end_date DATE, + notes VARCHAR(1000), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (end_date IS NULL OR end_date >= start_date) + ); + CREATE INDEX IF NOT EXISTS idx_weight_records_bird_recorded_on ON weight_records (bird_id, recorded_on DESC); CREATE INDEX IF NOT EXISTS idx_vet_visits_bird_visited_on ON vet_visits (bird_id, visited_on DESC); + CREATE INDEX IF NOT EXISTS idx_medications_bird_start_date + ON medications (bird_id, start_date DESC); + DO $$ BEGIN IF EXISTS ( diff --git a/backend/src/repositories/birdRepository.ts b/backend/src/repositories/birdRepository.ts index c35a485..f9e122f 100644 --- a/backend/src/repositories/birdRepository.ts +++ b/backend/src/repositories/birdRepository.ts @@ -1,5 +1,5 @@ import { db } from '../db/client.js'; -import type { BirdGender, BirdRow, LostBirdMatchRow, PendingBirdTransferRow, VetVisitRow, WeightRow } from '../types.js'; +import type { BirdGender, BirdRow, LostBirdMatchRow, MedicationRow, PendingBirdTransferRow, VetVisitRow, WeightRow } from '../types.js'; const birdSelectFields = ` birds.id, @@ -403,3 +403,82 @@ export const deleteVetVisitForBird = async (visitId: string, birdId: string) => return (result.rowCount ?? 0) > 0; }; + +export const listMedicationsForBird = async (birdId: string, workspaceId: number) => { + const result = await db.query( + `SELECT id, bird_id, name, dosage, frequency, route, start_date::text, end_date::text, notes + FROM medications + WHERE bird_id = $1 + AND EXISTS ( + SELECT 1 + FROM birds + WHERE birds.id = medications.bird_id + AND birds.workspace_id = $2 + ) + ORDER BY COALESCE(end_date, '9999-12-31'::date) DESC, start_date DESC, created_at DESC`, + [birdId, workspaceId], + ); + + return result.rows; +}; + +export const createMedicationForBird = async ( + birdId: string, + name: string, + dosage: string, + frequency: string, + route: string | null, + startDate: string, + endDate: string | null, + notes: string | null, +) => { + const result = await db.query( + `INSERT INTO medications (bird_id, name, dosage, frequency, route, start_date, end_date, notes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, bird_id, name, dosage, frequency, route, start_date::text, end_date::text, notes`, + [birdId, name, dosage, frequency, route, startDate, endDate, notes], + ); + + return result.rows[0] ?? null; +}; + +export const updateMedicationForBird = async ( + medicationId: string, + birdId: string, + name: string, + dosage: string, + frequency: string, + route: string | null, + startDate: string, + endDate: string | null, + notes: string | null, +) => { + const result = await db.query( + `UPDATE medications + SET name = $3, + dosage = $4, + frequency = $5, + route = $6, + start_date = $7, + end_date = $8, + notes = $9 + WHERE id = $1 + AND bird_id = $2 + RETURNING id, bird_id, name, dosage, frequency, route, start_date::text, end_date::text, notes`, + [medicationId, birdId, name, dosage, frequency, route, startDate, endDate, notes], + ); + + return result.rows[0] ?? null; +}; + +export const deleteMedicationForBird = async (medicationId: string, birdId: string) => { + const result = await db.query<{ id: string }>( + `DELETE FROM medications + WHERE id = $1 + AND bird_id = $2 + RETURNING id`, + [medicationId, birdId], + ); + + return (result.rowCount ?? 0) > 0; +}; diff --git a/backend/src/types.ts b/backend/src/types.ts index 02f3926..77c9de0 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -144,6 +144,18 @@ export type VetVisitRow = { notes: string | null; }; +export type MedicationRow = { + id: string; + bird_id: string; + name: string; + dosage: string; + frequency: string; + route: string | null; + start_date: string; + end_date: string | null; + notes: string | null; +}; + export type AuthContext = { user: UserRow; session: AuthSessionRow; diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 015914c..b34db35 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -75,6 +75,7 @@ Endpoints that accept either browser session tokens or integration tokens: - `/api/birds` - `/api/birds/:birdId/weights` - `/api/birds/:birdId/vet-visits` +- `/api/birds/:birdId/medications` Read-only integration tokens can call read endpoints, but cannot call write endpoints. @@ -245,6 +246,22 @@ Role requirements are called out per endpoint below. If the signed-in member lac } ``` +### Medication + +```json +{ + "id": "uuid", + "birdId": "uuid", + "name": "Meloxicam", + "dosage": "0.05 mL", + "frequency": "Every 12 hours", + "route": "Oral", + "startDate": "2026-04-14", + "endDate": null, + "notes": "Give with food" +} +``` + ## Common Validation Rules - Dates use `YYYY-MM-DD` @@ -885,6 +902,59 @@ Possible errors: - `404` if the bird does not exist in the active workspace +### Medications + +#### `GET /api/birds/:birdId/medications` + +Requires auth. Lists medication records for a bird in the active workspace. + +Response `200`: + +```json +{ + "medications": [] +} +``` + +#### `POST /api/birds/:birdId/medications` + +Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Creates a medication record. + +Request body: + +```json +{ + "name": "Meloxicam", + "dosage": "0.05 mL", + "frequency": "Every 12 hours", + "route": "Oral", + "startDate": "2026-04-14", + "endDate": "", + "notes": "Give with food" +} +``` + +Response `201`: + +```json +{ + "medication": {} +} +``` + +#### `PUT /api/birds/:birdId/medications/:medicationId` + +Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Updates a medication record. + +#### `DELETE /api/birds/:birdId/medications/:medicationId` + +Requires auth with write access and role `owner`, `assistant`, or `caregiver`. Deletes a medication record. + +Possible errors: + +- `400` if `endDate` is before `startDate` +- `404` if the bird or medication does not exist in the active workspace + ### Integration Tokens These endpoints are for browser-session users managing their own automation tokens. They are not accessible with an integration token itself. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index adef3d3..4b03040 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -47,6 +47,18 @@ type VetVisit = { notes: string | null; }; +type Medication = { + id: string; + birdId: string; + name: string; + dosage: string; + frequency: string; + route: string | null; + startDate: string; + endDate: string | null; + notes: string | null; +}; + type Workspace = { id: number; name: string; @@ -968,6 +980,7 @@ function App() { const [editingBirdId, setEditingBirdId] = useState(''); const [weights, setWeights] = useState([]); const [vetVisits, setVetVisits] = useState([]); + const [medications, setMedications] = useState([]); const [allBirdWeights, setAllBirdWeights] = useState>({}); const [allBirdVetVisits, setAllBirdVetVisits] = useState>({}); const [dismissedAlerts, setDismissedAlerts] = useState({}); @@ -1011,6 +1024,15 @@ function App() { reason: '', notes: '', }); + const [medicationForm, setMedicationForm] = useState({ + name: '', + dosage: '', + frequency: '', + route: '', + startDate: new Date().toISOString().slice(0, 10), + endDate: '', + notes: '', + }); const [flockTransferForm, setFlockTransferForm] = useState({ birdId: '', destinationOwnerEmail: '', @@ -1024,6 +1046,8 @@ function App() { const [deletingBird, setDeletingBird] = useState(false); const [editingVetVisitId, setEditingVetVisitId] = useState(''); const [deletingVetVisitId, setDeletingVetVisitId] = useState(''); + const [editingMedicationId, setEditingMedicationId] = useState(''); + const [deletingMedicationId, setDeletingMedicationId] = useState(''); const [removingWorkspaceMemberId, setRemovingWorkspaceMemberId] = useState(''); const [expandedSettingsSection, setExpandedSettingsSection] = useState(null); @@ -1219,6 +1243,15 @@ function App() { const vetVisitDueOverflowCount = Math.max(vetVisitDueBirds.length - 3, 0); const vetVisitDueBirdIds = useMemo(() => new Set(vetVisitDueBirds.map((bird) => bird.id)), [vetVisitDueBirds]); + const activeMedications = useMemo( + () => medications.filter((medication) => !medication.endDate || parseDateValue(medication.endDate) >= parseDateValue(new Date().toISOString().slice(0, 10))), + [medications], + ); + const pastMedications = useMemo( + () => medications.filter((medication) => medication.endDate && parseDateValue(medication.endDate) < parseDateValue(new Date().toISOString().slice(0, 10))), + [medications], + ); + const filteredSpeciesOptions = useMemo(() => { const query = birdForm.species.trim().toLowerCase(); @@ -1457,6 +1490,7 @@ function App() { setBirds([]); setWeights([]); setVetVisits([]); + setMedications([]); setAllBirdWeights({}); setAllBirdVetVisits({}); setSelectedBirdId(''); @@ -1624,22 +1658,25 @@ function App() { if (!selectedBird?.id) { setWeights([]); setVetVisits([]); + setMedications([]); return; } const loadBirdDetail = async () => { try { - const [weightsResponse, visitsResponse] = await Promise.all([ + const [weightsResponse, visitsResponse, medicationsResponse] = 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), ]); - if (!weightsResponse.ok || !visitsResponse.ok) { + if (!weightsResponse.ok || !visitsResponse.ok || !medicationsResponse.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)) ?? {}; setWeights(weightsData.weights ?? []); const nextVetVisits = visitsData.vetVisits ?? []; @@ -1648,8 +1685,11 @@ function App() { ...current, [selectedBird.id]: nextVetVisits, })); + setMedications(medicationsData.medications ?? []); setEditingVetVisitId(''); setDeletingVetVisitId(''); + setEditingMedicationId(''); + setDeletingMedicationId(''); } catch (loadError) { setError(loadError instanceof Error ? loadError.message : 'Unable to load flock member details.'); } @@ -1873,6 +1913,7 @@ function App() { setEditingBirdId(''); setWeights([]); setVetVisits([]); + setMedications([]); setAllBirdVetVisits({}); setActivePage('overview'); } catch (switchError) { @@ -2477,6 +2518,116 @@ function App() { } }; + const handleMedicationSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!selectedBird) { + return; + } + + setError(''); + + try { + const isEditingMedication = Boolean(editingMedicationId); + const response = await apiFetch( + isEditingMedication ? `/birds/${selectedBird.id}/medications/${editingMedicationId}` : `/birds/${selectedBird.id}/medications`, + authToken, + { + method: isEditingMedication ? 'PUT' : 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(medicationForm), + }, + ); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, `Unable to ${isEditingMedication ? 'update' : 'save'} medication.`)); + } + + const data = await readJsonSafely<{ medication: Medication }>(response); + if (!data?.medication) { + throw new Error(`Unable to ${isEditingMedication ? 'update' : 'save'} medication.`); + } + + setMedications((current) => + (isEditingMedication + ? current.map((medication) => (medication.id === data.medication.id ? data.medication : medication)) + : [data.medication, ...current] + ).sort((left, right) => { + const leftEnd = left.endDate ?? '9999-12-31'; + const rightEnd = right.endDate ?? '9999-12-31'; + return rightEnd.localeCompare(leftEnd) || right.startDate.localeCompare(left.startDate); + }), + ); + setMedicationForm({ + name: '', + dosage: '', + frequency: '', + route: '', + startDate: new Date().toISOString().slice(0, 10), + endDate: '', + notes: '', + }); + setEditingMedicationId(''); + } catch (submitError) { + setError(submitError instanceof Error ? submitError.message : 'Unable to save medication.'); + } + }; + + const handleEditMedication = (medication: Medication) => { + setEditingMedicationId(medication.id); + setMedicationForm({ + name: medication.name, + dosage: medication.dosage, + frequency: medication.frequency, + route: medication.route ?? '', + startDate: medication.startDate, + endDate: medication.endDate ?? '', + notes: medication.notes ?? '', + }); + setError(''); + }; + + const handleCancelMedicationEdit = () => { + setEditingMedicationId(''); + setMedicationForm({ + name: '', + dosage: '', + frequency: '', + route: '', + startDate: new Date().toISOString().slice(0, 10), + endDate: '', + notes: '', + }); + }; + + const handleDeleteMedication = async (medicationId: string) => { + if (!selectedBird || deletingMedicationId) { + return; + } + + setDeletingMedicationId(medicationId); + setError(''); + + try { + const response = await apiFetch(`/birds/${selectedBird.id}/medications/${medicationId}`, authToken, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to remove medication.')); + } + + setMedications((current) => current.filter((medication) => medication.id !== medicationId)); + if (editingMedicationId === medicationId) { + handleCancelMedicationEdit(); + } + } catch (removeError) { + setError(removeError instanceof Error ? removeError.message : 'Unable to remove medication.'); + } finally { + setDeletingMedicationId(''); + } + }; + const handleRemoveBird = async () => { if (!selectedBird || deletingBird) { return; @@ -2517,8 +2668,11 @@ function App() { setSelectedBirdId(''); setWeights([]); setVetVisits([]); + setMedications([]); setEditingVetVisitId(''); setDeletingVetVisitId(''); + setEditingMedicationId(''); + setDeletingMedicationId(''); if (editingBirdId === selectedBird.id) { setEditingBirdId(''); @@ -2591,6 +2745,7 @@ function App() { }); setWeights((current) => (selectedBird?.id === flockTransferForm.birdId ? [] : current)); setVetVisits((current) => (selectedBird?.id === flockTransferForm.birdId ? [] : current)); + setMedications((current) => (selectedBird?.id === flockTransferForm.birdId ? [] : current)); if (selectedBird?.id === flockTransferForm.birdId) { setSelectedBirdId(''); } @@ -3820,6 +3975,159 @@ function App() { +
+
+
+

Medication

+

Per-bird medication log

+
+
+ +
+ + + + + + +