← back to AbramsOS

routes/withings.js

103 lines

// Withings — connect a Withings account (OAuth2) and auto-sync device readings
// (weight, BP, heart rate, SpO2) into health_reading. Token stored encrypted in
// connector_account (provider='withings'); Withings rotates the refresh token each
// refresh, so we re-persist it every sync. Sensitive medical data; audited.
const express = require('express');
const db = require('../lib/db');
const audit = require('../lib/audit');
const { id } = require('../lib/ids');
const { encrypt, decrypt } = require('../lib/crypto');
const w = require('../lib/withings-client');
const { insertReadings } = require('./vitals');

const router = express.Router();
const DEV_USER_ID = 'user_steve';

async function getConnector() {
  const r = await db.query(
    `SELECT id, external_subject_id, refresh_token_encrypted, refresh_token_iv, refresh_token_tag, sync_cursor, last_sync_at
       FROM connector_account WHERE user_id = $1 AND provider = 'withings' LIMIT 1`, [DEV_USER_ID]);
  return r.rows[0] || null;
}
async function selfPersonId() {
  const r = await db.query(`SELECT id FROM person WHERE user_id=$1 AND relation='self' LIMIT 1`, [DEV_USER_ID]);
  return r.rows[0] && r.rows[0].id;
}

router.get('/api/withings/status', async (_req, res) => {
  const c = await getConnector();
  res.json({ configured: w.isConfigured(), connected: !!c, last_sync_at: c && c.last_sync_at });
});

// 1) Kick off OAuth — send the browser to Withings' consent screen.
router.get('/auth/withings', (_req, res) => {
  if (!w.isConfigured()) return res.status(501).send('Withings not configured — set WITHINGS_CLIENT_ID + WITHINGS_CLIENT_SECRET.');
  res.redirect(w.authUrl('withings'));
});

// 2) Withings redirects back with a code → exchange, store, initial sync.
router.get('/api/withings/callback', async (req, res) => {
  const { code } = req.query;
  if (!code) return res.status(400).send('No authorization code');
  try {
    const tok = await w.exchangeCode(code);
    await storeToken(tok);
    const synced = await runSync().catch(() => ({ inserted: 0 }));
    res.redirect(`/health?withings=connected&added=${synced.inserted || 0}`);
  } catch (err) {
    res.status(502).send(`Withings connect failed: ${err.message}`);
  }
});

// 3) Manual re-sync.
router.post('/api/withings/sync', async (_req, res) => {
  const c = await getConnector();
  if (!c) return res.status(404).json({ ok: false, error: 'Withings not connected' });
  try {
    const out = await runSync();
    res.json({ ok: true, ...out });
  } catch (err) {
    res.status(502).json({ ok: false, error: err.message });
  }
});

async function storeToken(tok, connectorId) {
  const enc = encrypt(tok.refresh_token);
  if (connectorId) {
    await db.query(
      `UPDATE connector_account SET refresh_token_encrypted=$1, refresh_token_iv=$2, refresh_token_tag=$3 WHERE id=$4`,
      [enc.ciphertext, enc.iv, enc.tag, connectorId]);
    return connectorId;
  }
  const consentId = id('consent'); const cid = id('connector');
  await db.tx(async (client) => {
    await client.query(`INSERT INTO consent_grant (id, user_id, connector_type, scopes_json) VALUES ($1,$2,'withings',$3)`,
      [consentId, DEV_USER_ID, JSON.stringify({ scope: 'user.metrics' })]);
    await client.query(
      `INSERT INTO connector_account (id, user_id, consent_grant_id, provider, external_subject_id, refresh_token_encrypted, refresh_token_iv, refresh_token_tag)
       VALUES ($1,$2,$3,'withings',$4,$5,$6,$7)
       ON CONFLICT (provider, external_subject_id) DO UPDATE SET
         refresh_token_encrypted=EXCLUDED.refresh_token_encrypted, refresh_token_iv=EXCLUDED.refresh_token_iv,
         refresh_token_tag=EXCLUDED.refresh_token_tag, consent_grant_id=EXCLUDED.consent_grant_id`,
      [cid, DEV_USER_ID, consentId, String(tok.userid || 'withings'), enc.ciphertext, enc.iv, enc.tag]);
  });
  return cid;
}

async function runSync() {
  const c = await getConnector();
  if (!c) throw new Error('not connected');
  const refreshToken = decrypt(c.refresh_token_encrypted, c.refresh_token_iv, c.refresh_token_tag);
  const tok = await w.refresh(refreshToken);          // rotates the refresh token
  await storeToken(tok, c.id);                          // re-persist the new one
  const since = c.sync_cursor ? Number(c.sync_cursor) : null;
  const { readings, newest } = await w.getMeasures(tok.access_token, since);
  const personId = await selfPersonId();
  const out = await insertReadings(DEV_USER_ID, personId, readings.map((r) => ({ ...r })));
  await db.query(`UPDATE connector_account SET last_sync_at=now(), sync_cursor=$2 WHERE id=$1`, [c.id, String(newest || since || '')]);
  await audit.log({ actorType: 'system', actorId: DEV_USER_ID, objectType: 'connector_account', objectId: c.id, eventType: 'withings_sync', metadata: { medical: true, fetched: readings.length, inserted: out.inserted } }).catch(() => {});
  return { fetched: readings.length, inserted: out.inserted, skipped: out.skipped };
}

module.exports = router;