c9d2aa734e
- HealthRecordForm: per-type field configs (vaccination/exam/surgery/medication), expanded OFA test catalog with result scales, min-age validation, expiry rules - Document upload (PDF/image, 15MB) via new POST /api/health/upload - Delete button for health records on DogDetail - Fold legacy vet_name into performed_by; normalize payload per record type Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
296 lines
9.9 KiB
JavaScript
296 lines
9.9 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
const { getDatabase } = require('../db/init');
|
|
const multer = require('multer');
|
|
const path = require('path');
|
|
|
|
const storage = multer.diskStorage({
|
|
destination: (req, file, cb) => {
|
|
const uploadPath = process.env.UPLOAD_PATH || path.join(__dirname, '../../uploads');
|
|
cb(null, uploadPath);
|
|
},
|
|
filename: (req, file, cb) => {
|
|
const uniqueName = `${Date.now()}-${Math.random().toString(36).substring(7)}${path.extname(file.originalname)}`;
|
|
cb(null, uniqueName);
|
|
}
|
|
});
|
|
|
|
const upload = multer({
|
|
storage,
|
|
limits: { fileSize: 15 * 1024 * 1024 },
|
|
fileFilter: (req, file, cb) => {
|
|
const allowed = /pdf|jpeg|jpg|png|gif|webp/;
|
|
const extOk = allowed.test(path.extname(file.originalname).toLowerCase());
|
|
const mimeOk = allowed.test(file.mimetype);
|
|
if (extOk && mimeOk) cb(null, true);
|
|
else cb(new Error('Only PDF or image files are allowed'));
|
|
}
|
|
});
|
|
|
|
// OFA tests that count toward GRCA eligibility
|
|
const GRCA_REQUIRED = ['hip_ofa', 'hip_pennhip', 'elbow_ofa', 'heart_ofa', 'heart_echo', 'eye_caer'];
|
|
const GRCA_CORE = {
|
|
hip: ['hip_ofa', 'hip_pennhip'],
|
|
elbow: ['elbow_ofa'],
|
|
heart: ['heart_ofa', 'heart_echo'],
|
|
eye: ['eye_caer'],
|
|
};
|
|
// DNA is owned exclusively by the genetics module (genetic_tests table), so it is
|
|
// intentionally NOT a valid health test type.
|
|
const VALID_TEST_TYPES = ['hip_ofa', 'hip_pennhip', 'elbow_ofa', 'patella_ofa', 'heart_ofa', 'heart_echo', 'eye_caer', 'thyroid_ofa'];
|
|
|
|
// Helper: compute clearance summary for a dog
|
|
function getClearanceSummary(db, dogId) {
|
|
const records = db.prepare(`
|
|
SELECT test_type, ofa_result, ofa_number, expires_at, test_date
|
|
FROM health_records
|
|
WHERE dog_id = ? AND test_type IS NOT NULL
|
|
ORDER BY test_date DESC
|
|
`).all(dogId);
|
|
|
|
const today = new Date();
|
|
const in90 = new Date(); in90.setDate(today.getDate() + 90);
|
|
|
|
const summary = {};
|
|
for (const [group, types] of Object.entries(GRCA_CORE)) {
|
|
const match = records.find(r => types.includes(r.test_type));
|
|
if (!match) {
|
|
summary[group] = { status: 'missing', record: null };
|
|
} else {
|
|
let status = 'pass';
|
|
if (match.expires_at) {
|
|
const exp = new Date(match.expires_at);
|
|
if (exp < today) status = 'expired';
|
|
else if (exp <= in90) status = 'expiring_soon';
|
|
}
|
|
summary[group] = { status, record: match };
|
|
}
|
|
}
|
|
return summary;
|
|
}
|
|
|
|
// GET all health records for a dog
|
|
router.get('/dog/:dogId', (req, res) => {
|
|
try {
|
|
const db = getDatabase();
|
|
const records = db.prepare(`
|
|
SELECT * FROM health_records
|
|
WHERE dog_id = ?
|
|
ORDER BY test_date DESC
|
|
`).all(req.params.dogId);
|
|
res.json(records);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// GET clearance summary (Hip / Elbow / Heart / Eyes) for a dog
|
|
router.get('/dog/:dogId/clearance-summary', (req, res) => {
|
|
try {
|
|
const db = getDatabase();
|
|
const dog = db.prepare('SELECT id, birth_date, chic_number FROM dogs WHERE id = ?').get(req.params.dogId);
|
|
if (!dog) return res.status(404).json({ error: 'Dog not found' });
|
|
|
|
const summary = getClearanceSummary(db, dog.id);
|
|
|
|
// Age check: must be >= 24 months for hip/elbow
|
|
let ageEligible = false;
|
|
if (dog.birth_date) {
|
|
const months = (new Date() - new Date(dog.birth_date)) / (1000 * 60 * 60 * 24 * 30.44);
|
|
ageEligible = months >= 24;
|
|
}
|
|
|
|
const allPass = Object.values(summary).every(s => ['pass', 'expiring_soon'].includes(s.status));
|
|
const grca_eligible = allPass && ageEligible;
|
|
|
|
res.json({ summary, grca_eligible, age_eligible: ageEligible, chic_number: dog.chic_number });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// GET CHIC eligibility check
|
|
router.get('/dog/:dogId/chic-eligible', (req, res) => {
|
|
try {
|
|
const db = getDatabase();
|
|
const dog = db.prepare('SELECT id, chic_number FROM dogs WHERE id = ?').get(req.params.dogId);
|
|
if (!dog) return res.status(404).json({ error: 'Dog not found' });
|
|
|
|
const summary = getClearanceSummary(db, dog.id);
|
|
const missing = Object.entries(summary)
|
|
.filter(([, v]) => v.status === 'missing')
|
|
.map(([k]) => k);
|
|
|
|
res.json({
|
|
chic_eligible: missing.length === 0,
|
|
chic_number: dog.chic_number || null,
|
|
missing_tests: missing,
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
|
|
// POST create health record
|
|
router.post('/', (req, res) => {
|
|
try {
|
|
const {
|
|
dog_id, record_type, test_type, test_name, test_date,
|
|
ofa_result, ofa_number, performed_by, expires_at,
|
|
document_url, result, vet_name, next_due, notes
|
|
} = req.body;
|
|
|
|
if (!dog_id || !record_type || !test_date) {
|
|
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
|
|
(dog_id, record_type, test_type, test_name, test_date,
|
|
ofa_result, ofa_number, performed_by, expires_at,
|
|
document_url, result, vet_name, next_due, notes)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
dog_id, record_type, test_type || null, test_name || null, test_date,
|
|
ofa_result || null, ofa_number || null, performed_by || null, expires_at || null,
|
|
document_url || null, result || null, vet_name || null, next_due || null, notes || null
|
|
);
|
|
|
|
const record = db.prepare('SELECT * FROM health_records WHERE id = ?').get(dbResult.lastInsertRowid);
|
|
res.status(201).json(record);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// POST upload a supporting document (PDF/image). Returns a URL to store in
|
|
// document_url. Works for new or existing records — the URL is saved via the
|
|
// record's normal POST/PUT, so no record id is needed here.
|
|
router.post('/upload', (req, res) => {
|
|
upload.single('document')(req, res, (err) => {
|
|
if (err) return res.status(400).json({ error: err.message });
|
|
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
|
res.json({ url: `/uploads/${req.file.filename}` });
|
|
});
|
|
});
|
|
|
|
// PUT update health record
|
|
router.put('/:id', (req, res) => {
|
|
try {
|
|
const {
|
|
record_type, test_type, test_name, test_date,
|
|
ofa_result, ofa_number, performed_by, expires_at,
|
|
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
|
|
SET record_type = ?, test_type = ?, test_name = ?, test_date = ?,
|
|
ofa_result = ?, ofa_number = ?, performed_by = ?, expires_at = ?,
|
|
document_url = ?, result = ?, vet_name = ?, next_due = ?, notes = ?,
|
|
updated_at = datetime('now')
|
|
WHERE id = ?
|
|
`).run(
|
|
record_type, test_type || null, test_name || null, test_date,
|
|
ofa_result || null, ofa_number || null, performed_by || null, expires_at || null,
|
|
document_url || null, result || null, vet_name || null, next_due || null, notes || null,
|
|
req.params.id
|
|
);
|
|
|
|
const record = db.prepare('SELECT * FROM health_records WHERE id = ?').get(req.params.id);
|
|
res.json(record);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// DELETE health record
|
|
router.delete('/:id', (req, res) => {
|
|
try {
|
|
const db = getDatabase();
|
|
db.prepare('DELETE FROM health_records WHERE id = ?').run(req.params.id);
|
|
res.json({ message: 'Health record deleted successfully' });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// 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;
|