add pglite with migration runner, zod schemas, db hook

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 10:53:25 +01:00
parent 2c9e94247e
commit e8e2f8b3e8
6 changed files with 223 additions and 2 deletions

View File

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

View File

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

41
src/shared/db/client.ts Normal file
View File

@@ -0,0 +1,41 @@
import { PGlite } from "@electric-sql/pglite"
let _db: PGlite | null = null
export async function getDb(): Promise<PGlite> {
if (!_db) {
_db = new PGlite("idb://therapyfinder")
await runMigrations(_db)
}
return _db
}
async function runMigrations(db: PGlite): Promise<void> {
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])
}
}

View File

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

80
src/shared/db/schema.ts Normal file
View File

@@ -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<typeof prozessSchrittEnum>
export const kontaktKanalEnum = z.enum([
"telefon",
"email",
"online_formular",
"persoenlich",
])
export type KontaktKanal = z.infer<typeof kontaktKanalEnum>
export const kontaktErgebnisEnum = z.enum([
"keine_antwort",
"absage",
"warteliste",
"zusage",
])
export type KontaktErgebnis = z.infer<typeof kontaktErgebnisEnum>
export const therapieformEnum = z.enum([
"verhaltenstherapie",
"tiefenpsychologisch",
"analytisch",
"systemisch",
])
export type Therapieform = z.infer<typeof therapieformEnum>
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(),
})

View File

@@ -0,0 +1,37 @@
import { useCallback, useEffect, useState } from "react"
import { getDb } from "../db/client"
export function useDbQuery<T>(
query: string,
params: unknown[] = [],
deps: unknown[] = [],
) {
const [data, setData] = useState<T[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(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)
}