Add private health tracker (owner-only)

- health schema: vaccinations, body_measurements, workouts, nutrition_daily,
  sleep (generated hours), medications, hair_progress, appointments
- migration with updated_at triggers; grants to personal_app only
- /trackers/health overview with add/edit/delete, owner-gated (404 otherwise),
  noindex + private no-store, hidden from listings for non-owners
- owner-only JSON/CSV import endpoint and scripts/health-import.ts
This commit is contained in:
2026-10-24 00:14:07 +00:00
parent 88837af624
commit 8275f1fb22
24 changed files with 13622 additions and 14 deletions

View File

@@ -0,0 +1,199 @@
import { check, date, index, integer, numeric, text, timestamp, uuid } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { healthSchema } from './schemas';
import { timestamps, timestamptz } from './helpers';
// Private health log (owner only, see requireOwner). No user_id: there is exactly one
// owner, and the whole schema is readable by personal_app only (no personal_ro grant).
// Numerics use mode 'number' so the app gets plain numbers instead of strings.
const pk = () => uuid('id').primaryKey().defaultRandom();
const day = (name = 'date') => date(name, { mode: 'string' });
const sane = (column: string) => sql.raw(`"${column}" >= DATE '1900-01-01'`);
export const healthVaccinations = healthSchema.table(
'vaccinations',
{
id: pk(),
vaccine: text('vaccine').notNull(),
target: text('target'), // disease(s) it protects against, e.g. "DTP", "COVID-19"
doseNo: integer('dose_no'),
date: day().notNull(),
batchLot: text('batch_lot'),
provider: text('provider'), // GGD, GP, travel clinic...
country: text('country'), // ISO 3166-1 alpha-2, e.g. NL
notes: text('notes'),
...timestamps
},
(t) => [
index('vaccinations_date_idx').on(t.date),
check('vaccinations_vaccine_check', sql`length(btrim(${t.vaccine})) > 0`),
check('vaccinations_dose_no_check', sql`${t.doseNo} IS NULL OR ${t.doseNo} BETWEEN 1 AND 20`),
check('vaccinations_country_check', sql`${t.country} IS NULL OR ${t.country} ~ '^[A-Z]{2}$'`),
check('vaccinations_date_check', sane('date'))
]
);
export const healthBodyMeasurements = healthSchema.table(
'body_measurements',
{
id: pk(),
date: day().notNull(),
weightKg: numeric('weight_kg', { precision: 5, scale: 2, mode: 'number' }),
waistCm: numeric('waist_cm', { precision: 5, scale: 1, mode: 'number' }), // at the navel
bodyFatPct: numeric('body_fat_pct', { precision: 4, scale: 1, mode: 'number' }),
notes: text('notes'),
...timestamps
},
(t) => [
index('body_measurements_date_idx').on(t.date),
check(
'body_measurements_any_check',
sql`${t.weightKg} IS NOT NULL OR ${t.waistCm} IS NOT NULL OR ${t.bodyFatPct} IS NOT NULL`
),
check(
'body_measurements_weight_check',
sql`${t.weightKg} IS NULL OR ${t.weightKg} BETWEEN 20 AND 400`
),
check(
'body_measurements_waist_check',
sql`${t.waistCm} IS NULL OR ${t.waistCm} BETWEEN 30 AND 250`
),
check(
'body_measurements_fat_check',
sql`${t.bodyFatPct} IS NULL OR ${t.bodyFatPct} BETWEEN 1 AND 75`
),
check('body_measurements_date_check', sane('date'))
]
);
export const healthWorkouts = healthSchema.table(
'workouts',
{
id: pk(),
date: day().notNull(),
sessionId: uuid('session_id'), // optional: groups the exercises of one session
exercise: text('exercise').notNull(),
sets: integer('sets'),
reps: integer('reps'), // per set
loadKg: numeric('load_kg', { precision: 6, scale: 2, mode: 'number' }),
rpe: numeric('rpe', { precision: 3, scale: 1, mode: 'number' }),
notes: text('notes'),
...timestamps
},
(t) => [
index('workouts_date_idx').on(t.date),
index('workouts_session_idx').on(t.sessionId),
check('workouts_exercise_check', sql`length(btrim(${t.exercise})) > 0`),
check('workouts_sets_check', sql`${t.sets} IS NULL OR ${t.sets} BETWEEN 1 AND 100`),
check('workouts_reps_check', sql`${t.reps} IS NULL OR ${t.reps} BETWEEN 0 AND 1000`),
check('workouts_load_check', sql`${t.loadKg} IS NULL OR ${t.loadKg} BETWEEN 0 AND 1000`),
check('workouts_rpe_check', sql`${t.rpe} IS NULL OR ${t.rpe} BETWEEN 1 AND 10`),
check('workouts_date_check', sane('date'))
]
);
export const healthNutritionDaily = healthSchema.table(
'nutrition_daily',
{
id: pk(),
date: day().notNull().unique('nutrition_daily_date_key'),
kcal: integer('kcal'),
proteinG: numeric('protein_g', { precision: 5, scale: 1, mode: 'number' }),
notes: text('notes'),
...timestamps
},
(t) => [
check('nutrition_daily_kcal_check', sql`${t.kcal} IS NULL OR ${t.kcal} BETWEEN 0 AND 20000`),
check(
'nutrition_daily_protein_check',
sql`${t.proteinG} IS NULL OR ${t.proteinG} BETWEEN 0 AND 1000`
),
check('nutrition_daily_date_check', sane('date'))
]
);
export const healthSleep = healthSchema.table(
'sleep',
{
id: pk(),
date: day().notNull().unique('sleep_date_key'), // the wake-up date of the night
bedtime: timestamptz('bedtime').notNull(),
wakeTime: timestamp('wake_time', { withTimezone: true }).notNull(),
hours: numeric('hours', { precision: 4, scale: 2, mode: 'number' }).generatedAlwaysAs(
sql`round((extract(epoch from (wake_time - bedtime)) / 3600)::numeric, 2)`
),
notes: text('notes'),
...timestamps
},
(t) => [
check('sleep_order_check', sql`${t.wakeTime} > ${t.bedtime}`),
check('sleep_length_check', sql`${t.wakeTime} - ${t.bedtime} <= interval '24 hours'`),
check('sleep_date_check', sane('date'))
]
);
export const healthMedications = healthSchema.table(
'medications',
{
id: pk(),
name: text('name').notNull(),
kind: text('kind').notNull().default('medication'),
dose: text('dose'), // free text: "1 mg", "5% solution, 1 ml"
frequency: text('frequency'), // free text: "daily", "2x daily", "as needed"
startDate: day('start_date'),
stopDate: day('stop_date'), // null = still taking it
notes: text('notes'),
...timestamps
},
(t) => [
index('medications_start_date_idx').on(t.startDate),
index('medications_stop_date_idx').on(t.stopDate),
check('medications_name_check', sql`length(btrim(${t.name})) > 0`),
check('medications_kind_check', sql`${t.kind} IN ('medication','supplement')`),
check(
'medications_dates_check',
sql`${t.stopDate} IS NULL OR ${t.startDate} IS NULL OR ${t.stopDate} >= ${t.startDate}`
)
]
);
export const healthHairProgress = healthSchema.table(
'hair_progress',
{
id: pk(),
date: day().notNull(),
// Key/path into private storage (never a public URL), e.g. "health/hair/2026-10-24-top.jpg".
photoRef: text('photo_ref'),
notes: text('notes'),
...timestamps
},
(t) => [
index('hair_progress_date_idx').on(t.date),
check(
'hair_progress_photo_ref_check',
sql`${t.photoRef} IS NULL OR (${t.photoRef} !~* '^[a-z][a-z0-9+.-]*://' AND ${t.photoRef} !~ '^//')`
),
check('hair_progress_date_check', sane('date'))
]
);
export const healthAppointments = healthSchema.table(
'appointments',
{
id: pk(),
date: day().notNull(),
type: text('type').notNull().default('appointment'),
provider: text('provider'),
notes: text('notes'),
...timestamps
},
(t) => [
index('appointments_date_idx').on(t.date),
check(
'appointments_type_check',
sql`${t.type} IN ('appointment','checkup','condition','test','procedure','other')`
),
check('appointments_date_check', sane('date'))
]
);

View File

@@ -12,3 +12,4 @@ export * from './schemas';
export * from './money-tracker';
export * from './cs-prices';
export * from './house-tracker';
export * from './health';

View File

@@ -12,3 +12,4 @@ export const csSchema = pgSchema('cs');
export const animaldexSchema = pgSchema('animaldex');
export const siteSchema = pgSchema('site');
export const metaSchema = pgSchema('meta');
export const healthSchema = pgSchema('health');