← back to AbramsOS
routes/vitals.js
142 lines
// Vitals — health readings dashboard: blood-pressure history + Apple Watch / device
// metrics. Sensitive medical data; every write is audited with medical:true. Readings
// arrive from: the Apple Health export CLI, the Health Auto Export app (JSON POST to
// /api/health/readings), a connected device, or manual entry. Nothing is fabricated.
const express = require('express');
const db = require('../lib/db');
const audit = require('../lib/audit');
const { id } = require('../lib/ids');
const hm = require('../lib/health-metrics');
const vitalsInsights = require('../lib/vitals-insights');
const bpMedsLib = require('../lib/bp-meds');
const router = express.Router();
const DEV_USER_ID = 'user_steve';
// Insert normalized readings, dedup on external_id. Returns { inserted, skipped }.
async function insertReadings(userId, personId, rawReadings) {
let inserted = 0, skipped = 0;
for (const raw of rawReadings) {
const r = hm.normalizeReading(raw);
if (!r.metric || !r.measured_at || isNaN(r.measured_at.getTime())) { skipped++; continue; }
if (r.metric === 'blood_pressure' && (r.systolic == null || r.diastolic == null)) { skipped++; continue; }
if (r.metric !== 'blood_pressure' && r.value == null) { skipped++; continue; }
const res = await db.query(
`INSERT INTO health_reading
(id, user_id, person_id, metric, systolic, diastolic, value, unit, category,
measured_at, source, device_name, external_id, notes)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
ON CONFLICT (user_id, external_id) WHERE external_id IS NOT NULL DO NOTHING
RETURNING id`,
[
id('reading'), userId, personId || raw.person_id || null, r.metric,
r.systolic, r.diastolic, r.value, r.unit, r.category || null,
r.measured_at, r.source, r.device_name, r.external_id, r.notes,
]
);
if (res.rows.length) inserted++; else skipped++;
}
return { inserted, skipped };
}
async function personList(userId) {
return (await db.query(
`SELECT id, full_name, relation FROM person WHERE user_id = $1 ORDER BY (relation<>'self'), full_name`,
[userId]
)).rows;
}
router.get('/health', async (req, res) => {
const personId = req.query.person || null;
const people = await personList(DEV_USER_ID);
const params = [DEV_USER_ID];
let where = 'user_id = $1';
if (personId) { params.push(personId); where += ` AND person_id = $${params.length}`; }
const series = (metric) => db.query(
`SELECT value, measured_at FROM health_reading WHERE ${where} AND metric = '${metric}'
ORDER BY measured_at DESC LIMIT 40`, params);
const medWhere = personId ? 'm.user_id = $1 AND m.person_id = $2' : 'm.user_id = $1';
const [bp, latest, counts, weight, rhr, trends, meds] = await Promise.all([
db.query(
`SELECT id, systolic, diastolic, category, measured_at, device_name, source
FROM health_reading WHERE ${where} AND metric = 'blood_pressure'
ORDER BY measured_at DESC LIMIT 60`, params),
db.query(
`SELECT DISTINCT ON (metric) metric, value, systolic, diastolic, unit, category, measured_at, device_name
FROM health_reading WHERE ${where}
ORDER BY metric, measured_at DESC`, params),
db.query(`SELECT count(*)::int AS n FROM health_reading WHERE ${where}`, params),
series('weight'),
series('resting_heart_rate'),
vitalsInsights.computeTrends(DEV_USER_ID, personId), // cheap, DB-only (no LLM)
db.query(
`SELECT m.name, m.generic_name, p.full_name AS person_name
FROM medication m LEFT JOIN person p ON p.id = m.person_id
WHERE ${medWhere} AND m.is_active`, params),
]);
res.render('vitals', {
people, personId,
bp: bp.rows,
latest: latest.rows.filter((r) => r.metric !== 'blood_pressure'),
total: counts.rows[0].n,
weightSeries: weight.rows,
rhrSeries: rhr.rows,
trends,
recheck: vitalsInsights.recheckFlag(trends),
bpMeds: bpMedsLib.bpRelevant(meds.rows),
});
});
// LLM narrative ("what changed this month") — fetched async so the page never blocks on it.
router.get('/api/health/insights', async (req, res) => {
try {
const out = await vitalsInsights.insights(DEV_USER_ID, req.query.person || null, { model: 'gemma3:12b' });
res.json(out);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/api/health/readings', async (req, res) => {
const metric = req.query.metric || 'blood_pressure';
const r = await db.query(
`SELECT id, metric, systolic, diastolic, value, unit, category, measured_at, device_name, source
FROM health_reading WHERE user_id = $1 AND metric = $2
ORDER BY measured_at DESC LIMIT 500`,
[DEV_USER_ID, metric]
);
res.json(r.rows);
});
// Add one manual reading OR bulk-ingest (Health Auto Export app posts { readings: [...] }).
router.post('/api/health/readings', async (req, res) => {
try {
const body = req.body || {};
const list = Array.isArray(body.readings) ? body.readings : [body];
const personId = body.person_id || null;
const out = await insertReadings(DEV_USER_ID, personId, list);
await audit.log({
actorType: body.readings ? 'system' : 'user', actorId: DEV_USER_ID,
objectType: 'health_reading', objectId: null, eventType: 'health_reading_added',
metadata: { medical: true, consent: 'user-entered', ...out, source: (list[0] && list[0].source) || 'manual' },
}).catch(() => {});
res.json({ ok: true, ...out });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
});
router.post('/api/health/readings/:id/delete', async (req, res) => {
await db.query(`DELETE FROM health_reading WHERE id = $1 AND user_id = $2`, [req.params.id, DEV_USER_ID]);
await audit.log({
actorType: 'user', actorId: DEV_USER_ID, objectType: 'health_reading',
objectId: req.params.id, eventType: 'health_reading_deleted', metadata: { medical: true },
}).catch(() => {});
res.json({ ok: true });
});
module.exports = { router, insertReadings };