[object Object]

← back to AbramsOS

auto-save: 2026-07-13T01:21:52 (4 files) — lib/ids.js db/migrations/0014_biometrics.sql lib/biometrics.js routes/biometrics.js

a4f780394dd01361eb84957435f0a3cb806e0b5e · 2026-07-13 01:21:56 -0700 · Steve Abrams

Files touched

Diff

commit a4f780394dd01361eb84957435f0a3cb806e0b5e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jul 13 01:21:56 2026 -0700

    auto-save: 2026-07-13T01:21:52 (4 files) — lib/ids.js db/migrations/0014_biometrics.sql lib/biometrics.js routes/biometrics.js
---
 db/migrations/0014_biometrics.sql | 37 +++++++++++++++++++
 lib/biometrics.js                 | 62 +++++++++++++++++++++++++++++++
 lib/ids.js                        |  1 +
 routes/biometrics.js              | 77 +++++++++++++++++++++++++++++++++++++++
 4 files changed, 177 insertions(+)

diff --git a/db/migrations/0014_biometrics.sql b/db/migrations/0014_biometrics.sql
new file mode 100644
index 0000000..bdcc108
--- /dev/null
+++ b/db/migrations/0014_biometrics.sql
@@ -0,0 +1,37 @@
+-- 0014_biometrics.sql
+-- Per-person biometric / ID profile — for kids AND adults. Stable physical & identity
+-- characteristics (not time-series; that's health_reading). Doubles as an emergency /
+-- child-ID record: DOB, sex, height, weight, blood type, eye/hair color, distinguishing
+-- marks, allergies, conditions, emergency contact, and whether fingerprints/photo are on
+-- file. Sensitive PII — same posture as the medication tables (local, audited).
+-- One row per person. Idempotent. Safe to re-run.
+
+BEGIN;
+
+CREATE TABLE IF NOT EXISTS person_biometric (
+  id                   text PRIMARY KEY,
+  user_id              text NOT NULL REFERENCES user_account(id) ON DELETE CASCADE,
+  person_id            text NOT NULL REFERENCES person(id) ON DELETE CASCADE,
+  date_of_birth        date,
+  sex                  text,                 -- m|f|x
+  height_in            numeric(5,1),
+  weight_lb            numeric(6,1),
+  blood_type           text,                 -- O+, A-, etc.
+  eye_color            text,
+  hair_color           text,
+  distinguishing_marks text,                 -- scars, birthmarks, tattoos
+  allergies            text,
+  conditions           text,                 -- chronic conditions relevant in an emergency
+  emergency_contact    text,
+  emergency_notes      text,
+  photo_path           text,                 -- optional reference to an uploaded photo
+  prints_on_file       boolean NOT NULL DEFAULT false,  -- child-ID: fingerprints kept somewhere
+  prints_location      text,                 -- where (safe, doctor, police kit)
+  metadata_jsonb       jsonb NOT NULL DEFAULT '{}'::jsonb,
+  created_at           timestamptz NOT NULL DEFAULT now(),
+  updated_at           timestamptz NOT NULL DEFAULT now(),
+  UNIQUE (user_id, person_id)
+);
+CREATE INDEX IF NOT EXISTS person_biometric_user_idx ON person_biometric (user_id);
+
+COMMIT;
diff --git a/lib/biometrics.js b/lib/biometrics.js
new file mode 100644
index 0000000..ffc3875
--- /dev/null
+++ b/lib/biometrics.js
@@ -0,0 +1,62 @@
+// biometrics.js — derived helpers for a person's biometric profile.
+//
+// Everything here is EXACT arithmetic (age, BMI) or a documented CDC threshold — no
+// invented percentiles. For children we compute BMI and hand off to the CDC calculator
+// for the age/sex percentile rather than fabricate one (that needs CDC LMS tables).
+
+function ageYears(dob, asOf) {
+  if (!dob) return null;
+  const b = new Date(dob), now = asOf ? new Date(asOf) : new Date();
+  if (isNaN(b.getTime())) return null;
+  let a = now.getFullYear() - b.getFullYear();
+  const m = now.getMonth() - b.getMonth();
+  if (m < 0 || (m === 0 && now.getDate() < b.getDate())) a--;
+  return a;
+}
+
+function ageMonths(dob, asOf) {
+  if (!dob) return null;
+  const b = new Date(dob), now = asOf ? new Date(asOf) : new Date();
+  if (isNaN(b.getTime())) return null;
+  return (now.getFullYear() - b.getFullYear()) * 12 + (now.getMonth() - b.getMonth());
+}
+
+// Imperial BMI = 703 × lb / in².
+function bmi(heightIn, weightLb) {
+  const h = Number(heightIn), w = Number(weightLb);
+  if (!(h > 0) || !(w > 0)) return null;
+  return Math.round((703 * w / (h * h)) * 10) / 10;
+}
+
+// Adult (20+) BMI category — CDC thresholds. For children this label does NOT apply
+// (kids use age/sex percentiles), so we return null and the UI links to CDC.
+function adultBmiCategory(bmiVal) {
+  if (bmiVal == null) return null;
+  if (bmiVal < 18.5) return 'underweight';
+  if (bmiVal < 25) return 'healthy';
+  if (bmiVal < 30) return 'overweight';
+  return 'obese';
+}
+
+function isChild(dob) {
+  const a = ageYears(dob);
+  return a != null && a < 18;
+}
+
+// Build the derived view for one biometric row (+ its person).
+function derive(b) {
+  const age = ageYears(b.date_of_birth);
+  const child = isChild(b.date_of_birth);
+  const bmiVal = bmi(b.height_in, b.weight_lb);
+  return {
+    age,
+    ageMonths: ageMonths(b.date_of_birth),
+    isChild: child,
+    bmi: bmiVal,
+    bmiCategory: child ? null : adultBmiCategory(bmiVal),
+    // For kids: authoritative CDC child BMI percentile calculator (we don't fake the number).
+    cdcPercentileUrl: child ? 'https://www.cdc.gov/healthyweight/bmi/calculator.html' : null,
+  };
+}
+
+module.exports = { ageYears, ageMonths, bmi, adultBmiCategory, isChild, derive };
diff --git a/lib/ids.js b/lib/ids.js
index 750bf2a..ae8c36b 100644
--- a/lib/ids.js
+++ b/lib/ids.js
@@ -20,6 +20,7 @@ const PREFIX = {
   coupon: 'coup',
   reading: 'hr',
   asset: 'ast',
+  biometric: 'bio',
 };
 
 function id(kind) {
diff --git a/routes/biometrics.js b/routes/biometrics.js
new file mode 100644
index 0000000..753488f
--- /dev/null
+++ b/routes/biometrics.js
@@ -0,0 +1,77 @@
+// Biometrics — per-person ID / emergency profile (kids + adults). Sensitive PII; every
+// write audited. Doubles as a child-ID record. One row per person (upsert).
+const express = require('express');
+const db = require('../lib/db');
+const audit = require('../lib/audit');
+const { id } = require('../lib/ids');
+const bio = require('../lib/biometrics');
+
+const router = express.Router();
+const DEV_USER_ID = 'user_steve';
+
+async function profiles(userId) {
+  const rows = (await db.query(
+    `SELECT p.id AS person_id, p.full_name, p.relation, b.*
+       FROM person p LEFT JOIN person_biometric b ON b.person_id = p.id AND b.user_id = $1
+      WHERE p.user_id = $1
+      ORDER BY (p.relation <> 'self'), p.full_name`,
+    [userId]
+  )).rows;
+  return rows.map((r) => ({ ...r, derived: bio.derive(r) }));
+}
+
+router.get('/biometrics', async (_req, res) => {
+  res.render('biometrics', { profiles: await profiles(DEV_USER_ID) });
+});
+
+router.get('/api/biometrics', async (_req, res) => {
+  res.json(await profiles(DEV_USER_ID));
+});
+
+const FIELDS = ['date_of_birth', 'sex', 'height_in', 'weight_lb', 'blood_type', 'eye_color',
+  'hair_color', 'distinguishing_marks', 'allergies', 'conditions', 'emergency_contact',
+  'emergency_notes', 'prints_on_file', 'prints_location'];
+
+function clean(b) {
+  const out = {};
+  for (const f of FIELDS) {
+    if (!(f in b)) continue;
+    let v = b[f];
+    if (v === '' ) v = null;
+    if (f === 'prints_on_file') v = v === true || v === 'true' || v === 'on' || v === '1';
+    else if (['height_in', 'weight_lb'].includes(f)) v = (v == null ? null : (Number.isFinite(Number(v)) ? Number(v) : null));
+    else if (v != null) v = String(v).slice(0, 1000);
+    out[f] = v;
+  }
+  return out;
+}
+
+// Upsert a person's biometric profile.
+router.post('/api/biometrics/:personId', async (req, res) => {
+  const personId = req.params.personId;
+  const owns = await db.query(`SELECT 1 FROM person WHERE id = $1 AND user_id = $2`, [personId, DEV_USER_ID]);
+  if (!owns.rows.length) return res.status(404).json({ error: 'person not found' });
+
+  const c = clean(req.body || {});
+  const cols = Object.keys(c);
+  if (!cols.length) return res.status(400).json({ error: 'nothing to save' });
+
+  const existing = await db.query(`SELECT id FROM person_biometric WHERE user_id = $1 AND person_id = $2`, [DEV_USER_ID, personId]);
+  if (existing.rows.length) {
+    const sets = cols.map((c2, i) => `${c2} = $${i + 1}`);
+    const vals = cols.map((k) => c[k]);
+    sets.push('updated_at = now()');
+    vals.push(existing.rows[0].id, DEV_USER_ID);
+    await db.query(`UPDATE person_biometric SET ${sets.join(', ')} WHERE id = $${vals.length - 1} AND user_id = $${vals.length}`, vals);
+  } else {
+    const bid = id('biometric');
+    const allCols = ['id', 'user_id', 'person_id', ...cols];
+    const vals = [bid, DEV_USER_ID, personId, ...cols.map((k) => c[k])];
+    const ph = allCols.map((_, i) => `$${i + 1}`);
+    await db.query(`INSERT INTO person_biometric (${allCols.join(', ')}) VALUES (${ph.join(', ')})`, vals);
+  }
+  await audit.log({ actorType: 'user', actorId: DEV_USER_ID, objectType: 'person_biometric', objectId: personId, eventType: 'biometric_saved', metadata: { medical: true, sensitive: true } }).catch(() => {});
+  res.json({ ok: true });
+});
+
+module.exports = router;

← 42d431f neighborhood: Neighborhood Watch — address+radius, Leaflet m  ·  back to AbramsOS  ·  biometrics: per-person ID/emergency profile for kids + adult 5ab1c97 →