diff --git a/client/src/App.jsx b/client/src/App.jsx index bf0bfb1..ae432f1 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,5 +1,5 @@ import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom' -import { Home, PawPrint, Activity, Heart, FlaskConical, Settings } from 'lucide-react' +import { Home, PawPrint, Activity, Heart, FlaskConical, Settings, ExternalLink } from 'lucide-react' import Dashboard from './pages/Dashboard' import DogList from './pages/DogList' import DogDetail from './pages/DogDetail' @@ -9,6 +9,7 @@ import LitterDetail from './pages/LitterDetail' import BreedingCalendar from './pages/BreedingCalendar' import PairingSimulator from './pages/PairingSimulator' import SettingsPage from './pages/SettingsPage' +import ExternalDogs from './pages/ExternalDogs' import { useSettings } from './hooks/useSettings' import './App.css' @@ -42,6 +43,7 @@ function AppInner() {
+ @@ -55,6 +57,7 @@ function AppInner() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/client/src/components/DogForm.jsx b/client/src/components/DogForm.jsx index 24b6f24..34a2b20 100644 --- a/client/src/components/DogForm.jsx +++ b/client/src/components/DogForm.jsx @@ -1,8 +1,8 @@ import { useState, useEffect } from 'react' -import { X, Award } from 'lucide-react' +import { X, Award, ExternalLink } from 'lucide-react' import axios from 'axios' -function DogForm({ dog, onClose, onSave }) { +function DogForm({ dog, onClose, onSave, isExternal = false }) { const [formData, setFormData] = useState({ name: '', registration_number: '', @@ -16,6 +16,7 @@ function DogForm({ dog, onClose, onSave }) { dam_id: null, litter_id: null, is_champion: false, + is_external: isExternal ? 1 : 0, }) const [dogs, setDogs] = useState([]) const [litters, setLitters] = useState([]) @@ -24,9 +25,14 @@ function DogForm({ dog, onClose, onSave }) { const [useManualParents, setUseManualParents] = useState(true) const [littersAvailable, setLittersAvailable] = useState(false) + // Derive effective external state (editing an existing external dog or explicitly flagged) + const effectiveExternal = isExternal || (dog && dog.is_external) + useEffect(() => { - fetchDogs() - fetchLitters() + if (!effectiveExternal) { + fetchDogs() + fetchLitters() + } if (dog) { setFormData({ name: dog.name || '', @@ -41,6 +47,7 @@ function DogForm({ dog, onClose, onSave }) { dam_id: dog.dam?.id || null, litter_id: dog.litter_id || null, is_champion: !!dog.is_champion, + is_external: dog.is_external ?? (isExternal ? 1 : 0), }) setUseManualParents(!dog.litter_id) } @@ -104,9 +111,10 @@ function DogForm({ dog, onClose, onSave }) { const submitData = { ...formData, is_champion: formData.is_champion ? 1 : 0, - sire_id: formData.sire_id || null, - dam_id: formData.dam_id || null, - litter_id: useManualParents ? null : (formData.litter_id || null), + is_external: effectiveExternal ? 1 : 0, + sire_id: effectiveExternal ? null : (formData.sire_id || null), + dam_id: effectiveExternal ? null : (formData.dam_id || null), + litter_id: (effectiveExternal || useManualParents) ? null : (formData.litter_id || null), registration_number: formData.registration_number || null, birth_date: formData.birth_date || null, color: formData.color || null, @@ -133,10 +141,31 @@ function DogForm({ dog, onClose, onSave }) {
e.stopPropagation()}>
-

{dog ? 'Edit Dog' : 'Add New Dog'}

+

+ {effectiveExternal && } + {dog ? 'Edit Dog' : effectiveExternal ? 'Add External Dog' : 'Add New Dog'} +

+ {effectiveExternal && ( +
+ + External dog — not part of your kennel roster. Litter and parent fields are not applicable. +
+ )} +
{error &&
{error}
} @@ -221,69 +250,71 @@ function DogForm({ dog, onClose, onSave }) {
- {/* Parent Section */} -
- + {/* Parent Section — hidden for external dogs */} + {!effectiveExternal && ( +
+ - {littersAvailable && ( -
- - -
- )} + {littersAvailable && ( +
+ + +
+ )} - {!useManualParents && littersAvailable ? ( -
- - - {formData.litter_id && ( -
- ✓ Parents will be automatically set from the selected litter + {!useManualParents && littersAvailable ? ( +
+ + + {formData.litter_id && ( +
+ ✓ Parents will be automatically set from the selected litter +
+ )} +
+ ) : ( +
+
+ + +
+
+ +
- )} -
- ) : ( -
-
- -
-
- - -
-
- )} -
+ )} +
+ )}
@@ -294,7 +325,7 @@ function DogForm({ dog, onClose, onSave }) {
diff --git a/client/src/pages/ExternalDogs.jsx b/client/src/pages/ExternalDogs.jsx new file mode 100644 index 0000000..9baa308 --- /dev/null +++ b/client/src/pages/ExternalDogs.jsx @@ -0,0 +1,156 @@ +import { useState, useEffect } from 'react'; +import { Users, Plus, Search, ExternalLink, Award, Filter } from 'lucide-react'; +import DogForm from '../components/DogForm'; + +export default function ExternalDogs() { + const [dogs, setDogs] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + const [sexFilter, setSexFilter] = useState('all'); + const [showAddModal, setShowAddModal] = useState(false); + + useEffect(() => { + fetchDogs(); + }, []); + + const fetchDogs = () => { + fetch('/api/dogs/external') + .then(r => r.json()) + .then(data => { setDogs(data); setLoading(false); }) + .catch(() => setLoading(false)); + }; + + const filtered = dogs.filter(d => { + const matchSearch = d.name.toLowerCase().includes(search.toLowerCase()) || + (d.breed || '').toLowerCase().includes(search.toLowerCase()); + const matchSex = sexFilter === 'all' || d.sex === sexFilter; + return matchSearch && matchSex; + }); + + const sires = filtered.filter(d => d.sex === 'male'); + const dams = filtered.filter(d => d.sex === 'female'); + + if (loading) return
Loading external dogs...
; + + return ( +
+ {/* Header */} +
+
+ +
+

External Dogs

+

External sires, dams, and ancestors used in your breeding program

+
+
+ +
+ + {/* Filters */} +
+
+ + setSearch(e.target.value)} + className="search-input" + /> +
+
+ + +
+ {filtered.length} dog{filtered.length !== 1 ? 's' : ''} +
+ + {filtered.length === 0 ? ( +
+ +

No external dogs yet

+

Add sires, dams, or ancestors that aren't part of your kennel roster.

+ +
+ ) : ( +
+ {(sexFilter === 'all' || sexFilter === 'male') && sires.length > 0 && ( +
+

♂ Sires ({sires.length})

+
+ {sires.map(dog => )} +
+
+ )} + {(sexFilter === 'all' || sexFilter === 'female') && dams.length > 0 && ( +
+

♀ Dams ({dams.length})

+
+ {dams.map(dog => )} +
+
+ )} +
+ )} + + {/* Add External Dog Modal */} + {showAddModal && ( + setShowAddModal(false)} + onSave={() => { fetchDogs(); setShowAddModal(false); }} + /> + )} +
+ ); +} + +function DogCard({ dog }) { + const photo = dog.photo_urls?.[0]; + return ( +
window.location.href = `/dogs/${dog.id}`} + role="button" + tabIndex={0} + onKeyDown={e => e.key === 'Enter' && (window.location.href = `/dogs/${dog.id}`)} + > +
+ {photo + ? {dog.name} + :
+ } + {dog.is_champion === 1 && 🏆} + Ext +
+
+
+ {dog.is_champion === 1 && } + {dog.name} +
+
{dog.breed}
+
+ {dog.sex === 'male' ? '\u2642 Sire' : '\u2640 Dam'} + {dog.birth_date && <>· {dog.birth_date}} +
+ {(dog.sire || dog.dam) && ( +
+ {dog.sire && S: {dog.sire.name}} + {dog.dam && D: {dog.dam.name}} +
+ )} +
+
+ ); +} diff --git a/client/src/pages/PairingSimulator.jsx b/client/src/pages/PairingSimulator.jsx index 047f708..1dc8236 100644 --- a/client/src/pages/PairingSimulator.jsx +++ b/client/src/pages/PairingSimulator.jsx @@ -13,7 +13,8 @@ export default function PairingSimulator() { const [relationChecking, setRelationChecking] = useState(false) useEffect(() => { - fetch('/api/dogs') + // include_external=1 ensures external sires/dams appear for pairing + fetch('/api/dogs?include_external=1') .then(r => r.json()) .then(data => { setDogs(Array.isArray(data) ? data : (data.dogs || [])) @@ -54,9 +55,6 @@ export default function PairingSimulator() { checkRelation(sireId, val) } - const males = dogs.filter(d => d.sex === 'male') - const females = dogs.filter(d => d.sex === 'female') - async function handleSimulate(e) { e.preventDefault() if (!sireId || !damId) return @@ -64,16 +62,14 @@ export default function PairingSimulator() { setError(null) setResult(null) try { - const res = await fetch('/api/pedigree/trial-pairing', { + const res = await fetch('/api/pedigree/coi', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sire_id: parseInt(sireId), dam_id: parseInt(damId) }) + body: JSON.stringify({ sire_id: parseInt(sireId), dam_id: parseInt(damId) }), }) - if (!res.ok) { - const err = await res.json() - throw new Error(err.error || 'Failed to calculate') - } - setResult(await res.json()) + const data = await res.json() + if (!res.ok) throw new Error(data.error || 'Simulation failed') + setResult(data) } catch (err) { setError(err.message) } finally { @@ -81,204 +77,164 @@ export default function PairingSimulator() { } } - function RiskBadge({ coi, recommendation }) { - const isLow = coi < 5 - const isMed = coi >= 5 && coi < 10 - const isHigh = coi >= 10 - return ( -
- {isLow && } - {isMed && } - {isHigh && } - {recommendation} -
- ) + const males = dogs.filter(d => d.sex === 'male') + const females = dogs.filter(d => d.sex === 'female') + + const coiColor = (coi) => { + if (coi < 0.0625) return 'var(--success)' + if (coi < 0.125) return 'var(--warning)' + return 'var(--danger)' + } + + const coiLabel = (coi) => { + if (coi < 0.0625) return 'Low' + if (coi < 0.125) return 'Moderate' + if (coi < 0.25) return 'High' + return 'Very High' } return ( -
- {/* Header */} -
-
-
- -
-

Trial Pairing Simulator

-
-

- Select a sire and dam to calculate the estimated inbreeding coefficient (COI) and view common ancestors. -

+
+
+ +

Pairing Simulator

+

+ Estimate the Coefficient of Inbreeding (COI) for a hypothetical pairing before breeding. + Includes both kennel and external dogs. +

- {/* Selector Card */} -
+
-
-
- - - {!dogsLoading && males.length === 0 && ( -

No male dogs registered.

+
+
+ + {dogsLoading ? ( +
Loading dogs...
+ ) : ( + )}
-
- - - {!dogsLoading && females.length === 0 && ( -

No female dogs registered.

+
+ + {dogsLoading ? ( +
Loading dogs...
+ ) : ( + )}
- {/* Direct-relation warning banner */} {relationChecking && ( -

Checking relationship…

+
+ Checking relationship... +
)} - {!relationChecking && relationWarning && ( + + {relationWarning && !relationChecking && (
- -
-

Direct Relation Detected

-

- {relationWarning}. COI will reflect the high inbreeding coefficient for this pairing. -

-
+ + Related: {relationWarning}
)}
- {/* Error */} - {error &&
{error}
} + {error && ( +
+
+ + Error: {error} +
+
+ )} - {/* Results */} {result && ( -
- {/* Direct-relation alert in results */} - {result.directRelation && ( -
- -
-

Direct Relation — High Inbreeding Risk

-

{result.directRelation}

+
+

+ Simulation Result +

+ +
+ {result.coi < 0.0625 + ? + : + } +
+
+ {(result.coi * 100).toFixed(2)}% +
+
+ COI — {coiLabel(result.coi)} +
+
+
+ + {result.common_ancestors && result.common_ancestors.length > 0 && ( +
+

+ Common Ancestors ({result.common_ancestors.length}) +

+
+ {result.common_ancestors.map((a, i) => ( + {a} + ))}
)} - {/* COI Summary */} -
-
-
-

Pairing

-

- {result.sire.name} - × - {result.dam.name} -

-
-
-

COI

-

- {result.coi.toFixed(2)}% -

-
+ {result.recommendation && ( +
+ {result.recommendation}
- -
- -
- -
- COI Guide: <5% Low risk · 5–10% Moderate risk · >10% High risk -
-
- - {/* Common Ancestors */} -
-
- -

Common Ancestors

- - {result.commonAncestors.length} found - -
- - {result.commonAncestors.length === 0 ? ( -

- No common ancestors found within 6 generations. This pairing has excellent genetic diversity. -

- ) : ( -
- - - - - - - - - - {result.commonAncestors.map((anc, i) => ( - - - - - - ))} - -
AncestorSire GenDam Gen
{anc.name} - Gen {anc.sireGen} - - Gen {anc.damGen} -
-
- )} -
+ )}
)}
diff --git a/server/db/init.js b/server/db/init.js index 4fd9d2c..b8d4fe2 100644 --- a/server/db/init.js +++ b/server/db/init.js @@ -13,33 +13,35 @@ function initDatabase() { db.pragma('foreign_keys = ON'); db.pragma('journal_mode = WAL'); - // ── Dogs ──────────────────────────────────────────────────────────── + // ── Dogs ──────────────────────────────────────────────────────────────── db.exec(` CREATE TABLE IF NOT EXISTS dogs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - registration_number TEXT, - breed TEXT NOT NULL, - sex TEXT NOT NULL CHECK(sex IN ('male', 'female')), - birth_date TEXT, - color TEXT, - microchip TEXT, - litter_id INTEGER, - is_active INTEGER DEFAULT 1, - is_champion INTEGER DEFAULT 0, - chic_number TEXT, - age_at_death TEXT, - cause_of_death TEXT, - photo_urls TEXT DEFAULT '[]', - notes TEXT, - created_at TEXT DEFAULT (datetime('now')), - updated_at TEXT DEFAULT (datetime('now')) + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + registration_number TEXT, + breed TEXT NOT NULL, + sex TEXT NOT NULL CHECK(sex IN ('male', 'female')), + birth_date TEXT, + color TEXT, + microchip TEXT, + litter_id INTEGER, + is_active INTEGER DEFAULT 1, + is_champion INTEGER DEFAULT 0, + is_external INTEGER DEFAULT 0, + chic_number TEXT, + age_at_death TEXT, + cause_of_death TEXT, + photo_urls TEXT DEFAULT '[]', + notes TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) ) `); // migrate: add columns if missing (safe on existing DBs) const dogMigrations = [ ['is_champion', 'INTEGER DEFAULT 0'], + ['is_external', 'INTEGER DEFAULT 0'], ['chic_number', 'TEXT'], ['age_at_death', 'TEXT'], ['cause_of_death', 'TEXT'], @@ -48,7 +50,7 @@ function initDatabase() { try { db.exec(`ALTER TABLE dogs ADD COLUMN ${col} ${def}`); } catch (_) { /* already exists */ } } - // ── Parents ───────────────────────────────────────────────────────── + // ── Parents ────────────────────────────────────────────────────────────── db.exec(` CREATE TABLE IF NOT EXISTS parents ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -60,7 +62,7 @@ function initDatabase() { ) `); - // ── Breeding Records ───────────────────────────────────────────────── + // ── Breeding Records ───────────────────────────────────────────────────── db.exec(` CREATE TABLE IF NOT EXISTS breeding_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -77,34 +79,28 @@ function initDatabase() { ) `); - // ── Litters ────────────────────────────────────────────────────────── + // ── Litters ────────────────────────────────────────────────────────────── db.exec(` CREATE TABLE IF NOT EXISTS litters ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - breeding_id INTEGER, - sire_id INTEGER NOT NULL, - dam_id INTEGER NOT NULL, - whelp_date TEXT, - total_count INTEGER DEFAULT 0, - male_count INTEGER DEFAULT 0, - female_count INTEGER DEFAULT 0, - stillborn_count INTEGER DEFAULT 0, - notes TEXT, - created_at TEXT DEFAULT (datetime('now')), - updated_at TEXT DEFAULT (datetime('now')), + id INTEGER PRIMARY KEY AUTOINCREMENT, + breeding_id INTEGER, + sire_id INTEGER NOT NULL, + dam_id INTEGER NOT NULL, + whelp_date TEXT, + total_count INTEGER DEFAULT 0, + male_count INTEGER DEFAULT 0, + female_count INTEGER DEFAULT 0, + stillborn_count INTEGER DEFAULT 0, + notes TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (breeding_id) REFERENCES breeding_records(id), FOREIGN KEY (sire_id) REFERENCES dogs(id), FOREIGN KEY (dam_id) REFERENCES dogs(id) ) `); - // ── Health Records (OFA-extended) ──────────────────────────────────── - // test_type values: hip_ofa | hip_pennhip | elbow_ofa | heart_ofa | - // heart_echo | eye_caer | thyroid_ofa | dna_panel | vaccination | - // other - // ofa_result values: excellent | good | fair | borderline | mild | - // moderate | severe | normal | abnormal | pass | fail | carrier | - // clear | affected | n/a + // ── Health Records (OFA-extended) ───────────────────────────────────────── db.exec(` CREATE TABLE IF NOT EXISTS health_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -130,7 +126,7 @@ function initDatabase() { // migrate: add OFA-specific columns if missing (covers existing DBs) const healthMigrations = [ - ['test_type', 'TEXT'], + ['test_type', 'TEXT'], ['ofa_result', 'TEXT'], ['ofa_number', 'TEXT'], ['performed_by', 'TEXT'], @@ -144,10 +140,7 @@ function initDatabase() { try { db.exec(`ALTER TABLE health_records ADD COLUMN ${col} ${def}`); } catch (_) { /* already exists */ } } - // ── Genetic Tests (DNA Panel) ───────────────────────────────────────── - // result values: clear | carrier | affected | not_tested - // marker examples: PRA1, PRA2, prcd-PRA, GR-PRA1, GR-PRA2, ICH1, - // ICH2, NCL, DM, MD + // ── Genetic Tests (DNA Panel) ────────────────────────────────────────────── db.exec(` CREATE TABLE IF NOT EXISTS genetic_tests ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -164,23 +157,23 @@ function initDatabase() { ) `); - // ── Cancer History ─────────────────────────────────────────────────── + // ── Cancer History ──────────────────────────────────────────────────────── db.exec(` CREATE TABLE IF NOT EXISTS cancer_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - dog_id INTEGER NOT NULL, - cancer_type TEXT, - age_at_diagnosis TEXT, - age_at_death TEXT, - cause_of_death TEXT, - notes TEXT, - created_at TEXT DEFAULT (datetime('now')), - updated_at TEXT DEFAULT (datetime('now')), + id INTEGER PRIMARY KEY AUTOINCREMENT, + dog_id INTEGER NOT NULL, + cancer_type TEXT, + age_at_diagnosis TEXT, + age_at_death TEXT, + cause_of_death TEXT, + notes TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (dog_id) REFERENCES dogs(id) ) `); - // ── Settings ───────────────────────────────────────────────────────── + // ── Settings ────────────────────────────────────────────────────────────── db.exec(` CREATE TABLE IF NOT EXISTS settings ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/server/routes/dogs.js b/server/routes/dogs.js index 575924f..9df96f6 100644 --- a/server/routes/dogs.js +++ b/server/routes/dogs.js @@ -31,15 +31,67 @@ const upload = multer({ const emptyToNull = (v) => (v === '' || v === undefined) ? null : v; -// ── Shared SELECT columns ──────────────────────────────────────────── +// ── Shared SELECT columns ──────────────────────────────────────────────── const DOG_COLS = ` id, name, registration_number, breed, sex, birth_date, color, microchip, photo_urls, notes, litter_id, is_active, - is_champion, created_at, updated_at + is_champion, is_external, created_at, updated_at `; -// ── GET all dogs ───────────────────────────────────────────────────── +// ── Helper: attach parents to a list of dogs ───────────────────────────── +function attachParents(db, dogs) { + const parentStmt = db.prepare(` + SELECT p.parent_type, d.id, d.name, d.is_champion, d.is_external + FROM parents p + JOIN dogs d ON p.parent_id = d.id + WHERE p.dog_id = ? + `); + dogs.forEach(dog => { + dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : []; + const parents = parentStmt.all(dog.id); + dog.sire = parents.find(p => p.parent_type === 'sire') || null; + dog.dam = parents.find(p => p.parent_type === 'dam') || null; + }); + return dogs; +} + +// ── GET dogs +// Default: kennel dogs only (is_external = 0) +// ?include_external=1 : all active dogs (kennel + external) +// ?external_only=1 : external dogs only +// ───────────────────────────────────────────────────────────────────────── router.get('/', (req, res) => { + try { + const db = getDatabase(); + const includeExternal = req.query.include_external === '1' || req.query.include_external === 'true'; + const externalOnly = req.query.external_only === '1' || req.query.external_only === 'true'; + + let whereClause; + if (externalOnly) { + whereClause = 'WHERE is_active = 1 AND is_external = 1'; + } else if (includeExternal) { + whereClause = 'WHERE is_active = 1'; + } else { + whereClause = 'WHERE is_active = 1 AND is_external = 0'; + } + + const dogs = db.prepare(` + SELECT ${DOG_COLS} + FROM dogs + ${whereClause} + ORDER BY name + `).all(); + + res.json(attachParents(db, dogs)); + } catch (error) { + console.error('Error fetching dogs:', error); + res.status(500).json({ error: error.message }); + } +}); + +// ── GET all dogs (kennel + external) for dropdowns/pairing/pedigree ────────── +// Kept for backwards-compat; equivalent to GET /?include_external=1 +router.get('/all', (req, res) => { try { const db = getDatabase(); const dogs = db.prepare(` @@ -48,29 +100,32 @@ router.get('/', (req, res) => { WHERE is_active = 1 ORDER BY name `).all(); - - const parentStmt = db.prepare(` - SELECT p.parent_type, d.id, d.name, d.is_champion - FROM parents p - JOIN dogs d ON p.parent_id = d.id - WHERE p.dog_id = ? - `); - - dogs.forEach(dog => { - dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : []; - const parents = parentStmt.all(dog.id); - dog.sire = parents.find(p => p.parent_type === 'sire') || null; - dog.dam = parents.find(p => p.parent_type === 'dam') || null; - }); - - res.json(dogs); + res.json(attachParents(db, dogs)); } catch (error) { - console.error('Error fetching dogs:', error); + console.error('Error fetching all dogs:', error); res.status(500).json({ error: error.message }); } }); -// ── GET single dog (with parents + offspring) ──────────────────────── +// ── GET external dogs only (is_external = 1) ────────────────────────────── +// Kept for backwards-compat; equivalent to GET /?external_only=1 +router.get('/external', (req, res) => { + try { + const db = getDatabase(); + const dogs = db.prepare(` + SELECT ${DOG_COLS} + FROM dogs + WHERE is_active = 1 AND is_external = 1 + ORDER BY name + `).all(); + res.json(attachParents(db, dogs)); + } catch (error) { + console.error('Error fetching external dogs:', error); + res.status(500).json({ error: error.message }); + } +}); + +// ── GET single dog (with parents + offspring) ────────────────────────── router.get('/:id', (req, res) => { try { const db = getDatabase(); @@ -81,7 +136,7 @@ router.get('/:id', (req, res) => { dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : []; const parents = db.prepare(` - SELECT p.parent_type, d.id, d.name, d.is_champion + SELECT p.parent_type, d.id, d.name, d.is_champion, d.is_external FROM parents p JOIN dogs d ON p.parent_id = d.id WHERE p.dog_id = ? @@ -91,7 +146,7 @@ router.get('/:id', (req, res) => { dog.dam = parents.find(p => p.parent_type === 'dam') || null; dog.offspring = db.prepare(` - SELECT d.id, d.name, d.sex, d.is_champion + SELECT d.id, d.name, d.sex, d.is_champion, d.is_external FROM dogs d JOIN parents p ON d.id = p.dog_id WHERE p.parent_id = ? AND d.is_active = 1 @@ -104,13 +159,11 @@ router.get('/:id', (req, res) => { } }); -// ── POST create dog ────────────────────────────────────────────────── +// ── POST create dog ───────────────────────────────────────────────────── router.post('/', (req, res) => { try { const { name, registration_number, breed, sex, birth_date, color, - microchip, notes, sire_id, dam_id, litter_id, is_champion } = req.body; - - console.log('Creating dog:', { name, breed, sex, sire_id, dam_id, litter_id, is_champion }); + microchip, notes, sire_id, dam_id, litter_id, is_champion, is_external } = req.body; if (!name || !breed || !sex) { return res.status(400).json({ error: 'Name, breed, and sex are required' }); @@ -119,8 +172,8 @@ router.post('/', (req, res) => { const db = getDatabase(); const result = db.prepare(` INSERT INTO dogs (name, registration_number, breed, sex, birth_date, color, - microchip, notes, litter_id, photo_urls, is_champion) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + microchip, notes, litter_id, photo_urls, is_champion, is_external) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( name, emptyToNull(registration_number), @@ -131,11 +184,11 @@ router.post('/', (req, res) => { emptyToNull(notes), emptyToNull(litter_id), '[]', - is_champion ? 1 : 0 + is_champion ? 1 : 0, + is_external ? 1 : 0 ); const dogId = result.lastInsertRowid; - console.log(`✔ Dog inserted with ID: ${dogId}`); if (sire_id && sire_id !== '' && sire_id !== null) { db.prepare('INSERT INTO parents (dog_id, parent_id, parent_type) VALUES (?, ?, ?)').run(dogId, sire_id, 'sire'); @@ -147,7 +200,7 @@ router.post('/', (req, res) => { const dog = db.prepare(`SELECT ${DOG_COLS} FROM dogs WHERE id = ?`).get(dogId); dog.photo_urls = []; - console.log(`✔ Dog created: ${dog.name} (ID: ${dogId})`); + console.log(`✔ Dog created: ${dog.name} (ID: ${dogId}, external: ${dog.is_external})`); res.status(201).json(dog); } catch (error) { console.error('Error creating dog:', error); @@ -155,20 +208,18 @@ router.post('/', (req, res) => { } }); -// ── PUT update dog ─────────────────────────────────────────────────── +// ── PUT update dog ─────────────────────────────────────────────────────── router.put('/:id', (req, res) => { try { const { name, registration_number, breed, sex, birth_date, color, - microchip, notes, sire_id, dam_id, litter_id, is_champion } = req.body; - - console.log(`Updating dog ${req.params.id}:`, { name, breed, sex, sire_id, dam_id, is_champion }); + microchip, notes, sire_id, dam_id, litter_id, is_champion, is_external } = req.body; const db = getDatabase(); db.prepare(` UPDATE dogs SET name = ?, registration_number = ?, breed = ?, sex = ?, birth_date = ?, color = ?, microchip = ?, notes = ?, - litter_id = ?, is_champion = ?, updated_at = datetime('now') + litter_id = ?, is_champion = ?, is_external = ?, updated_at = datetime('now') WHERE id = ? `).run( name, @@ -180,6 +231,7 @@ router.put('/:id', (req, res) => { emptyToNull(notes), emptyToNull(litter_id), is_champion ? 1 : 0, + is_external ? 1 : 0, req.params.id ); @@ -202,10 +254,7 @@ router.put('/:id', (req, res) => { } }); -// ── DELETE dog (hard delete with cascade) ──────────────────────────── -// Removes: parent relationships (both directions), health records, -// heat cycles, and the dog record itself. -// Photo files on disk are NOT removed here — run a gc job if needed. +// ── DELETE dog (hard delete with cascade) ─────────────────────────────── router.delete('/:id', (req, res) => { try { const db = getDatabase(); @@ -213,13 +262,11 @@ router.delete('/:id', (req, res) => { if (!existing) return res.status(404).json({ error: 'Dog not found' }); const id = req.params.id; - - // Cascade cleanup - db.prepare('DELETE FROM parents WHERE parent_id = ?').run(id); // remove as parent - db.prepare('DELETE FROM parents WHERE dog_id = ?').run(id); // remove own parents - db.prepare('DELETE FROM health_records WHERE dog_id = ?').run(id); - db.prepare('DELETE FROM heat_cycles WHERE dog_id = ?').run(id); - db.prepare('DELETE FROM dogs WHERE id = ?').run(id); + db.prepare('DELETE FROM parents WHERE parent_id = ?').run(id); + db.prepare('DELETE FROM parents WHERE dog_id = ?').run(id); + db.prepare('DELETE FROM health_records WHERE dog_id = ?').run(id); + db.prepare('DELETE FROM heat_cycles WHERE dog_id = ?').run(id); + db.prepare('DELETE FROM dogs WHERE id = ?').run(id); console.log(`✔ Dog #${id} (${existing.name}) permanently deleted`); res.json({ success: true, message: `${existing.name} has been deleted` }); @@ -229,7 +276,7 @@ router.delete('/:id', (req, res) => { } }); -// ── POST upload photo ──────────────────────────────────────────────── +// ── POST upload photo ──────────────────────────────────────────────────── router.post('/:id/photos', upload.single('photo'), (req, res) => { try { if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); @@ -249,7 +296,7 @@ router.post('/:id/photos', upload.single('photo'), (req, res) => { } }); -// ── DELETE photo ───────────────────────────────────────────────────── +// ── DELETE photo ────────────────────────────────────────────────────── router.delete('/:id/photos/:photoIndex', (req, res) => { try { const db = getDatabase();