health: drop hair_progress; owner = Stan's account email only
All checks were successful
Deploy production / deploy (push) Successful in 2m22s

Regenerated the (not yet deployed) health migration without hair_progress.
This commit is contained in:
2026-10-24 00:22:24 +00:00
parent 8275f1fb22
commit 8aa689af64
10 changed files with 50 additions and 268 deletions

View File

@@ -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;

View File

@@ -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",

View File

@@ -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: [

View File

@@ -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',
{

View File

@@ -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' } } });

View File

@@ -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();

View File

@@ -5,11 +5,10 @@ const event = (user: Partial<NonNullable<App.Locals['user']>> | 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);

View File

@@ -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. */

View File

@@ -2,7 +2,6 @@ import { db } from '#lib/server/db/index.js';
import {
healthAppointments,
healthBodyMeasurements,
healthHairProgress,
healthMedications,
healthNutritionDaily,
healthSleep,
@@ -30,17 +29,8 @@ 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([
const [measurements, workouts, nutrition, sleep, medications, vaccinations, upcoming, past] =
await Promise.all([
db
.select()
.from(healthBodyMeasurements)
@@ -75,8 +65,7 @@ export const load: PageServerLoad = async (event) => {
.from(healthAppointments)
.where(lt(healthAppointments.date, now))
.orderBy(desc(healthAppointments.date))
.limit(10),
db.select().from(healthHairProgress).orderBy(desc(healthHairProgress.date)).limit(10)
.limit(10)
]);
return {
today: now,
@@ -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 }
};
};

View File

@@ -340,26 +340,6 @@
{/if}
</section>
<section class="panel">
{@render sectionHeader('Hair progress', 'hair_progress')}
{#if data.hair.length}
<ul class="divide-y divide-[var(--site-border)] text-sm">
{#each data.hair as h (h.id)}
<li class="flex items-center justify-between gap-2 py-1.5">
<span
>{dayLabel(h.date)}{h.photoRef ? ` · ${h.photoRef}` : ''}{h.notes
? ` · ${h.notes}`
: ''}</span
>
{@render rowActions('hair_progress', h)}
</li>
{/each}
</ul>
{:else}
{@render empty('No hair check-ins yet.')}
{/if}
</section>
<section class="panel lg:col-span-3">
{@render sectionHeader('Vaccinations', 'vaccinations')}
{#if data.vaccinations.length}