diff --git a/drizzle/20261024000114_health/migration.sql b/drizzle/20261024002120_health/migration.sql similarity index 90% rename from drizzle/20261024000114_health/migration.sql rename to drizzle/20261024002120_health/migration.sql index 53be8b1..08d1c33 100644 --- a/drizzle/20261024000114_health/migration.sql +++ b/drizzle/20261024002120_health/migration.sql @@ -28,17 +28,6 @@ CREATE TABLE "health"."body_measurements" ( 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, @@ -122,19 +111,18 @@ CREATE TABLE "health"."workouts" ( --> 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 +CREATE INDEX "workouts_session_idx" ON "health"."workouts" ("session_id"); -- 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 + FOREACH t IN ARRAY ARRAY['vaccinations','body_measurements','workouts','nutrition_daily','sleep','medications','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; diff --git a/drizzle/20261024000114_health/snapshot.json b/drizzle/20261024002120_health/snapshot.json similarity index 98% rename from drizzle/20261024000114_health/snapshot.json rename to drizzle/20261024002120_health/snapshot.json index 54daeaf..8ae2944 100644 --- a/drizzle/20261024000114_health/snapshot.json +++ b/drizzle/20261024002120_health/snapshot.json @@ -1,7 +1,7 @@ { "version": "8", "dialect": "postgres", - "id": "a9c8c2d8-9454-4842-bd66-3830867023ed", + "id": "b07ff36e-fc9d-4dd4-a267-448a6e807617", "prevIds": [ "f166e058-a62b-4994-b4ba-12ee871a8bb0" ], @@ -423,12 +423,6 @@ "entityType": "tables", "schema": "health" }, - { - "isRlsEnabled": false, - "name": "hair_progress", - "entityType": "tables", - "schema": "health" - }, { "isRlsEnabled": false, "name": "medications", @@ -7463,84 +7457,6 @@ "schema": "health", "table": "body_measurements" }, - { - "type": "uuid", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": "gen_random_uuid()", - "generated": null, - "identity": null, - "name": "id", - "entityType": "columns", - "schema": "health", - "table": "hair_progress" - }, - { - "type": "date", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "date", - "entityType": "columns", - "schema": "health", - "table": "hair_progress" - }, - { - "type": "text", - "typeSchema": null, - "notNull": false, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "photo_ref", - "entityType": "columns", - "schema": "health", - "table": "hair_progress" - }, - { - "type": "text", - "typeSchema": null, - "notNull": false, - "dimensions": 0, - "default": null, - "generated": null, - "identity": null, - "name": "notes", - "entityType": "columns", - "schema": "health", - "table": "hair_progress" - }, - { - "type": "timestamp with time zone", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": "now()", - "generated": null, - "identity": null, - "name": "created_at", - "entityType": "columns", - "schema": "health", - "table": "hair_progress" - }, - { - "type": "timestamp with time zone", - "typeSchema": null, - "notNull": true, - "dimensions": 0, - "default": "now()", - "generated": null, - "identity": null, - "name": "updated_at", - "entityType": "columns", - "schema": "health", - "table": "hair_progress" - }, { "type": "uuid", "typeSchema": null, @@ -9114,27 +9030,6 @@ "schema": "health", "table": "body_measurements" }, - { - "nameExplicit": true, - "columns": [ - { - "value": "date", - "isExpression": false, - "asc": true, - "nullsFirst": false, - "opclass": null - } - ], - "isUnique": false, - "where": null, - "with": "", - "method": "btree", - "concurrently": false, - "name": "hair_progress_date_idx", - "entityType": "indexes", - "schema": "health", - "table": "hair_progress" - }, { "nameExplicit": true, "columns": [ @@ -10888,16 +10783,6 @@ "table": "body_measurements", "entityType": "pks" }, - { - "columns": [ - "id" - ], - "nameExplicit": false, - "name": "hair_progress_pkey", - "schema": "health", - "table": "hair_progress", - "entityType": "pks" - }, { "columns": [ "id" @@ -11560,20 +11445,6 @@ "schema": "health", "table": "body_measurements" }, - { - "value": "\"photo_ref\" IS NULL OR (\"photo_ref\" !~* '^[a-z][a-z0-9+.-]*://' AND \"photo_ref\" !~ '^//')", - "name": "hair_progress_photo_ref_check", - "entityType": "checks", - "schema": "health", - "table": "hair_progress" - }, - { - "value": "\"date\" >= DATE '1900-01-01'", - "name": "hair_progress_date_check", - "entityType": "checks", - "schema": "health", - "table": "hair_progress" - }, { "value": "length(btrim(\"name\")) > 0", "name": "medications_name_check", diff --git a/src/lib/health/tables.ts b/src/lib/health/tables.ts index ebae1c0..c102ab0 100644 --- a/src/lib/health/tables.ts +++ b/src/lib/health/tables.ts @@ -111,20 +111,7 @@ export const healthTables = { notes ] }, - hair_progress: { - label: 'Hair progress', - fields: [ - date, - { - key: 'photo_ref', - label: 'Photo key (private storage, not a URL)', - type: 'text', - maxLength: 500, - placeholder: 'health/hair/2026-10-24-top.jpg' - }, - notes - ] - }, + appointments: { label: 'Appointments & conditions', fields: [ diff --git a/src/lib/server/db/schema/health.ts b/src/lib/server/db/schema/health.ts index 3af3849..abcbeb9 100644 --- a/src/lib/server/db/schema/health.ts +++ b/src/lib/server/db/schema/health.ts @@ -158,26 +158,6 @@ export const healthMedications = healthSchema.table( ] ); -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', { diff --git a/src/lib/server/health-import.test.ts b/src/lib/server/health-import.test.ts index 24c5f85..893dbb0 100644 --- a/src/lib/server/health-import.test.ts +++ b/src/lib/server/health-import.test.ts @@ -25,9 +25,6 @@ describe('health import', () => { expect(cleanRow('workouts', { date: '2026-01-02', exercise: 'squat', bogus: 1 }).ok).toBe( false ); - expect(cleanRow('hair_progress', { date: '2026-01-02', photo_ref: 'https://x/y.jpg' }).ok).toBe( - false - ); expect( cleanRow('vaccinations', { vaccine: 'X', date: '2026-01-02', country: 'nl' }) ).toMatchObject({ ok: true, row: { values: { country: 'NL' } } }); diff --git a/src/lib/server/health-import.ts b/src/lib/server/health-import.ts index 6df3306..2e0ca82 100644 --- a/src/lib/server/health-import.ts +++ b/src/lib/server/health-import.ts @@ -6,7 +6,6 @@ import type { PgAsyncDatabase } from 'drizzle-orm/pg-core'; import { healthAppointments, healthBodyMeasurements, - healthHairProgress, healthMedications, healthNutritionDaily, healthSleep, @@ -22,7 +21,6 @@ export const drizzleTables = { nutrition_daily: healthNutritionDaily, sleep: healthSleep, medications: healthMedications, - hair_progress: healthHairProgress, appointments: healthAppointments } as const; @@ -184,12 +182,6 @@ export function cleanRow(table: HealthTable, input: Row): RowResult { return { ok: false, error: 'stop_date is before start_date' }; } if (table === 'appointments') v.type ??= 'appointment'; - if ( - table === 'hair_progress' && - typeof v.photo_ref === 'string' && - /^([a-z][a-z0-9+.-]*:|\/\/)/i.test(v.photo_ref) - ) - return { ok: false, error: 'photo_ref must be a private storage key/path, not a URL' }; if (table === 'sleep') { const day = String(v.date); const wakeRaw = String(input.wake_time).trim(); diff --git a/src/lib/server/owner.test.ts b/src/lib/server/owner.test.ts index 7f6c64b..80927f5 100644 --- a/src/lib/server/owner.test.ts +++ b/src/lib/server/owner.test.ts @@ -5,11 +5,10 @@ const event = (user: Partial> | null) => ({ locals: { user, session: null } }) as unknown as { locals: App.Locals }; describe('owner gate', () => { - it('needs admin role and the owner email', () => { + it('is only the owner account', () => { expect(isOwner(event(null))).toBe(false); expect(isOwner(event({ role: 'admin', email: 'someone@example.com' }))).toBe(false); - expect(isOwner(event({ role: 'user', email: 'stan@stanrunge.dev' }))).toBe(false); - expect(isOwner(event({ role: 'admin', email: 'Stan@StanRunge.dev' }))).toBe(true); + expect(isOwner(event({ role: 'user', email: 'Stan@StanRunge.dev' }))).toBe(true); }); it('matches owner-only paths', () => { expect(isOwnerOnlyPath('/trackers/health')).toBe(true); diff --git a/src/lib/server/owner.ts b/src/lib/server/owner.ts index 30149f8..901dbf9 100644 --- a/src/lib/server/owner.ts +++ b/src/lib/server/owner.ts @@ -1,15 +1,15 @@ import { error } from '@sveltejs/kit'; -// The site has one owner (Stan). Owner-only data, such as the health tracker, needs both -// the admin role and the owner's account email, so granting someone else `admin` (e.g. to -// moderate Animaldex) never exposes it. Override with OWNER_EMAIL if the address changes. -const OWNER_EMAIL = (process.env.OWNER_EMAIL || 'stan@stanrunge.dev').toLowerCase(); +// The site has exactly one owner: Stan's account (prod auth.user id tfn6OZDOpiQvMARIH65HQ2XJuaVvmGEe). +// Owner-only data, such as the health tracker, is gated on that account's email, not on +// a role, so other users (now or later, e.g. on a public tracking project) never see it. +// Override with OWNER_EMAIL if the address ever changes. +const OWNER_EMAIL = (process.env.OWNER_EMAIL || 'stan@stanrunge.dev').trim().toLowerCase(); type OwnerEvent = { locals: App.Locals }; export function isOwner(event: OwnerEvent): boolean { - const user = event.locals.user; - return !!user && user.role === 'admin' && user.email?.toLowerCase() === OWNER_EMAIL; + return event.locals.user?.email?.trim().toLowerCase() === OWNER_EMAIL; } /** Owner-only routes answer 404 to everyone else, so they don't reveal that they exist. */ diff --git a/src/routes/trackers/health/+page.server.ts b/src/routes/trackers/health/+page.server.ts index 0a69d01..bdab44d 100644 --- a/src/routes/trackers/health/+page.server.ts +++ b/src/routes/trackers/health/+page.server.ts @@ -2,7 +2,6 @@ import { db } from '#lib/server/db/index.js'; import { healthAppointments, healthBodyMeasurements, - healthHairProgress, healthMedications, healthNutritionDaily, healthSleep, @@ -30,54 +29,44 @@ function daysAgo(day: string, n: number): string { export const load: PageServerLoad = async (event) => { requireOwner(event); const now = today(); - const [ - measurements, - workouts, - nutrition, - sleep, - medications, - vaccinations, - upcoming, - past, - hair - ] = await Promise.all([ - db - .select() - .from(healthBodyMeasurements) - .orderBy(asc(healthBodyMeasurements.date), asc(healthBodyMeasurements.createdAt)), - db - .select() - .from(healthWorkouts) - .orderBy(desc(healthWorkouts.date), asc(healthWorkouts.createdAt)) - .limit(30), - db - .select() - .from(healthNutritionDaily) - .where(gte(healthNutritionDaily.date, daysAgo(now, 6))) - .orderBy(desc(healthNutritionDaily.date)), - db - .select() - .from(healthSleep) - .where(gte(healthSleep.date, daysAgo(now, 6))) - .orderBy(desc(healthSleep.date)), - db.select().from(healthMedications).orderBy(asc(healthMedications.name)), - db - .select() - .from(healthVaccinations) - .orderBy(desc(healthVaccinations.date), asc(healthVaccinations.vaccine)), - db - .select() - .from(healthAppointments) - .where(gte(healthAppointments.date, now)) - .orderBy(asc(healthAppointments.date)), - db - .select() - .from(healthAppointments) - .where(lt(healthAppointments.date, now)) - .orderBy(desc(healthAppointments.date)) - .limit(10), - db.select().from(healthHairProgress).orderBy(desc(healthHairProgress.date)).limit(10) - ]); + const [measurements, workouts, nutrition, sleep, medications, vaccinations, upcoming, past] = + await Promise.all([ + db + .select() + .from(healthBodyMeasurements) + .orderBy(asc(healthBodyMeasurements.date), asc(healthBodyMeasurements.createdAt)), + db + .select() + .from(healthWorkouts) + .orderBy(desc(healthWorkouts.date), asc(healthWorkouts.createdAt)) + .limit(30), + db + .select() + .from(healthNutritionDaily) + .where(gte(healthNutritionDaily.date, daysAgo(now, 6))) + .orderBy(desc(healthNutritionDaily.date)), + db + .select() + .from(healthSleep) + .where(gte(healthSleep.date, daysAgo(now, 6))) + .orderBy(desc(healthSleep.date)), + db.select().from(healthMedications).orderBy(asc(healthMedications.name)), + db + .select() + .from(healthVaccinations) + .orderBy(desc(healthVaccinations.date), asc(healthVaccinations.vaccine)), + db + .select() + .from(healthAppointments) + .where(gte(healthAppointments.date, now)) + .orderBy(asc(healthAppointments.date)), + db + .select() + .from(healthAppointments) + .where(lt(healthAppointments.date, now)) + .orderBy(desc(healthAppointments.date)) + .limit(10) + ]); return { today: now, measurements, @@ -87,7 +76,6 @@ export const load: PageServerLoad = async (event) => { medications, vaccinations, appointments: { upcoming, past }, - hair, seo: { title: 'Health', description: 'Private health log.', index: false } }; }; diff --git a/src/routes/trackers/health/+page.svelte b/src/routes/trackers/health/+page.svelte index a35cdef..0bd272d 100644 --- a/src/routes/trackers/health/+page.svelte +++ b/src/routes/trackers/health/+page.svelte @@ -340,26 +340,6 @@ {/if} -
- {@render sectionHeader('Hair progress', 'hair_progress')} - {#if data.hair.length} - - {:else} - {@render empty('No hair check-ins yet.')} - {/if} -
-
{@render sectionHeader('Vaccinations', 'vaccinations')} {#if data.vaccinations.length}