- 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
161 lines
7.9 KiB
SQL
161 lines
7.9 KiB
SQL
CREATE SCHEMA "health";
|
|
--> statement-breakpoint
|
|
CREATE TABLE "health"."appointments" (
|
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
"date" date NOT NULL,
|
|
"type" text DEFAULT 'appointment' NOT NULL,
|
|
"provider" text,
|
|
"notes" text,
|
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
CONSTRAINT "appointments_type_check" CHECK ("type" IN ('appointment','checkup','condition','test','procedure','other')),
|
|
CONSTRAINT "appointments_date_check" CHECK ("date" >= DATE '1900-01-01')
|
|
);
|
|
--> statement-breakpoint
|
|
CREATE TABLE "health"."body_measurements" (
|
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
"date" date NOT NULL,
|
|
"weight_kg" numeric(5,2),
|
|
"waist_cm" numeric(5,1),
|
|
"body_fat_pct" numeric(4,1),
|
|
"notes" text,
|
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
CONSTRAINT "body_measurements_any_check" CHECK ("weight_kg" IS NOT NULL OR "waist_cm" IS NOT NULL OR "body_fat_pct" IS NOT NULL),
|
|
CONSTRAINT "body_measurements_weight_check" CHECK ("weight_kg" IS NULL OR "weight_kg" BETWEEN 20 AND 400),
|
|
CONSTRAINT "body_measurements_waist_check" CHECK ("waist_cm" IS NULL OR "waist_cm" BETWEEN 30 AND 250),
|
|
CONSTRAINT "body_measurements_fat_check" CHECK ("body_fat_pct" IS NULL OR "body_fat_pct" BETWEEN 1 AND 75),
|
|
CONSTRAINT "body_measurements_date_check" CHECK ("date" >= DATE '1900-01-01')
|
|
);
|
|
--> statement-breakpoint
|
|
CREATE TABLE "health"."hair_progress" (
|
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
"date" date NOT NULL,
|
|
"photo_ref" text,
|
|
"notes" text,
|
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
CONSTRAINT "hair_progress_photo_ref_check" CHECK ("photo_ref" IS NULL OR ("photo_ref" !~* '^[a-z][a-z0-9+.-]*://' AND "photo_ref" !~ '^//')),
|
|
CONSTRAINT "hair_progress_date_check" CHECK ("date" >= DATE '1900-01-01')
|
|
);
|
|
--> statement-breakpoint
|
|
CREATE TABLE "health"."medications" (
|
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
"name" text NOT NULL,
|
|
"kind" text DEFAULT 'medication' NOT NULL,
|
|
"dose" text,
|
|
"frequency" text,
|
|
"start_date" date,
|
|
"stop_date" date,
|
|
"notes" text,
|
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
CONSTRAINT "medications_name_check" CHECK (length(btrim("name")) > 0),
|
|
CONSTRAINT "medications_kind_check" CHECK ("kind" IN ('medication','supplement')),
|
|
CONSTRAINT "medications_dates_check" CHECK ("stop_date" IS NULL OR "start_date" IS NULL OR "stop_date" >= "start_date")
|
|
);
|
|
--> statement-breakpoint
|
|
CREATE TABLE "health"."nutrition_daily" (
|
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
"date" date NOT NULL CONSTRAINT "nutrition_daily_date_key" UNIQUE,
|
|
"kcal" integer,
|
|
"protein_g" numeric(5,1),
|
|
"notes" text,
|
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
CONSTRAINT "nutrition_daily_kcal_check" CHECK ("kcal" IS NULL OR "kcal" BETWEEN 0 AND 20000),
|
|
CONSTRAINT "nutrition_daily_protein_check" CHECK ("protein_g" IS NULL OR "protein_g" BETWEEN 0 AND 1000),
|
|
CONSTRAINT "nutrition_daily_date_check" CHECK ("date" >= DATE '1900-01-01')
|
|
);
|
|
--> statement-breakpoint
|
|
CREATE TABLE "health"."sleep" (
|
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
"date" date NOT NULL CONSTRAINT "sleep_date_key" UNIQUE,
|
|
"bedtime" timestamp with time zone NOT NULL,
|
|
"wake_time" timestamp with time zone NOT NULL,
|
|
"hours" numeric(4,2) GENERATED ALWAYS AS (round((extract(epoch from (wake_time - bedtime)) / 3600)::numeric, 2)) STORED,
|
|
"notes" text,
|
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
CONSTRAINT "sleep_order_check" CHECK ("wake_time" > "bedtime"),
|
|
CONSTRAINT "sleep_length_check" CHECK ("wake_time" - "bedtime" <= interval '24 hours'),
|
|
CONSTRAINT "sleep_date_check" CHECK ("date" >= DATE '1900-01-01')
|
|
);
|
|
--> statement-breakpoint
|
|
CREATE TABLE "health"."vaccinations" (
|
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
"vaccine" text NOT NULL,
|
|
"target" text,
|
|
"dose_no" integer,
|
|
"date" date NOT NULL,
|
|
"batch_lot" text,
|
|
"provider" text,
|
|
"country" text,
|
|
"notes" text,
|
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
CONSTRAINT "vaccinations_vaccine_check" CHECK (length(btrim("vaccine")) > 0),
|
|
CONSTRAINT "vaccinations_dose_no_check" CHECK ("dose_no" IS NULL OR "dose_no" BETWEEN 1 AND 20),
|
|
CONSTRAINT "vaccinations_country_check" CHECK ("country" IS NULL OR "country" ~ '^[A-Z]{2}$'),
|
|
CONSTRAINT "vaccinations_date_check" CHECK ("date" >= DATE '1900-01-01')
|
|
);
|
|
--> statement-breakpoint
|
|
CREATE TABLE "health"."workouts" (
|
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
"date" date NOT NULL,
|
|
"session_id" uuid,
|
|
"exercise" text NOT NULL,
|
|
"sets" integer,
|
|
"reps" integer,
|
|
"load_kg" numeric(6,2),
|
|
"rpe" numeric(3,1),
|
|
"notes" text,
|
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
CONSTRAINT "workouts_exercise_check" CHECK (length(btrim("exercise")) > 0),
|
|
CONSTRAINT "workouts_sets_check" CHECK ("sets" IS NULL OR "sets" BETWEEN 1 AND 100),
|
|
CONSTRAINT "workouts_reps_check" CHECK ("reps" IS NULL OR "reps" BETWEEN 0 AND 1000),
|
|
CONSTRAINT "workouts_load_check" CHECK ("load_kg" IS NULL OR "load_kg" BETWEEN 0 AND 1000),
|
|
CONSTRAINT "workouts_rpe_check" CHECK ("rpe" IS NULL OR "rpe" BETWEEN 1 AND 10),
|
|
CONSTRAINT "workouts_date_check" CHECK ("date" >= DATE '1900-01-01')
|
|
);
|
|
--> statement-breakpoint
|
|
CREATE INDEX "appointments_date_idx" ON "health"."appointments" ("date");--> statement-breakpoint
|
|
CREATE INDEX "body_measurements_date_idx" ON "health"."body_measurements" ("date");--> statement-breakpoint
|
|
CREATE INDEX "hair_progress_date_idx" ON "health"."hair_progress" ("date");--> statement-breakpoint
|
|
CREATE INDEX "medications_start_date_idx" ON "health"."medications" ("start_date");--> statement-breakpoint
|
|
CREATE INDEX "medications_stop_date_idx" ON "health"."medications" ("stop_date");--> statement-breakpoint
|
|
CREATE INDEX "vaccinations_date_idx" ON "health"."vaccinations" ("date");--> statement-breakpoint
|
|
CREATE INDEX "workouts_date_idx" ON "health"."workouts" ("date");--> statement-breakpoint
|
|
CREATE INDEX "workouts_session_idx" ON "health"."workouts" ("session_id");--> statement-breakpoint
|
|
-- Hand-written below this line (not generated by drizzle-kit).
|
|
-- updated_at triggers, same as every other core table (07_triggers_meta_grants.sql).
|
|
DO $$
|
|
DECLARE t text;
|
|
BEGIN
|
|
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
|
|
FOREACH t IN ARRAY ARRAY['vaccinations','body_measurements','workouts','nutrition_daily','sleep','medications','hair_progress','appointments'] LOOP
|
|
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON health.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
|
|
END LOOP;
|
|
END IF;
|
|
END $$;
|
|
--> statement-breakpoint
|
|
-- Privacy: health data is readable/writable by the app role only. Unlike the other core
|
|
-- schemas it is deliberately NOT granted to personal_ro, and it is not registered in
|
|
-- meta.tables (so the generic list/table GUI never shows it).
|
|
-- Grants only run on prod (migrations run as the schema owner `personal`); the dev DB
|
|
-- (owned by personal_dev) carries no prod grants, matching db-refresh-dev.sh.
|
|
REVOKE ALL ON SCHEMA health FROM PUBLIC;
|
|
--> statement-breakpoint
|
|
DO $$
|
|
BEGIN
|
|
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
|
GRANT USAGE ON SCHEMA health TO personal_app;
|
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA health TO personal_app;
|
|
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA health
|
|
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
|
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA health
|
|
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
|
END IF;
|
|
END $$;
|