← 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 };