- 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
73 lines
2.8 KiB
TypeScript
73 lines
2.8 KiB
TypeScript
/**
|
|
* Import health data (JSON or CSV) into the private `health` schema.
|
|
*
|
|
* bun scripts/health-import.ts data.json # {"vaccinations":[...],"sleep":[...]}
|
|
* bun scripts/health-import.ts --table vaccinations rivm.csv # CSV (header row) or a JSON array
|
|
* bun scripts/health-import.ts --table sleep - < sleep.csv # stdin
|
|
* add --dry-run to validate without writing
|
|
*
|
|
* Column names are the snake_case DB columns (see src/lib/health/tables.ts). Rows with an
|
|
* `id` update that row; nutrition_daily and sleep upsert on `date`; everything else is
|
|
* inserted (importing the same file twice duplicates those rows). All-or-nothing: if any
|
|
* row is invalid, nothing is written. Uses DB_URL (the app role on prod, personal_dev on dev).
|
|
*/
|
|
import { readFile } from 'node:fs/promises';
|
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
|
import postgres from 'postgres';
|
|
import { parseCsv, toPayload, validatePayload, writeRows } from '../src/lib/server/health-import';
|
|
|
|
let dryRun = false;
|
|
let table: string | undefined;
|
|
const files: string[] = [];
|
|
for (let i = 2; i < process.argv.length; i++) {
|
|
const arg = process.argv[i];
|
|
if (arg === '--dry-run') dryRun = true;
|
|
else if (arg === '--table') table = process.argv[++i];
|
|
else if (arg.startsWith('--table=')) table = arg.slice(8);
|
|
else files.push(arg);
|
|
}
|
|
if (!files.length) {
|
|
console.error(
|
|
'usage: bun scripts/health-import.ts [--table <name>] [--dry-run] <file.json|file.csv|->...'
|
|
);
|
|
process.exit(2);
|
|
}
|
|
|
|
const read = (file: string) => (file === '-' ? Bun.stdin.text() : readFile(file, 'utf8'));
|
|
const merged: Record<string, unknown[]> = {};
|
|
try {
|
|
for (const file of files) {
|
|
const text = await read(file);
|
|
const csv = file.toLowerCase().endsWith('.csv') || (file === '-' && !/^\s*[[{]/.test(text));
|
|
const payload = toPayload(csv ? parseCsv(text) : JSON.parse(text), table);
|
|
for (const [t, rows] of Object.entries(payload)) (merged[t] ??= []).push(...(rows ?? []));
|
|
}
|
|
} catch (e) {
|
|
console.error(e instanceof Error ? e.message : e);
|
|
process.exit(1);
|
|
}
|
|
|
|
const { clean, errors } = validatePayload(toPayload(merged));
|
|
if (errors.length) {
|
|
for (const e of errors.slice(0, 50)) console.error(`${e.table} row ${e.row}: ${e.error}`);
|
|
console.error(`${errors.length} invalid row(s); nothing imported.`);
|
|
process.exit(1);
|
|
}
|
|
const summary = Object.entries(clean)
|
|
.map(([t, r]) => `${t}: ${r.length}`)
|
|
.join(', ');
|
|
if (dryRun) {
|
|
console.log(`Valid (dry run, nothing written): ${summary}`);
|
|
process.exit(0);
|
|
}
|
|
|
|
if (!process.env.DB_URL) throw new Error('DB_URL is required');
|
|
const client = postgres(process.env.DB_URL, { max: 1, onnotice: () => {} });
|
|
try {
|
|
const counts = await writeRows(drizzle({ client }), clean);
|
|
for (const [t, c] of Object.entries(counts))
|
|
console.log(`${t}: ${c.inserted} inserted, ${c.updated} updated`);
|
|
} finally {
|
|
await client.end();
|
|
}
|