From e8e2f8b3e85aa003c6b8d32a9401208e080eb62b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Wed, 11 Mar 2026 10:53:25 +0100 Subject: [PATCH] add pglite with migration runner, zod schemas, db hook Co-Authored-By: Claude Opus 4.6 --- bun.lock | 10 +++- package.json | 4 +- src/shared/db/client.ts | 41 ++++++++++++++ src/shared/db/migrations/001_init.sql | 53 ++++++++++++++++++ src/shared/db/schema.ts | 80 +++++++++++++++++++++++++++ src/shared/hooks/use-db.ts | 37 +++++++++++++ 6 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 src/shared/db/client.ts create mode 100644 src/shared/db/migrations/001_init.sql create mode 100644 src/shared/db/schema.ts create mode 100644 src/shared/hooks/use-db.ts diff --git a/bun.lock b/bun.lock index cf60695..fc1bccb 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "therapyfinder", "dependencies": { + "@electric-sql/pglite": "^0.3.16", "@tailwindcss/vite": "^4.2.1", "@tanstack/react-router": "^1.166.7", "@tanstack/router-plugin": "^1.166.7", @@ -16,6 +17,7 @@ "react-dom": "^19.2.4", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", + "zod": "^4.3.6", }, "devDependencies": { "@biomejs/biome": "^2.4.6", @@ -89,6 +91,8 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.6", "", { "os": "win32", "cpu": "x64" }, "sha512-7++XhnsPlr1HDbor5amovPjOH6vsrFOCdp93iKXhFn6bcMUI6soodj3WWKfgEO6JosKU1W5n3uky3WW9RlRjTg=="], + "@electric-sql/pglite": ["@electric-sql/pglite@0.3.16", "", {}, "sha512-mZkZfOd9OqTMHsK+1cje8OSzfAQcpD7JmILXTl5ahdempjUDdmg4euf1biDex5/LfQIDJ3gvCu6qDgdnDxfJmA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], @@ -603,7 +607,7 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], @@ -617,6 +621,10 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], diff --git a/package.json b/package.json index 4cae36e..95278c9 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "format": "biome check --write ." }, "dependencies": { + "@electric-sql/pglite": "^0.3.16", "@tailwindcss/vite": "^4.2.1", "@tanstack/react-router": "^1.166.7", "@tanstack/router-plugin": "^1.166.7", @@ -23,7 +24,8 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "tailwind-merge": "^3.5.0", - "tailwindcss": "^4.2.1" + "tailwindcss": "^4.2.1", + "zod": "^4.3.6" }, "devDependencies": { "@biomejs/biome": "^2.4.6", diff --git a/src/shared/db/client.ts b/src/shared/db/client.ts new file mode 100644 index 0000000..48c6b35 --- /dev/null +++ b/src/shared/db/client.ts @@ -0,0 +1,41 @@ +import { PGlite } from "@electric-sql/pglite" + +let _db: PGlite | null = null + +export async function getDb(): Promise { + if (!_db) { + _db = new PGlite("idb://therapyfinder") + await runMigrations(_db) + } + return _db +} + +async function runMigrations(db: PGlite): Promise { + await db.exec(` + CREATE TABLE IF NOT EXISTS _migrations ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `) + + const migrations = import.meta.glob("./migrations/*.sql", { + query: "?raw", + import: "default", + }) + + const sortedPaths = Object.keys(migrations).sort() + + for (const path of sortedPaths) { + const name = path.split("/").pop()! + const result = await db.query( + "SELECT 1 FROM _migrations WHERE name = $1", + [name], + ) + if (result.rows.length > 0) continue + + const sql = (await migrations[path]()) as string + await db.exec(sql) + await db.query("INSERT INTO _migrations (name) VALUES ($1)", [name]) + } +} diff --git a/src/shared/db/migrations/001_init.sql b/src/shared/db/migrations/001_init.sql new file mode 100644 index 0000000..43d649b --- /dev/null +++ b/src/shared/db/migrations/001_init.sql @@ -0,0 +1,53 @@ +-- User profile (single row per device) +CREATE TABLE nutzer ( + id SERIAL PRIMARY KEY, + name TEXT, + plz TEXT, + ort TEXT, + krankenkasse TEXT, + aktueller_schritt TEXT NOT NULL DEFAULT 'neu', + dringlichkeitscode BOOLEAN NOT NULL DEFAULT FALSE, + dringlichkeitscode_datum DATE, + tss_beantragt BOOLEAN NOT NULL DEFAULT FALSE, + tss_beantragt_datum DATE, + erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(), + aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Therapist contacts +CREATE TABLE therapeut ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + adresse TEXT, + plz TEXT, + stadt TEXT, + telefon TEXT, + email TEXT, + website TEXT, + therapieform TEXT, + kassenzulassung TEXT DEFAULT 'gkv', + erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Contact attempts +CREATE TABLE kontakt ( + id SERIAL PRIMARY KEY, + therapeut_id INTEGER NOT NULL REFERENCES therapeut(id) ON DELETE CASCADE, + datum DATE NOT NULL DEFAULT CURRENT_DATE, + kanal TEXT NOT NULL DEFAULT 'telefon', + ergebnis TEXT NOT NULL DEFAULT 'keine_antwort', + notiz TEXT, + antwort_datum DATE, + erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Sprechstunden visits +CREATE TABLE sprechstunde ( + id SERIAL PRIMARY KEY, + therapeut_id INTEGER NOT NULL REFERENCES therapeut(id) ON DELETE CASCADE, + datum DATE NOT NULL, + ergebnis TEXT, + diagnose TEXT, + dringlichkeitscode BOOLEAN NOT NULL DEFAULT FALSE, + erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/src/shared/db/schema.ts b/src/shared/db/schema.ts new file mode 100644 index 0000000..30e0130 --- /dev/null +++ b/src/shared/db/schema.ts @@ -0,0 +1,80 @@ +import { z } from "zod" + +export const prozessSchrittEnum = z.enum([ + "neu", + "sprechstunde_absolviert", + "diagnose_erhalten", + "tss_beantragt", + "eigensuche", + "antrag_gestellt", +]) +export type ProzessSchritt = z.infer + +export const kontaktKanalEnum = z.enum([ + "telefon", + "email", + "online_formular", + "persoenlich", +]) +export type KontaktKanal = z.infer + +export const kontaktErgebnisEnum = z.enum([ + "keine_antwort", + "absage", + "warteliste", + "zusage", +]) +export type KontaktErgebnis = z.infer + +export const therapieformEnum = z.enum([ + "verhaltenstherapie", + "tiefenpsychologisch", + "analytisch", + "systemisch", +]) +export type Therapieform = z.infer + +export const nutzerSchema = z.object({ + id: z.number(), + name: z.string().nullable(), + plz: z.string().nullable(), + ort: z.string().nullable(), + krankenkasse: z.string().nullable(), + aktueller_schritt: prozessSchrittEnum, + dringlichkeitscode: z.boolean(), + dringlichkeitscode_datum: z.string().nullable(), + tss_beantragt: z.boolean(), + tss_beantragt_datum: z.string().nullable(), +}) + +export const therapeutSchema = z.object({ + id: z.number(), + name: z.string(), + adresse: z.string().nullable(), + plz: z.string().nullable(), + stadt: z.string().nullable(), + telefon: z.string().nullable(), + email: z.string().nullable(), + website: z.string().nullable(), + therapieform: z.string().nullable(), + kassenzulassung: z.string().nullable(), +}) + +export const kontaktSchema = z.object({ + id: z.number(), + therapeut_id: z.number(), + datum: z.string(), + kanal: kontaktKanalEnum, + ergebnis: kontaktErgebnisEnum, + notiz: z.string().nullable(), + antwort_datum: z.string().nullable(), +}) + +export const sprechstundeSchema = z.object({ + id: z.number(), + therapeut_id: z.number(), + datum: z.string(), + ergebnis: z.string().nullable(), + diagnose: z.string().nullable(), + dringlichkeitscode: z.boolean(), +}) diff --git a/src/shared/hooks/use-db.ts b/src/shared/hooks/use-db.ts new file mode 100644 index 0000000..845fa90 --- /dev/null +++ b/src/shared/hooks/use-db.ts @@ -0,0 +1,37 @@ +import { useCallback, useEffect, useState } from "react" +import { getDb } from "../db/client" + +export function useDbQuery( + query: string, + params: unknown[] = [], + deps: unknown[] = [], +) { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const refetch = useCallback(async () => { + setLoading(true) + try { + const db = await getDb() + const result = await db.query(query, params) + setData(result.rows as T[]) + setError(null) + } catch (e) { + setError(e instanceof Error ? e : new Error(String(e))) + } finally { + setLoading(false) + } + }, [query, ...deps]) + + useEffect(() => { + refetch() + }, [refetch]) + + return { data, loading, error, refetch } +} + +export async function dbExec(query: string, params: unknown[] = []) { + const db = await getDb() + return db.query(query, params) +}