← 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
A db/migrations/0014_biometrics.sqlA lib/biometrics.jsM lib/ids.jsA routes/biometrics.js
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 →