2026-03-08 22:45:07 -05:00
|
|
|
|
const express = require('express');
|
|
|
|
|
|
const router = express.Router();
|
|
|
|
|
|
const { getDatabase } = require('../db/init');
|
|
|
|
|
|
|
2026-03-11 23:48:35 -05:00
|
|
|
|
const MAX_CACHE_SIZE = 1000;
|
|
|
|
|
|
const ancestorCache = new Map();
|
|
|
|
|
|
const coiCache = new Map();
|
|
|
|
|
|
|
|
|
|
|
|
function getFromCache(cache, key, computeFn) {
|
|
|
|
|
|
if (cache.has(key)) {
|
|
|
|
|
|
const val = cache.get(key);
|
|
|
|
|
|
cache.delete(key);
|
|
|
|
|
|
cache.set(key, val);
|
|
|
|
|
|
return val;
|
|
|
|
|
|
}
|
|
|
|
|
|
const val = computeFn();
|
|
|
|
|
|
if (cache.size >= MAX_CACHE_SIZE) {
|
|
|
|
|
|
cache.delete(cache.keys().next().value);
|
|
|
|
|
|
}
|
|
|
|
|
|
cache.set(key, val);
|
|
|
|
|
|
return val;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 14:44:27 -05:00
|
|
|
|
/**
|
2026-03-10 14:54:59 -05:00
|
|
|
|
* getAncestorMap(db, dogId, maxGen)
|
|
|
|
|
|
* Returns Map<id, [{ id, name, generation }, ...]>
|
|
|
|
|
|
* INCLUDES dogId itself at generation 0 so direct parent-offspring
|
|
|
|
|
|
* pairings are correctly detected by calculateCOI.
|
2026-03-10 14:44:27 -05:00
|
|
|
|
*/
|
2026-03-10 14:54:59 -05:00
|
|
|
|
function getAncestorMap(db, dogId, maxGen = 6) {
|
2026-03-11 23:48:35 -05:00
|
|
|
|
const cacheKey = `${dogId}-${maxGen}`;
|
|
|
|
|
|
return getFromCache(ancestorCache, cacheKey, () => {
|
|
|
|
|
|
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));
|
|
|
|
|
|
}
|
2026-03-08 22:45:07 -05:00
|
|
|
|
}
|
2026-03-10 14:44:27 -05:00
|
|
|
|
|
2026-03-11 23:48:35 -05:00
|
|
|
|
recurse(parseInt(dogId), 0);
|
|
|
|
|
|
return map;
|
|
|
|
|
|
});
|
2026-03-10 14:54:59 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* isDirectRelation(db, sireId, damId)
|
2026-03-10 15:01:22 -05:00
|
|
|
|
* Returns { related, relationship } if one dog is a direct ancestor
|
|
|
|
|
|
* of the other within 3 generations.
|
2026-03-10 14:54:59 -05:00
|
|
|
|
*/
|
|
|
|
|
|
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` };
|
2026-03-10 14:44:27 -05:00
|
|
|
|
}
|
2026-03-10 14:54:59 -05:00
|
|
|
|
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 };
|
|
|
|
|
|
}
|
2026-03-10 14:44:27 -05:00
|
|
|
|
|
2026-03-10 14:54:59 -05:00
|
|
|
|
/**
|
|
|
|
|
|
* calculateCOI(db, sireId, damId)
|
|
|
|
|
|
* Wright Path Coefficient method.
|
2026-03-10 15:01:22 -05:00
|
|
|
|
* Dogs included at gen 0 in their own maps so parent x offspring
|
2026-03-10 15:08:33 -05:00
|
|
|
|
* 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.
|
2026-03-10 14:54:59 -05:00
|
|
|
|
*/
|
|
|
|
|
|
function calculateCOI(db, sireId, damId) {
|
2026-03-11 23:48:35 -05:00
|
|
|
|
const cacheKey = `${sireId}-${damId}`;
|
|
|
|
|
|
return getFromCache(coiCache, cacheKey, () => {
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-03-10 14:44:27 -05:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-11 23:48:35 -05:00
|
|
|
|
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
|
|
|
|
|
|
});
|
2026-03-10 14:44:27 -05:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-11 23:48:35 -05:00
|
|
|
|
return {
|
|
|
|
|
|
coefficient: coi,
|
|
|
|
|
|
commonAncestors: commonAncestorList
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
2026-03-08 22:45:07 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 15:08:33 -05:00
|
|
|
|
// =====================================================================
|
2026-03-10 15:01:22 -05:00
|
|
|
|
// 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.
|
2026-03-10 15:08:33 -05:00
|
|
|
|
// =====================================================================
|
2026-03-10 15:01:22 -05:00
|
|
|
|
|
2026-03-11 14:48:59 -05:00
|
|
|
|
const handleTrialPairing = (req, res) => {
|
2026-03-10 15:01:22 -05:00
|
|
|
|
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) {
|
2026-03-10 15:08:33 -05:00
|
|
|
|
return res.status(404).json({ error: 'Invalid sire or dam \u2014 check sex values in database' });
|
2026-03-10 15:01:22 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-03-11 13:07:04 -05:00
|
|
|
|
recommendation: result.coefficient < 0.05 ? 'Low risk'
|
|
|
|
|
|
: result.coefficient < 0.10 ? 'Moderate risk'
|
2026-03-10 15:01:22 -05:00
|
|
|
|
: 'High risk'
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
res.status(500).json({ error: error.message });
|
|
|
|
|
|
}
|
2026-03-11 14:48:59 -05:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// POST /api/pedigree/trial-pairing
|
|
|
|
|
|
router.post('/trial-pairing', handleTrialPairing);
|
|
|
|
|
|
|
|
|
|
|
|
// POST /api/pedigree/coi
|
|
|
|
|
|
router.post('/coi', handleTrialPairing);
|
2026-03-10 15:01:22 -05:00
|
|
|
|
|
2026-03-11 13:07:04 -05:00
|
|
|
|
// GET /api/pedigree/:id/coi
|
|
|
|
|
|
router.get('/:id/coi', (req, res) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const db = getDatabase();
|
|
|
|
|
|
const parents = db.prepare('SELECT parent_type, parent_id FROM parents WHERE dog_id = ?').all(req.params.id);
|
|
|
|
|
|
const sire = parents.find(p => p.parent_type === 'sire');
|
|
|
|
|
|
const dam = parents.find(p => p.parent_type === 'dam');
|
|
|
|
|
|
|
|
|
|
|
|
if (!sire || !dam) {
|
|
|
|
|
|
return res.json({ coi: 0, commonAncestors: [], message: 'Incomplete parent data' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const result = calculateCOI(db, sire.parent_id, dam.parent_id);
|
|
|
|
|
|
res.json({
|
|
|
|
|
|
coi: result.coefficient,
|
|
|
|
|
|
commonAncestors: result.commonAncestors
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
res.status(500).json({ error: error.message });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-10 15:01:22 -05:00
|
|
|
|
// 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 });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-11 23:48:35 -05:00
|
|
|
|
// GET /api/pedigree/:id/cancer-lineage
|
|
|
|
|
|
router.get('/:id/cancer-lineage', (req, res) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const db = getDatabase();
|
|
|
|
|
|
// Get ancestor map up to 5 generations
|
|
|
|
|
|
const ancestorMap = getAncestorMap(db, req.params.id, 5);
|
|
|
|
|
|
|
|
|
|
|
|
// Collect all unique ancestor IDs
|
|
|
|
|
|
const ancestorIds = Array.from(ancestorMap.keys());
|
|
|
|
|
|
|
|
|
|
|
|
if (ancestorIds.length === 0) {
|
|
|
|
|
|
return res.json({ lineage_cases: [], stats: { total_ancestors: 0, ancestors_with_cancer: 0 } });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Query cancer history for all ancestors
|
|
|
|
|
|
const placeholders = ancestorIds.map(() => '?').join(',');
|
|
|
|
|
|
const cancerRecords = db.prepare(`
|
|
|
|
|
|
SELECT c.*, d.name, d.sex
|
|
|
|
|
|
FROM cancer_history c
|
|
|
|
|
|
JOIN dogs d ON c.dog_id = d.id
|
|
|
|
|
|
WHERE c.dog_id IN (${placeholders})
|
|
|
|
|
|
`).all(...ancestorIds);
|
|
|
|
|
|
|
|
|
|
|
|
// Structure the response
|
|
|
|
|
|
const cases = cancerRecords.map(record => {
|
|
|
|
|
|
// Find the closest generation this ancestor appears in
|
|
|
|
|
|
const occurrences = ancestorMap.get(record.dog_id);
|
|
|
|
|
|
const closestGen = occurrences.reduce((min, occ) => occ.generation < min ? occ.generation : min, 999);
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
...record,
|
|
|
|
|
|
generation_distance: closestGen
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Sort by generation distance (closer relatives first)
|
|
|
|
|
|
cases.sort((a, b) => a.generation_distance - b.generation_distance);
|
|
|
|
|
|
|
|
|
|
|
|
// Count unique dogs with cancer (excluding generation 0 if we only want stats on ancestors)
|
|
|
|
|
|
const ancestorCases = cases.filter(c => c.generation_distance > 0);
|
|
|
|
|
|
const uniqueAncestorsWithCancer = new Set(ancestorCases.map(c => c.dog_id)).size;
|
|
|
|
|
|
|
|
|
|
|
|
// Number of ancestors is total unique IDs minus 1 for the dog itself
|
|
|
|
|
|
const numAncestors = ancestorIds.length > 0 && ancestorMap.get(parseInt(req.params.id)) ? ancestorIds.length - 1 : ancestorIds.length;
|
|
|
|
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
|
lineage_cases: cases,
|
|
|
|
|
|
stats: {
|
|
|
|
|
|
total_ancestors: numAncestors,
|
|
|
|
|
|
ancestors_with_cancer: uniqueAncestorsWithCancer
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
res.status(500).json({ error: error.message });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-10 15:08:33 -05:00
|
|
|
|
// =====================================================================
|
2026-03-10 15:01:22 -05:00
|
|
|
|
// Wildcard routes last
|
2026-03-10 15:08:33 -05:00
|
|
|
|
// =====================================================================
|
2026-03-10 15:01:22 -05:00
|
|
|
|
|
|
|
|
|
|
// GET /api/pedigree/:id
|
2026-03-08 22:45:07 -05:00
|
|
|
|
router.get('/:id', (req, res) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const db = getDatabase();
|
|
|
|
|
|
const generations = parseInt(req.query.generations) || 5;
|
2026-03-10 14:44:27 -05:00
|
|
|
|
|
2026-03-08 22:45:07 -05:00
|
|
|
|
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) : [];
|
2026-03-10 14:54:59 -05:00
|
|
|
|
const parents = db.prepare('SELECT parent_type, parent_id FROM parents WHERE dog_id = ?').all(dogId);
|
2026-03-08 22:45:07 -05:00
|
|
|
|
const sire = parents.find(p => p.parent_type === 'sire');
|
2026-03-10 14:44:27 -05:00
|
|
|
|
const dam = parents.find(p => p.parent_type === 'dam');
|
2026-03-08 22:45:07 -05:00
|
|
|
|
return {
|
|
|
|
|
|
...dog,
|
|
|
|
|
|
generation: currentGen,
|
|
|
|
|
|
sire: sire ? buildTree(sire.parent_id, currentGen + 1) : null,
|
2026-03-10 14:44:27 -05:00
|
|
|
|
dam: dam ? buildTree(dam.parent_id, currentGen + 1) : null
|
2026-03-08 22:45:07 -05:00
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-03-10 14:44:27 -05:00
|
|
|
|
|
2026-03-08 22:45:07 -05:00
|
|
|
|
const tree = buildTree(req.params.id);
|
2026-03-10 14:44:27 -05:00
|
|
|
|
if (!tree) return res.status(404).json({ error: 'Dog not found' });
|
2026-03-08 22:45:07 -05:00
|
|
|
|
res.json(tree);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
res.status(500).json({ error: error.message });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-10 15:01:22 -05:00
|
|
|
|
// GET /api/pedigree/:id/descendants
|
2026-03-08 22:45:07 -05:00
|
|
|
|
router.get('/:id/descendants', (req, res) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const db = getDatabase();
|
|
|
|
|
|
const generations = parseInt(req.query.generations) || 3;
|
2026-03-10 14:44:27 -05:00
|
|
|
|
|
2026-03-08 22:45:07 -05:00
|
|
|
|
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
|
2026-03-10 14:54:59 -05:00
|
|
|
|
FROM dogs d JOIN parents p ON d.id = p.dog_id
|
2026-03-08 22:45:07 -05:00
|
|
|
|
WHERE p.parent_id = ? AND d.is_active = 1
|
|
|
|
|
|
`).all(dogId);
|
|
|
|
|
|
return {
|
|
|
|
|
|
...dog,
|
|
|
|
|
|
generation: currentGen,
|
2026-03-10 14:54:59 -05:00
|
|
|
|
offspring: offspring.map(c => buildDescendantTree(c.id, currentGen + 1))
|
2026-03-08 22:45:07 -05:00
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-03-10 14:44:27 -05:00
|
|
|
|
|
2026-03-08 22:45:07 -05:00
|
|
|
|
const tree = buildDescendantTree(req.params.id);
|
2026-03-10 14:44:27 -05:00
|
|
|
|
if (!tree) return res.status(404).json({ error: 'Dog not found' });
|
2026-03-08 22:45:07 -05:00
|
|
|
|
res.json(tree);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
res.status(500).json({ error: error.message });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-10 14:38:16 -05:00
|
|
|
|
module.exports = router;
|