Roadmap 2,3,4
This commit is contained in:
@@ -10,6 +10,7 @@ const GRCA_CORE = {
|
||||
heart: ['heart_ofa', 'heart_echo'],
|
||||
eye: ['eye_caer'],
|
||||
};
|
||||
const VALID_TEST_TYPES = ['hip_ofa', 'hip_pennhip', 'elbow_ofa', 'heart_ofa', 'heart_echo', 'eye_caer', 'thyroid_ofa', 'dna_panel'];
|
||||
|
||||
// Helper: compute clearance summary for a dog
|
||||
function getClearanceSummary(db, dogId) {
|
||||
@@ -103,17 +104,6 @@ router.get('/dog/:dogId/chic-eligible', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET single health record
|
||||
router.get('/:id', (req, res) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const record = db.prepare('SELECT * FROM health_records WHERE id = ?').get(req.params.id);
|
||||
if (!record) return res.status(404).json({ error: 'Health record not found' });
|
||||
res.json(record);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST create health record
|
||||
router.post('/', (req, res) => {
|
||||
@@ -128,6 +118,10 @@ router.post('/', (req, res) => {
|
||||
return res.status(400).json({ error: 'dog_id, record_type, and test_date are required' });
|
||||
}
|
||||
|
||||
if (test_type && !VALID_TEST_TYPES.includes(test_type)) {
|
||||
return res.status(400).json({ error: 'Invalid test_type' });
|
||||
}
|
||||
|
||||
const db = getDatabase();
|
||||
const dbResult = db.prepare(`
|
||||
INSERT INTO health_records
|
||||
@@ -157,6 +151,10 @@ router.put('/:id', (req, res) => {
|
||||
document_url, result, vet_name, next_due, notes
|
||||
} = req.body;
|
||||
|
||||
if (test_type && !VALID_TEST_TYPES.includes(test_type)) {
|
||||
return res.status(400).json({ error: 'Invalid test_type' });
|
||||
}
|
||||
|
||||
const db = getDatabase();
|
||||
db.prepare(`
|
||||
UPDATE health_records
|
||||
@@ -190,4 +188,70 @@ router.delete('/:id', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET cancer history for a dog
|
||||
router.get('/dog/:dogId/cancer-history', (req, res) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const records = db.prepare(`
|
||||
SELECT * FROM cancer_history
|
||||
WHERE dog_id = ?
|
||||
ORDER BY age_at_diagnosis ASC, created_at DESC
|
||||
`).all(req.params.dogId);
|
||||
res.json(records);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST create cancer history record
|
||||
router.post('/cancer-history', (req, res) => {
|
||||
try {
|
||||
const {
|
||||
dog_id, cancer_type, age_at_diagnosis, age_at_death, cause_of_death, notes
|
||||
} = req.body;
|
||||
|
||||
if (!dog_id || !cancer_type) {
|
||||
return res.status(400).json({ error: 'dog_id and cancer_type are required' });
|
||||
}
|
||||
|
||||
const db = getDatabase();
|
||||
|
||||
// Update dog's age_at_death and cause_of_death if provided
|
||||
if (age_at_death || cause_of_death) {
|
||||
db.prepare(`
|
||||
UPDATE dogs SET
|
||||
age_at_death = COALESCE(?, age_at_death),
|
||||
cause_of_death = COALESCE(?, cause_of_death)
|
||||
WHERE id = ?
|
||||
`).run(age_at_death || null, cause_of_death || null, dog_id);
|
||||
}
|
||||
|
||||
const dbResult = db.prepare(`
|
||||
INSERT INTO cancer_history
|
||||
(dog_id, cancer_type, age_at_diagnosis, age_at_death, cause_of_death, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
dog_id, cancer_type, age_at_diagnosis || null,
|
||||
age_at_death || null, cause_of_death || null, notes || null
|
||||
);
|
||||
|
||||
const record = db.prepare('SELECT * FROM cancer_history WHERE id = ?').get(dbResult.lastInsertRowid);
|
||||
res.status(201).json(record);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET single health record (wildcard should go last to prevent overlap)
|
||||
router.get('/:id', (req, res) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const record = db.prepare('SELECT * FROM health_records WHERE id = ?').get(req.params.id);
|
||||
if (!record) return res.status(404).json({ error: 'Health record not found' });
|
||||
res.json(record);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,6 +2,25 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDatabase } = require('../db/init');
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* getAncestorMap(db, dogId, maxGen)
|
||||
* Returns Map<id, [{ id, name, generation }, ...]>
|
||||
@@ -9,24 +28,27 @@ const { getDatabase } = require('../db/init');
|
||||
* pairings are correctly detected by calculateCOI.
|
||||
*/
|
||||
function getAncestorMap(db, dogId, maxGen = 6) {
|
||||
const map = new Map();
|
||||
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));
|
||||
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;
|
||||
recurse(parseInt(dogId), 0);
|
||||
return map;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,54 +90,57 @@ function isDirectRelation(db, sireId, damId) {
|
||||
* 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);
|
||||
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
|
||||
);
|
||||
// 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 = [];
|
||||
let coi = 0;
|
||||
const processedPaths = new Set();
|
||||
const commonAncestorList = [];
|
||||
|
||||
commonIds.forEach(ancId => {
|
||||
const sireOccs = sireMap.get(ancId);
|
||||
const damOccs = damMap.get(ancId);
|
||||
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);
|
||||
}
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
coefficient: coi,
|
||||
commonAncestors: commonAncestorList
|
||||
};
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
@@ -195,6 +220,63 @@ router.get('/relations/:sireId/:damId', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 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 });
|
||||
}
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// Wildcard routes last
|
||||
// =====================================================================
|
||||
|
||||
Reference in New Issue
Block a user