const express = require('express'); const router = express.Router(); const { getDatabase } = require('../db/init'); /** * getAncestorMap(db, dogId, maxGen) * Returns Map * INCLUDES dogId itself at generation 0 so direct parent-offspring * pairings are correctly detected by calculateCOI. */ function getAncestorMap(db, dogId, maxGen = 6) { const map = new Map(); function recurse(id, gen) { if (gen > maxGen) return; const dog = db.prepare('SELECT id, name FROM dogs WHERE id = ?').get(id); if (!dog) return; if (!map.has(id)) map.set(id, []); map.get(id).push({ id: dog.id, name: dog.name, generation: gen }); if (map.get(id).length === 1) { const parents = db.prepare(` SELECT p.parent_id FROM parents p WHERE p.dog_id = ? `).all(id); parents.forEach(p => recurse(p.parent_id, gen + 1)); } } recurse(parseInt(dogId), 0); return map; } /** * isDirectRelation(db, sireId, damId) * Returns { related, relationship } if one dog is a direct ancestor * of the other within 3 generations. */ function isDirectRelation(db, sireId, damId) { const sid = parseInt(sireId); const did = parseInt(damId); const sireMap = getAncestorMap(db, sid, 3); const damMap = getAncestorMap(db, did, 3); if (damMap.has(sid)) { const gen = damMap.get(sid)[0].generation; const label = gen === 1 ? 'parent' : gen === 2 ? 'grandparent' : `generation-${gen} ancestor`; return { related: true, relationship: `Sire is the ${label} of the selected dam` }; } if (sireMap.has(did)) { const gen = sireMap.get(did)[0].generation; const label = gen === 1 ? 'parent' : gen === 2 ? 'grandparent' : `generation-${gen} ancestor`; return { related: true, relationship: `Dam is the ${label} of the selected sire` }; } return { related: false, relationship: null }; } /** * calculateCOI(db, sireId, damId) * Wright Path Coefficient method. * Dogs included at gen 0 in their own maps so parent x offspring * yields ~25% COI. * * Fix: do NOT exclude sid/did from commonIds globally. * - Exclude `did` from sireMap keys (the dam itself can't be a * common ancestor of the sire's side for THIS pairing's offspring) * - Exclude `sid` from damMap keys (same logic for sire) * This preserves the case where the sire IS a common ancestor in the * dam's ancestry (parent x offspring) while still avoiding reflexive * self-loops. */ function calculateCOI(db, sireId, damId) { const sid = parseInt(sireId); const did = parseInt(damId); const sireMap = getAncestorMap(db, sid); const damMap = getAncestorMap(db, did); // Common ancestors: in BOTH maps, but: // - not the dam itself appearing in sireMap (would be a loop) // - not the sire itself appearing in damMap already handled below // We collect all IDs present in both, excluding only the direct // subjects (did from sireMap side, sid excluded already since we // iterate sireMap keys — but sid IS in sireMap at gen 0, and if // damMap also has sid, that is the parent×offspring case we WANT). const commonIds = [...sireMap.keys()].filter( id => damMap.has(id) && id !== did ); let coi = 0; const processedPaths = new Set(); const commonAncestorList = []; commonIds.forEach(ancId => { const sireOccs = sireMap.get(ancId); const damOccs = damMap.get(ancId); sireOccs.forEach(so => { damOccs.forEach(do_ => { const key = `${ancId}-${so.generation}-${do_.generation}`; if (!processedPaths.has(key)) { processedPaths.add(key); coi += Math.pow(0.5, so.generation + do_.generation + 1); } }); }); const closestSire = sireOccs.reduce((a, b) => a.generation < b.generation ? a : b); const closestDam = damOccs.reduce((a, b) => a.generation < b.generation ? a : b); commonAncestorList.push({ id: ancId, name: sireOccs[0].name, sireGen: closestSire.generation, damGen: closestDam.generation }); }); return { coefficient: coi, commonAncestors: commonAncestorList }; } // ===================================================================== // IMPORTANT: Specific named routes MUST be registered BEFORE // the /:id wildcard, or Express will match 'relations' and // 'trial-pairing' as dog IDs and return 404/wrong data. // ===================================================================== // POST /api/pedigree/trial-pairing router.post('/trial-pairing', (req, res) => { try { const { sire_id, dam_id } = req.body; if (!sire_id || !dam_id) { return res.status(400).json({ error: 'Both sire_id and dam_id are required' }); } const db = getDatabase(); const sire = db.prepare("SELECT * FROM dogs WHERE id = ? AND sex = 'male'").get(sire_id); const dam = db.prepare("SELECT * FROM dogs WHERE id = ? AND sex = 'female'").get(dam_id); if (!sire || !dam) { return res.status(404).json({ error: 'Invalid sire or dam \u2014 check sex values in database' }); } const relation = isDirectRelation(db, sire_id, dam_id); const result = calculateCOI(db, sire_id, dam_id); res.json({ sire: { id: sire.id, name: sire.name }, dam: { id: dam.id, name: dam.name }, coi: result.coefficient, commonAncestors: result.commonAncestors, directRelation: relation.related ? relation.relationship : null, recommendation: result.coefficient < 5 ? 'Low risk' : result.coefficient < 10 ? 'Moderate risk' : 'High risk' }); } catch (error) { res.status(500).json({ error: error.message }); } }); // GET /api/pedigree/relations/:sireId/:damId router.get('/relations/:sireId/:damId', (req, res) => { try { const db = getDatabase(); res.json(isDirectRelation(db, req.params.sireId, req.params.damId)); } catch (error) { res.status(500).json({ error: error.message }); } }); // ===================================================================== // Wildcard routes last // ===================================================================== // GET /api/pedigree/:id router.get('/:id', (req, res) => { try { const db = getDatabase(); const generations = parseInt(req.query.generations) || 5; function buildTree(dogId, currentGen = 0) { if (currentGen >= generations) return null; const dog = db.prepare('SELECT * FROM dogs WHERE id = ?').get(dogId); if (!dog) return null; dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : []; const parents = db.prepare('SELECT parent_type, parent_id FROM parents WHERE dog_id = ?').all(dogId); const sire = parents.find(p => p.parent_type === 'sire'); const dam = parents.find(p => p.parent_type === 'dam'); return { ...dog, generation: currentGen, sire: sire ? buildTree(sire.parent_id, currentGen + 1) : null, dam: dam ? buildTree(dam.parent_id, currentGen + 1) : null }; } const tree = buildTree(req.params.id); if (!tree) return res.status(404).json({ error: 'Dog not found' }); res.json(tree); } catch (error) { res.status(500).json({ error: error.message }); } }); // GET /api/pedigree/:id/descendants router.get('/:id/descendants', (req, res) => { try { const db = getDatabase(); const generations = parseInt(req.query.generations) || 3; function buildDescendantTree(dogId, currentGen = 0) { if (currentGen >= generations) return null; const dog = db.prepare('SELECT * FROM dogs WHERE id = ?').get(dogId); if (!dog) return null; dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : []; const offspring = db.prepare(` SELECT DISTINCT d.id, d.name, d.sex, d.birth_date FROM dogs d JOIN parents p ON d.id = p.dog_id WHERE p.parent_id = ? AND d.is_active = 1 `).all(dogId); return { ...dog, generation: currentGen, offspring: offspring.map(c => buildDescendantTree(c.id, currentGen + 1)) }; } const tree = buildDescendantTree(req.params.id); if (!tree) return res.status(404).json({ error: 'Dog not found' }); res.json(tree); } catch (error) { res.status(500).json({ error: error.message }); } }); module.exports = router;