diff --git a/backend/src/app.ts b/backend/src/app.ts index 0d29160..d538bfb 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -262,6 +262,10 @@ const birdSchema = z.object({ motivators: birdProfileListSchema, demotivators: birdProfileListSchema, favoriteSnack: z.string().trim().max(160).optional().or(z.literal('')), + vetClinicName: z.string().trim().max(160).optional().or(z.literal('')), + vetClinicAddress: z.string().trim().max(500).optional().or(z.literal('')), + vetAccountNumber: z.string().trim().max(120).optional().or(z.literal('')), + vetDoctorName: z.string().trim().max(160).optional().or(z.literal('')), gender: birdGenderSchema.optional(), dateOfBirth: dateStringSchema.optional().or(z.literal('')), gotchaDay: dateStringSchema.optional().or(z.literal('')), @@ -617,6 +621,10 @@ const normalizeBird = (row: BirdRow) => ({ motivators: row.motivators, demotivators: row.demotivators, favoriteSnack: row.favorite_snack, + vetClinicName: row.vet_clinic_name, + vetClinicAddress: row.vet_clinic_address, + vetAccountNumber: row.vet_account_number, + vetDoctorName: row.vet_doctor_name, gender: row.gender, dateOfBirth: row.date_of_birth, gotchaDay: row.gotcha_day, @@ -3118,6 +3126,10 @@ app.post('/api/birds', requireAuth, requireWriteAccess, requireWorkspaceRole(['o motivators: emptyToNull(parsed.data.motivators), demotivators: emptyToNull(parsed.data.demotivators), favoriteSnack: emptyToNull(parsed.data.favoriteSnack), + vetClinicName: emptyToNull(parsed.data.vetClinicName), + vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress), + vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber), + vetDoctorName: emptyToNull(parsed.data.vetDoctorName), gender: (parsed.data.gender ?? 'unknown') as BirdGender, dateOfBirth: emptyToNull(parsed.data.dateOfBirth), gotchaDay: emptyToNull(parsed.data.gotchaDay), @@ -3271,6 +3283,10 @@ app.put('/api/birds/:birdId', requireAuth, requireWriteAccess, requireWorkspaceR motivators: emptyToNull(parsed.data.motivators), demotivators: emptyToNull(parsed.data.demotivators), favoriteSnack: emptyToNull(parsed.data.favoriteSnack), + vetClinicName: emptyToNull(parsed.data.vetClinicName), + vetClinicAddress: emptyToNull(parsed.data.vetClinicAddress), + vetAccountNumber: emptyToNull(parsed.data.vetAccountNumber), + vetDoctorName: emptyToNull(parsed.data.vetDoctorName), gender: (parsed.data.gender ?? 'unknown') as BirdGender, dateOfBirth: emptyToNull(parsed.data.dateOfBirth), gotchaDay: emptyToNull(parsed.data.gotchaDay), diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 8844137..5ee1112 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -215,6 +215,10 @@ export const ensureSchema = async (database: DatabaseClient = db) => { motivators VARCHAR(1000), demotivators VARCHAR(1000), favorite_snack VARCHAR(160), + vet_clinic_name VARCHAR(160), + vet_clinic_address VARCHAR(500), + vet_account_number VARCHAR(120), + vet_doctor_name VARCHAR(160), gender VARCHAR(16) NOT NULL DEFAULT 'unknown', date_of_birth DATE, gotcha_day DATE, @@ -239,6 +243,10 @@ export const ensureSchema = async (database: DatabaseClient = db) => { ADD COLUMN IF NOT EXISTS motivators VARCHAR(1000), ADD COLUMN IF NOT EXISTS demotivators VARCHAR(1000), ADD COLUMN IF NOT EXISTS favorite_snack VARCHAR(160), + ADD COLUMN IF NOT EXISTS vet_clinic_name VARCHAR(160), + ADD COLUMN IF NOT EXISTS vet_clinic_address VARCHAR(500), + ADD COLUMN IF NOT EXISTS vet_account_number VARCHAR(120), + ADD COLUMN IF NOT EXISTS vet_doctor_name VARCHAR(160), ADD COLUMN IF NOT EXISTS gender VARCHAR(16) NOT NULL DEFAULT 'unknown', ADD COLUMN IF NOT EXISTS date_of_birth DATE, ADD COLUMN IF NOT EXISTS gotcha_day DATE, diff --git a/backend/src/repositories/birdRepository.ts b/backend/src/repositories/birdRepository.ts index d194430..357c2df 100644 --- a/backend/src/repositories/birdRepository.ts +++ b/backend/src/repositories/birdRepository.ts @@ -23,6 +23,10 @@ const birdSelectFields = ` birds.motivators, birds.demotivators, birds.favorite_snack, + birds.vet_clinic_name, + birds.vet_clinic_address, + birds.vet_account_number, + birds.vet_doctor_name, birds.gender, birds.date_of_birth::text, birds.gotcha_day::text, @@ -287,6 +291,10 @@ export const createBird = async ({ motivators, demotivators, favoriteSnack, + vetClinicName = null, + vetClinicAddress = null, + vetAccountNumber = null, + vetDoctorName = null, gender, dateOfBirth, gotchaDay, @@ -308,6 +316,10 @@ export const createBird = async ({ motivators: string | null; demotivators: string | null; favoriteSnack: string | null; + vetClinicName?: string | null; + vetClinicAddress?: string | null; + vetAccountNumber?: string | null; + vetDoctorName?: string | null; gender: BirdGender; dateOfBirth: string | null; gotchaDay: string | null; @@ -322,9 +334,9 @@ export const createBird = async ({ publicProfileEnabled?: boolean; }) => { const result = await db.query( - `INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled) - VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) - RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`, + `INSERT INTO birds (id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth, gotcha_day, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled) + VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24) + RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, NULL::text AS latest_weight_grams, NULL::text AS latest_recorded_on`, [ birdId ?? null, workspaceId, @@ -334,6 +346,10 @@ export const createBird = async ({ motivators, demotivators, favoriteSnack, + vetClinicName, + vetClinicAddress, + vetAccountNumber, + vetDoctorName, gender, dateOfBirth, gotchaDay, @@ -361,6 +377,10 @@ export const updateBird = async ({ motivators, demotivators, favoriteSnack, + vetClinicName, + vetClinicAddress, + vetAccountNumber, + vetDoctorName, gender, dateOfBirth, gotchaDay, @@ -382,6 +402,10 @@ export const updateBird = async ({ motivators: string | null; demotivators: string | null; favoriteSnack: string | null; + vetClinicName: string | null; + vetClinicAddress: string | null; + vetAccountNumber: string | null; + vetDoctorName: string | null; gender: BirdGender; dateOfBirth: string | null; gotchaDay: string | null; @@ -403,22 +427,26 @@ export const updateBird = async ({ motivators = $5, demotivators = $6, favorite_snack = $7, - gender = $8, - date_of_birth = $9, - gotcha_day = $10, - chart_color = $11, - photo_data_url = $12, - photo_object_key = $13, - photo_content_type = $14, - photo_updated_at = $15, - notify_on_dob = $16, - notify_on_gotcha_day = $17, - public_profile_code = $18, - public_profile_enabled = $19 + vet_clinic_name = $8, + vet_clinic_address = $9, + vet_account_number = $10, + vet_doctor_name = $11, + gender = $12, + date_of_birth = $13, + gotcha_day = $14, + chart_color = $15, + photo_data_url = $16, + photo_object_key = $17, + photo_content_type = $18, + photo_updated_at = $19, + notify_on_dob = $20, + notify_on_gotcha_day = $21, + public_profile_code = $22, + public_profile_enabled = $23 WHERE id = $1 - AND workspace_id = $20 + AND workspace_id = $24 AND memorialized_at IS NULL - RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, + RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, ( SELECT weight_grams::text FROM weight_records @@ -441,6 +469,10 @@ export const updateBird = async ({ motivators, demotivators, favoriteSnack, + vetClinicName, + vetClinicAddress, + vetAccountNumber, + vetDoctorName, gender, dateOfBirth, gotchaDay, @@ -482,7 +514,7 @@ export const memorializeBird = async ({ WHERE id = $1 AND workspace_id = $2 AND memorialized_at IS NULL - RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, + RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, ( SELECT weight_grams::text FROM weight_records @@ -518,7 +550,7 @@ export const updateMemorialReminderPreference = async ({ WHERE id = $1 AND workspace_id = $2 AND memorialized_at IS NOT NULL - RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, + RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, ( SELECT weight_grams::text FROM weight_records @@ -558,7 +590,7 @@ export const transferBirdToWorkspace = async (birdId: string, sourceWorkspaceId: WHERE id = $1 AND workspace_id = $2 AND memorialized_at IS NULL - RETURNING id, workspace_id, name, tag_id, species, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, + RETURNING id, workspace_id, name, tag_id, species, motivators, demotivators, favorite_snack, vet_clinic_name, vet_clinic_address, vet_account_number, vet_doctor_name, gender, date_of_birth::text, gotcha_day::text, chart_color, photo_data_url, photo_object_key, photo_content_type, photo_updated_at, notify_on_dob, notify_on_gotcha_day, public_profile_code, public_profile_enabled, memorialized_at, memorialized_on::text, memorial_note, notify_on_memorial_day, created_at, ( SELECT weight_grams::text FROM weight_records diff --git a/backend/src/types.ts b/backend/src/types.ts index 2a4d236..689aff4 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -101,6 +101,10 @@ export type BirdRow = { motivators: string | null; demotivators: string | null; favorite_snack: string | null; + vet_clinic_name: string | null; + vet_clinic_address: string | null; + vet_account_number: string | null; + vet_doctor_name: string | null; gender: BirdGender; date_of_birth: string | null; gotcha_day: string | null; diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index a1736a0..18df41d 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -208,6 +208,10 @@ Role requirements are called out per endpoint below. If the signed-in member lac "name": "Kiwi", "tagId": "FP-001", "species": "Cockatiel", + "vetClinicName": "Avian Care Center", + "vetClinicAddress": "123 Feather Lane, Raleigh, NC", + "vetAccountNumber": "FP-1001", + "vetDoctorName": "Dr. Rivera", "gender": "female", "dateOfBirth": "2023-05-10", "gotchaDay": "2023-08-21", @@ -793,6 +797,10 @@ Request body: "name": "Kiwi", "tagId": "FP-001", "species": "Cockatiel", + "vetClinicName": "Avian Care Center", + "vetClinicAddress": "123 Feather Lane, Raleigh, NC", + "vetAccountNumber": "FP-1001", + "vetDoctorName": "Dr. Rivera", "gender": "female", "dateOfBirth": "2023-05-10", "gotchaDay": "2023-08-21", @@ -805,7 +813,7 @@ Request body: Notes: -- `dateOfBirth`, `gotchaDay`, and `photoDataUrl` may be omitted or sent as empty strings +- `dateOfBirth`, `gotchaDay`, `photoDataUrl`, and veterinary info fields may be omitted or sent as empty strings - `chartColor` defaults to `#cb3a35` Response `201`: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e329e07..0b7fb62 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,6 +24,10 @@ type Bird = { motivators: string | null; demotivators: string | null; favoriteSnack: string | null; + vetClinicName: string | null; + vetClinicAddress: string | null; + vetAccountNumber: string | null; + vetDoctorName: string | null; gender: BirdGender; dateOfBirth: string | null; gotchaDay: string | null; @@ -219,6 +223,10 @@ type BirdFormState = { motivators: string; demotivators: string; favoriteSnack: string; + vetClinicName: string; + vetClinicAddress: string; + vetAccountNumber: string; + vetDoctorName: string; gender: BirdGender; dateOfBirth: string; gotchaDay: string; @@ -229,6 +237,8 @@ type BirdFormState = { publicProfileEnabled: boolean; }; +type VeterinaryInfoFormState = Pick; + type BirdImportWeight = { weightGrams: number; recordedOn: string; @@ -627,6 +637,10 @@ const emptyBirdForm: BirdFormState = { motivators: '', demotivators: '', favoriteSnack: '', + vetClinicName: '', + vetClinicAddress: '', + vetAccountNumber: '', + vetDoctorName: '', gender: 'unknown', dateOfBirth: '', gotchaDay: '', @@ -637,6 +651,13 @@ const emptyBirdForm: BirdFormState = { publicProfileEnabled: false, }; +const emptyVeterinaryInfoForm: VeterinaryInfoFormState = { + vetClinicName: '', + vetClinicAddress: '', + vetAccountNumber: '', + vetDoctorName: '', +}; + const emptyMemorializeBirdForm = (): MemorializeBirdFormState => ({ memorializedOn: new Date().toISOString().slice(0, 10), memorialNote: '', @@ -758,6 +779,10 @@ const toBirdForm = (bird: Bird): BirdFormState => ({ motivators: parseBirdProfileList(bird.motivators).join('\n'), demotivators: parseBirdProfileList(bird.demotivators).join('\n'), favoriteSnack: bird.favoriteSnack ?? '', + vetClinicName: bird.vetClinicName ?? '', + vetClinicAddress: bird.vetClinicAddress ?? '', + vetAccountNumber: bird.vetAccountNumber ?? '', + vetDoctorName: bird.vetDoctorName ?? '', gender: bird.gender, dateOfBirth: bird.dateOfBirth ?? '', gotchaDay: bird.gotchaDay ?? '', @@ -768,6 +793,13 @@ const toBirdForm = (bird: Bird): BirdFormState => ({ publicProfileEnabled: bird.publicProfileEnabled, }); +const toVeterinaryInfoForm = (bird: Bird): VeterinaryInfoFormState => ({ + vetClinicName: bird.vetClinicName ?? '', + vetClinicAddress: bird.vetClinicAddress ?? '', + vetAccountNumber: bird.vetAccountNumber ?? '', + vetDoctorName: bird.vetDoctorName ?? '', +}); + const formatDate = (value: string | null) => { if (!value) { return 'Not set'; @@ -1538,6 +1570,7 @@ function App() { reason: '', notes: '', }); + const [veterinaryInfoForm, setVeterinaryInfoForm] = useState(emptyVeterinaryInfoForm); const [medicationForm, setMedicationForm] = useState({ name: '', dosage: '', @@ -1562,6 +1595,8 @@ function App() { const [memorializingBird, setMemorializingBird] = useState(false); const [savingMemorialReminderBirdId, setSavingMemorialReminderBirdId] = useState(''); const [editingVetVisitId, setEditingVetVisitId] = useState(''); + const [editingVeterinaryInfo, setEditingVeterinaryInfo] = useState(false); + const [savingVeterinaryInfo, setSavingVeterinaryInfo] = useState(false); const [deletingVetVisitId, setDeletingVetVisitId] = useState(''); const [editingMedicationId, setEditingMedicationId] = useState(''); const [deletingMedicationId, setDeletingMedicationId] = useState(''); @@ -1602,6 +1637,15 @@ function App() { setSelectedBirdTab('info'); }, [selectedBirdId]); + useEffect(() => { + if (selectedBird) { + setVeterinaryInfoForm(toVeterinaryInfoForm(selectedBird)); + } else { + setVeterinaryInfoForm(emptyVeterinaryInfoForm); + } + setEditingVeterinaryInfo(false); + }, [selectedBird]); + const overviewWindowStartDate = useMemo(() => { const startDate = new Date(); startDate.setHours(0, 0, 0, 0); @@ -2345,6 +2389,7 @@ function App() { setVetVisits([]); setMedications([]); setMedicationAdministrations([]); + setVeterinaryInfoForm(emptyVeterinaryInfoForm); return; } @@ -3190,6 +3235,49 @@ function App() { } }; + const handleVeterinaryInfoSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!selectedBird || savingVeterinaryInfo) { + return; + } + + setError(''); + setSavingVeterinaryInfo(true); + + try { + const response = await apiFetch(`/birds/${selectedBird.id}`, authToken, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...toBirdForm(selectedBird), + ...veterinaryInfoForm, + }), + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to save veterinary info.')); + } + + const data = await readJsonSafely<{ bird?: Bird }>(response); + if (!data?.bird) { + throw new Error('Unable to save veterinary info.'); + } + + const savedBird = data.bird; + setBirds((current) => sortBirdsByName(current.map((bird) => (bird.id === savedBird.id ? savedBird : bird)))); + setVeterinaryInfoForm(toVeterinaryInfoForm(savedBird)); + setEditingVeterinaryInfo(false); + if (editingBirdId === savedBird.id) { + setBirdForm(toBirdForm(savedBird)); + } + } catch (submitError) { + setError(submitError instanceof Error ? submitError.message : 'Unable to save veterinary info.'); + } finally { + setSavingVeterinaryInfo(false); + } + }; + const handleWeightSubmit = async (event: React.FormEvent) => { event.preventDefault(); @@ -5129,6 +5217,43 @@ function App() { placeholder="Optional" /> +
+

Veterinary

+

Clinic account

+
+ + + +