Migrate: PocketBase → SvelteKit + better-sqlite3 + JWT

Vollständige Migration weg von PocketBase. Neuer Stack:
- better-sqlite3 (WAL-Mode, direkte SQLite-Abfragen)
- jose (JWT HS256, 30 Tage Laufzeit)
- bcryptjs (Passwort-Hashing, cost 12)

Neue Dateien:
- src/lib/server/db.ts    → SQLite-Singleton + Schema + Helpers
- src/lib/server/auth.ts  → JWT sign/verify, bcrypt, Bearer-Token
- src/lib/user.ts         → Svelte-Store (ersetzt pb.authStore)
- src/lib/api.ts          → fetch()-Wrapper (ersetzt pb.collection())
- src/app.d.ts            → App.Locals TypeScript-Deklaration
- 30 neue API-Routes unter src/routes/api/

Entfernt:
- Abhängigkeit von pocketbase npm-Paket (bleibt im package.json bis
  alle Referenzen bereinigt sind)
- PocketBase-Container aus docker-compose.yml
- Migrations und Hooks aus Deploy-Pipeline

Docker: Ein einziger Container, SQLite-Volume unter /data/
Makefile: PocketBase-spezifische Targets entfernt
seed.js: Komplett neu für neue REST-API
This commit is contained in:
rene 2026-05-21 21:55:04 +02:00
parent 61c430f2e6
commit 39981c0d17
58 changed files with 2313 additions and 651 deletions

116
Makefile
View file

@ -1,22 +1,18 @@
# ==============================================================
# VEREINS.HAUS — Makefile
# Deploy-Strategie: SSH zur DS, Docker Compose
# Stack: SvelteKit + better-sqlite3 (kein PocketBase)
# ==============================================================
DS_HOST := ds
DS_IP := 10.47.11.10
DS_SSH_PORT := 4711
DS_PATH := /volume1/docker/vereinshaus
CONTAINER_PB := vereinshaus-pocketbase
CONTAINER_APP := vereinshaus-app
DOCKER := sudo /usr/local/bin/docker
STAGING_PATH := /volume1/docker/vereinshaus-staging
CONTAINER_PB_STAGING := vereinshaus-staging-pocketbase
CONTAINER_APP_STAGING:= vereinshaus-staging-app
STAGING_PB_URL := http://localhost:8091
STAGING_MIGRATIONS := $(STAGING_PATH)/pocketbase/migrations
STAGING_HOOKS := $(STAGING_PATH)/pocketbase/data/pb_hooks
STAGING_PATH := /volume1/docker/vereinshaus-staging
CONTAINER_APP_STAGING := vereinshaus-staging-app
TAR_EXCLUDE := --exclude='.git' \
--exclude='./app/node_modules' \
@ -25,13 +21,8 @@ TAR_EXCLUDE := --exclude='.git' \
--exclude='./.env' \
--exclude='./.DS_Store'
HOOKS_SRC := pocketbase/pb_hooks
HOOKS_DST := /volume1/docker/vereinshaus/pocketbase/data/pb_hooks
MIGRATIONS_SRC := pocketbase/pb_migrations
MIGRATIONS_DST := /volume1/docker/vereinshaus/pocketbase/migrations
.PHONY: help check-ssh start stop restart status logs logs-f logs-app \
shell-pb pb-admin deploy
.PHONY: help check-ssh start stop restart status logs logs-app logs-f deploy \
staging-deploy staging-reset staging-seed staging-logs staging-status staging-stop
# ----------------------------------------------------------
# Hilfe
@ -40,17 +31,17 @@ help:
@echo ""
@echo " vereins.haus — verfügbare Befehle:"
@echo ""
@echo " make deploy App bauen + zur DS übertragen + Container neu starten"
@echo " make start Alle Container starten"
@echo " make stop Alle Container stoppen"
@echo " make restart Alle Container neu starten"
@echo " make status Container-Status anzeigen"
@echo " make deploy App bauen + zur DS übertragen + Container neu starten"
@echo " make start Container starten"
@echo " make stop Container stoppen"
@echo " make restart Container neu starten"
@echo " make status Container-Status anzeigen"
@echo " make logs App-Logs (100 Zeilen)"
@echo " make logs-f App Live-Log"
@echo ""
@echo " make logs PocketBase-Logs (100 Zeilen)"
@echo " make logs-app App-Logs (100 Zeilen)"
@echo " make logs-f PocketBase Live-Log"
@echo " make shell-pb Shell in PocketBase-Container"
@echo " make pb-admin PocketBase Admin-URL anzeigen"
@echo " make staging-deploy Staging deployen"
@echo " make staging-seed Testdaten einfügen"
@echo " make staging-reset Staging-DB löschen (Neustart)"
@echo ""
# ----------------------------------------------------------
@ -65,7 +56,7 @@ check-ssh:
fi
# ----------------------------------------------------------
# DEPLOY
# DEPLOY (Production)
# ----------------------------------------------------------
deploy: check-ssh
@echo "→ Sync zu DS..."
@ -74,23 +65,6 @@ deploy: check-ssh
@if [ -f .env ]; then \
cat .env | ssh $(DS_HOST) "cat > $(DS_PATH)/.env"; \
fi
@echo "→ PocketBase Hooks synchronisieren..."
@if ls $(HOOKS_SRC)/*.pb.js 2>/dev/null | grep -q .; then \
for f in $(HOOKS_SRC)/*.pb.js; do \
cat "$$f" | ssh $(DS_HOST) "cat > $(HOOKS_DST)/$$(basename $$f)"; \
done; \
fi
@echo "→ PocketBase Migrations synchronisieren (nur neue)..."
@ssh $(DS_HOST) "mkdir -p $(MIGRATIONS_DST)"
@if ls $(MIGRATIONS_SRC)/*.js 2>/dev/null | grep -q .; then \
for f in $(MIGRATIONS_SRC)/*.js; do \
fname=$$(basename "$$f"); \
if ! ssh $(DS_HOST) "test -f $(MIGRATIONS_DST)/$$fname" 2>/dev/null; then \
cat "$$f" | ssh $(DS_HOST) "cat > $(MIGRATIONS_DST)/$$fname"; \
echo "$$fname"; \
fi; \
done; \
fi
@echo "→ Docker rebuild + restart..."
@ssh $(DS_HOST) " \
cd $(DS_PATH) && \
@ -98,7 +72,7 @@ deploy: check-ssh
$(DOCKER) compose build app && \
$(DOCKER) compose up -d"
@echo " ✓ Deploy fertig."
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_APP) --tail=10"
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_APP) --tail=15"
# ----------------------------------------------------------
# Container-Steuerung
@ -124,27 +98,16 @@ status: check-ssh
# Logs
# ----------------------------------------------------------
logs: check-ssh
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_PB) --tail=100"
logs-app: check-ssh
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_APP) --tail=100"
logs-app: logs
logs-f: check-ssh
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_PB) -f"
# ----------------------------------------------------------
# Shell + Admin
# ----------------------------------------------------------
shell-pb: check-ssh
@ssh -t $(DS_HOST) "$(DOCKER) exec -it $(CONTAINER_PB) sh"
pb-admin:
@echo " PocketBase Admin: https://api.vereins.haus/_/"
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_APP) -f"
# ==============================================================
# STAGING
# ==============================================================
.PHONY: staging-deploy staging-reset staging-seed staging-logs staging-status staging-stop
staging-deploy: check-ssh
@echo "→ Sync zu DS (Staging)..."
@ -153,20 +116,6 @@ staging-deploy: check-ssh
@if [ -f .env ]; then \
cat .env | ssh $(DS_HOST) "cat > $(STAGING_PATH)/.env"; \
fi
@echo "→ Hooks synchronisieren (Staging, vollständig)..."
@ssh $(DS_HOST) "mkdir -p $(STAGING_HOOKS)"
@if ls $(HOOKS_SRC)/*.pb.js 2>/dev/null | grep -q .; then \
for f in $(HOOKS_SRC)/*.pb.js; do \
cat "$$f" | ssh $(DS_HOST) "cat > $(STAGING_HOOKS)/$$(basename $$f)"; \
done; \
fi
@echo "→ Migrations synchronisieren (Staging, immer aktuell)..."
@ssh $(DS_HOST) "mkdir -p $(STAGING_MIGRATIONS)"
@if ls $(MIGRATIONS_SRC)/*.js 2>/dev/null | grep -q .; then \
for f in $(MIGRATIONS_SRC)/*.js; do \
cat "$$f" | ssh $(DS_HOST) "cat > $(STAGING_MIGRATIONS)/$$(basename $$f)"; \
done; \
fi
@echo "→ Docker rebuild + restart (Staging)..."
@ssh $(DS_HOST) " \
cd $(STAGING_PATH) && \
@ -174,31 +123,26 @@ staging-deploy: check-ssh
$(DOCKER) compose -f docker-compose.staging.yml build app-staging && \
$(DOCKER) compose -f docker-compose.staging.yml up -d"
@echo " ✓ Staging bereit."
@echo " App: https://staging.vereins.haus"
@echo " PocketBase: https://api-staging.vereins.haus/_/"
@echo " App: https://staging.vereins.haus"
# Löscht alle PB-Daten auf Staging → Migrations laufen frisch durch
# Löscht die SQLite-DB auf Staging → frischer Start
# Danach: make staging-deploy && make staging-seed
staging-reset: check-ssh staging-stop
@echo "→ PocketBase-Daten auf Staging löschen..."
@ssh $(DS_HOST) "rm -rf \
$(STAGING_PATH)/pocketbase/data/storage \
'$(STAGING_PATH)/pocketbase/data/data.db' \
'$(STAGING_PATH)/pocketbase/data/data.db-wal' \
'$(STAGING_PATH)/pocketbase/data/data.db-shm' \
$(STAGING_PATH)/pocketbase/migrations"
@echo "→ Alle Hooks auf Staging löschen (werden via staging-deploy neu geschrieben)..."
@ssh $(DS_HOST) "rm -f $(STAGING_HOOKS)/*.pb.js"
@echo "→ Staging-Daten löschen..."
@ssh $(DS_HOST) "rm -f \
$(STAGING_PATH)/data/vereinshaus.db \
$(STAGING_PATH)/data/vereinshaus.db-wal \
$(STAGING_PATH)/data/vereinshaus.db-shm && \
rm -rf $(STAGING_PATH)/data/uploads"
@echo " ✓ Reset fertig. Jetzt: make staging-deploy && make staging-seed"
staging-seed:
@echo "→ Testdaten in Staging einfügen..."
@echo " Voraussetzung: PB_EMAIL + PB_PASSWORD in .env gesetzt (Staging-Superuser)"
@if [ -f .env ]; then \
export $$(grep -v '^#' .env | xargs) && \
PB_URL=https://api-staging.vereins.haus node scripts/seed.js; \
APP_URL=https://staging.vereins.haus node scripts/seed.js; \
else \
PB_URL=https://api-staging.vereins.haus node scripts/seed.js; \
APP_URL=https://staging.vereins.haus node scripts/seed.js; \
fi
staging-logs: check-ssh

View file

@ -1,13 +1,13 @@
FROM node:22-alpine AS builder
RUN apk add --no-cache python3 make g++
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ARG VITE_PB_URL=http://vereinshaus-pocketbase:8090
ENV VITE_PB_URL=$VITE_PB_URL
RUN npm run build
FROM node:22-alpine
RUN apk add --no-cache python3 make g++
WORKDIR /app
COPY --from=builder /app/build ./build
COPY --from=builder /app/package*.json ./

449
app/package-lock.json generated
View file

@ -9,7 +9,10 @@
"version": "0.1.0",
"dependencies": {
"@event-calendar/core": "^5.7.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.10.0",
"ical-generator": "^10.2.0",
"jose": "^6.2.3",
"papaparse": "^5.5.3",
"pocketbase": "^0.26.9",
"rrule": "^2.8.1",
@ -19,6 +22,8 @@
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
"@types/papaparse": "^5.5.2",
"@types/web-push": "^3.6.4",
"svelte": "^5.55.2",
@ -2637,6 +2642,23 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
@ -2895,6 +2917,26 @@
"node": "18 || 20 || >=22"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.31",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz",
@ -2908,6 +2950,49 @@
"node": ">=6.0.0"
}
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/better-sqlite3": {
"version": "12.10.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz",
"integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/bn.js": {
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
@ -2961,6 +3046,30 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@ -3061,6 +3170,12 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@ -3221,6 +3336,30 @@
}
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@ -3271,7 +3410,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@ -3330,6 +3468,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/es-abstract": {
"version": "1.24.2",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
@ -3529,6 +3676,15 @@
"url": "https://github.com/bgub/eta?sponsor=1"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -3578,6 +3734,12 @@
}
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/filelist": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
@ -3651,6 +3813,12 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fs-extra": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
@ -3807,6 +3975,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/glob": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
@ -4030,12 +4204,38 @@
"dev": true,
"license": "ISC"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@ -4508,6 +4708,15 @@
"node": ">=10"
}
},
"node_modules/jose": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -4922,6 +5131,18 @@
"node": ">= 0.4"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
@ -4963,6 +5184,12 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@ -5008,6 +5235,36 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/node-abi": {
"version": "3.92.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz",
"integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-abi/node_modules/semver": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-releases": {
"version": "2.0.44",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz",
@ -5070,6 +5327,15 @@
],
"license": "MIT"
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/own-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@ -5210,6 +5476,33 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/pretty-bytes": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
@ -5223,6 +5516,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -5233,6 +5536,35 @@
"node": ">=6"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@ -5758,6 +6090,51 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
@ -5842,6 +6219,15 @@
"node": ">= 0.4"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string.prototype.matchall": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
@ -5954,6 +6340,15 @@
"node": ">=10"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@ -6027,6 +6422,34 @@
"@types/estree": "^1.0.6"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/temp-dir": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
@ -6118,6 +6541,18 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/type-fest": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
@ -6358,6 +6793,12 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vite": {
"version": "8.0.13",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
@ -6850,6 +7291,12 @@
"workbox-core": "7.4.1"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View file

@ -15,6 +15,8 @@
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
"@types/papaparse": "^5.5.2",
"@types/web-push": "^3.6.4",
"svelte": "^5.55.2",
@ -27,7 +29,10 @@
},
"dependencies": {
"@event-calendar/core": "^5.7.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.10.0",
"ical-generator": "^10.2.0",
"jose": "^6.2.3",
"papaparse": "^5.5.3",
"pocketbase": "^0.26.9",
"rrule": "^2.8.1",

15
app/src/app.d.ts vendored Normal file
View file

@ -0,0 +1,15 @@
declare global {
namespace App {
interface Locals {
user: {
id: string;
verein_id: string;
rolle: string | null;
name: string;
email: string;
} | null;
}
}
}
export {};

66
app/src/lib/api.ts Normal file
View file

@ -0,0 +1,66 @@
import { get } from 'svelte/store';
import { user } from './user';
import { goto } from '$app/navigation';
function token() { return get(user)?.token ?? ''; }
function headers(extra: Record<string, string> = {}): Record<string, string> {
return { Authorization: `Bearer ${token()}`, ...extra };
}
async function handleRes<T>(res: Response): Promise<T> {
if (res.status === 401) { user.clear(); goto('/login'); throw new Error('Nicht angemeldet'); }
if (!res.ok) {
const e = await res.json().catch(() => ({}));
throw new Error((e as { message?: string }).message || res.statusText);
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
export const api = {
async get<T>(path: string, query: Record<string, string> = {}): Promise<T> {
const url = new URL('/api' + path, location.origin);
Object.entries(query).forEach(([k, v]) => v !== undefined && url.searchParams.set(k, v));
return handleRes<T>(await fetch(url.toString(), { headers: headers() }));
},
async post<T>(path: string, data?: unknown): Promise<T> {
return handleRes<T>(await fetch('/api' + path, {
method: 'POST',
headers: headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify(data ?? {}),
}));
},
async put<T>(path: string, data?: unknown): Promise<T> {
return handleRes<T>(await fetch('/api' + path, {
method: 'PUT',
headers: headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify(data ?? {}),
}));
},
async patch<T>(path: string, data?: unknown): Promise<T> {
return handleRes<T>(await fetch('/api' + path, {
method: 'PATCH',
headers: headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify(data ?? {}),
}));
},
async del<T = void>(path: string): Promise<T> {
return handleRes<T>(await fetch('/api' + path, { method: 'DELETE', headers: headers() }));
},
async postForm<T>(path: string, form: FormData): Promise<T> {
return handleRes<T>(await fetch('/api' + path, {
method: 'POST', headers: headers(), body: form,
}));
},
fileUrl(verein_id: string, record_id: string, filename: string, thumb = false): string {
const base = `/api/files/${verein_id}/${record_id}/${filename}`;
return thumb ? base + '?thumb=1' : base;
}
};

View file

@ -0,0 +1,52 @@
import { SignJWT, jwtVerify } from 'jose';
import bcrypt from 'bcryptjs';
import { error } from '@sveltejs/kit';
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || 'vereinshaus-dev-secret-change-in-production'
);
export interface JwtPayload {
sub: string;
verein_id: string;
rolle: string | null;
name: string;
email: string;
}
export async function signJwt(payload: JwtPayload): Promise<string> {
return new SignJWT({ ...payload })
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('30d')
.sign(JWT_SECRET);
}
export async function verifyJwt(token: string): Promise<JwtPayload | null> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
return payload as unknown as JwtPayload;
} catch {
return null;
}
}
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 12);
}
export async function checkPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
export function bearerToken(request: Request): string | null {
const h = request.headers.get('Authorization');
return h?.startsWith('Bearer ') ? h.slice(7) : null;
}
export async function requireAuth(request: Request): Promise<JwtPayload> {
const token = bearerToken(request);
if (!token) throw error(401, 'Nicht authentifiziert');
const user = await verifyJwt(token);
if (!user) throw error(401, 'Ungültiger Token');
return user;
}

219
app/src/lib/server/db.ts Normal file
View file

@ -0,0 +1,219 @@
import Database from 'better-sqlite3';
import { mkdirSync, existsSync } from 'fs';
import { dirname } from 'path';
const DB_PATH = process.env.DB_PATH || './data/vereinshaus.db';
const SCHEMA = `
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;
CREATE TABLE IF NOT EXISTS vereine (
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
name TEXT NOT NULL,
adresse TEXT, plz TEXT, ort TEXT, bundesland TEXT,
plan TEXT NOT NULL DEFAULT 'free',
dosb_mitglied INTEGER NOT NULL DEFAULT 0,
email TEXT, telefon TEXT, website TEXT,
glaeubigerid TEXT, iban TEXT, bic TEXT,
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
rolle TEXT DEFAULT NULL,
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS gruppen (
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
name TEXT NOT NULL,
beschreibung TEXT,
trainer_ids TEXT NOT NULL DEFAULT '[]',
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS mitglieder (
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
vorname TEXT NOT NULL,
nachname TEXT NOT NULL,
email TEXT, telefon TEXT,
geburtsdatum TEXT, eintrittsdatum TEXT, austrittsdatum TEXT,
strasse TEXT, plz TEXT, ort TEXT,
iban TEXT, bic TEXT,
gruppe_ids TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'aktiv',
notizen TEXT,
mandatsreferenz TEXT, mandatsdatum TEXT,
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS beitraege (
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
name TEXT NOT NULL,
betrag REAL NOT NULL,
rhythmus TEXT NOT NULL DEFAULT 'jaehrlich',
beschreibung TEXT,
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS einzuege (
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
mitglied_id TEXT NOT NULL REFERENCES mitglieder(id) ON DELETE CASCADE,
beitrag_id TEXT NOT NULL REFERENCES beitraege(id),
betrag REAL NOT NULL,
faellig_am TEXT,
status TEXT NOT NULL DEFAULT 'ausstehend',
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS veranstaltungsorte (
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
name TEXT NOT NULL,
adresse TEXT,
typ TEXT DEFAULT 'sonstiges',
aktiv INTEGER NOT NULL DEFAULT 1,
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS ort_ausfaelle (
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
ort_id TEXT NOT NULL REFERENCES veranstaltungsorte(id) ON DELETE CASCADE,
von TEXT NOT NULL, bis TEXT NOT NULL, grund TEXT,
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS termine (
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
titel TEXT NOT NULL,
beschreibung TEXT,
beginn TEXT NOT NULL,
ende TEXT,
ort TEXT,
ort_id TEXT REFERENCES veranstaltungsorte(id) ON DELETE SET NULL,
gruppe_ids TEXT NOT NULL DEFAULT '[]',
durchfuehrender_id TEXT,
verfuegbarkeit TEXT DEFAULT 'offen',
rrule TEXT,
serie_id TEXT,
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS nachrichten (
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
autor_id TEXT NOT NULL,
betreff TEXT NOT NULL,
text TEXT NOT NULL DEFAULT '',
gruppe_ids TEXT NOT NULL DEFAULT '[]',
gesendet_am TEXT,
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS neuigkeiten (
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
autor_id TEXT NOT NULL,
autor_name TEXT NOT NULL DEFAULT '',
text TEXT,
medien TEXT NOT NULL DEFAULT '[]',
gruppe_ids TEXT NOT NULL DEFAULT '[]',
termin_id TEXT REFERENCES termine(id) ON DELETE SET NULL,
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS reaktionen (
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
beitrag_id TEXT NOT NULL REFERENCES neuigkeiten(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
UNIQUE(beitrag_id, user_id)
);
CREATE TABLE IF NOT EXISTS einladungen (
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE DEFAULT (lower(hex(randomblob(16)))),
rolle TEXT NOT NULL DEFAULT 'trainer',
genutzt INTEGER NOT NULL DEFAULT 0,
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS push_subscriptions (
id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(8)))),
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
verein_id TEXT NOT NULL REFERENCES vereine(id) ON DELETE CASCADE,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
created TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
`;
let _db: Database.Database | null = null;
export function getDb(): Database.Database {
if (_db) return _db;
const dir = dirname(DB_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
_db = new Database(DB_PATH);
_db.exec(SCHEMA);
return _db;
}
export function newId(): string {
const bytes = new Uint8Array(8);
crypto.getRandomValues(bytes);
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}
export function parseArr(val: unknown): string[] {
if (Array.isArray(val)) return val;
if (typeof val === 'string') { try { return JSON.parse(val); } catch { return []; } }
return [];
}
export function toArr(val: unknown): string {
return JSON.stringify(Array.isArray(val) ? val : []);
}
export function row<T extends Record<string, unknown>>(r: T): T {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(r)) {
if (typeof v === 'string' && (k.endsWith('_ids') || k === 'medien' || k === 'trainer_ids')) {
out[k] = parseArr(v);
} else if (typeof v === 'number' && (k === 'aktiv' || k === 'dosb_mitglied' || k === 'genutzt')) {
out[k] = Boolean(v);
} else {
out[k] = v;
}
}
return out as T;
}
export function rows<T extends Record<string, unknown>>(rs: T[]): T[] {
return rs.map(r => row(r));
}

34
app/src/lib/user.ts Normal file
View file

@ -0,0 +1,34 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
export interface AppUser {
id: string;
verein_id: string;
rolle: string | null;
name: string;
email: string;
token: string;
}
function createUserStore() {
let initial: AppUser | null = null;
if (browser) {
try { initial = JSON.parse(localStorage.getItem('vh_user') || 'null'); } catch { /* */ }
}
const { subscribe, set } = writable<AppUser | null>(initial);
return {
subscribe,
set(u: AppUser | null) {
if (browser) {
if (u) localStorage.setItem('vh_user', JSON.stringify(u));
else localStorage.removeItem('vh_user');
}
set(u);
},
clear() { this.set(null); }
};
}
export const user = createUserStore();

View file

@ -2,39 +2,27 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { pb } from '$lib/pb';
import { user } from '$lib/user';
import Icon from '$lib/components/Icon.svelte';
import type { IconName } from '$lib/icons';
let { children } = $props();
onMount(() => {
if (!pb.authStore.isValid) {
goto('/login');
return;
}
if (!pb.authStore.record?.verein_id) {
goto('/onboarding');
return;
}
if (!$user) { goto('/login'); return; }
if (!$user.verein_id) { goto('/onboarding'); return; }
registerPush();
});
async function registerPush() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
const permission = await Notification.requestPermission();
if (permission !== 'granted') return;
try {
const reg = await navigator.serviceWorker.ready;
// VAPID public key vom Server holen
const keyRes = await fetch('/api/push/key');
const { publicKey } = await keyRes.json();
if (!publicKey) return;
// Bestehende oder neue Subscription
let sub = await reg.pushManager.getSubscription();
if (!sub) {
sub = await reg.pushManager.subscribe({
@ -42,18 +30,10 @@
applicationServerKey: urlBase64ToUint8Array(publicKey) as BufferSource,
});
}
// In PocketBase speichern
await fetch('/api/push/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: pb.authStore.token,
},
body: JSON.stringify({
subscription: sub.toJSON(),
userId: pb.authStore.record?.id,
}),
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${$user?.token}` },
body: JSON.stringify({ subscription: sub.toJSON(), userId: $user?.id }),
});
} catch (e) {
console.warn('[push] Registrierung fehlgeschlagen:', e);
@ -67,7 +47,7 @@
return Uint8Array.from([...raw].map((c) => c.charCodeAt(0)));
}
const isAdmin = () => !pb.authStore.record?.rolle || pb.authStore.record?.rolle === 'admin';
const isAdmin = () => !$user?.rolle || $user?.rolle === 'admin';
const allNavItems: { href: string; label: string; icon: IconName; adminOnly?: boolean }[] = [
{ href: '/neuigkeiten', label: 'Neuigkeiten', icon: 'images' },

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { pb } from '$lib/pb';
import { api } from '$lib/api';
import { onMount } from 'svelte';
import type { Verein, Termin } from '$lib/types';
@ -8,15 +8,12 @@
let loading = $state(true);
onMount(async () => {
const vid = pb.authStore.record?.verein_id as string;
const now = new Date().toISOString();
[verein, termine] = await Promise.all([
pb.collection('vereine').getOne<Verein>(vid),
pb.collection('termine').getList<Termin>(1, 3, {
filter: `beginn >= "${now}"`,
sort: 'beginn',
}).then(r => r.items),
api.get<Verein>('/vereine'),
api.get<Termin[]>('/termine', { sort: 'beginn', page: '1', perPage: '3' }),
]);
termine = (termine as any)?.items ?? termine;
loading = false;
});

View file

@ -1,5 +1,7 @@
<script lang="ts">
import { pb } from '$lib/pb';
import { api } from '$lib/api';
import { user } from '$lib/user';
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { generatePain008, downloadXml, minEinzugsdatum, type SepaPosition } from '$lib/sepa';
@ -41,11 +43,10 @@
};
onMount(async () => {
if (pb.authStore.record?.rolle === 'trainer') { goto('/'); return; }
const vid = pb.authStore.record?.verein_id as string;
if (get(user)?.rolle === 'trainer') { goto('/'); return; }
[beitraege, verein] = await Promise.all([
pb.collection('beitraege').getFullList<Beitrag>({ sort: 'name' }),
pb.collection('vereine').getOne<Verein>(vid).catch(() => null),
api.get<Beitrag[]>('/beitraege', { sort: 'name' }),
api.get<Verein>('/vereine').catch(() => null),
]);
loading = false;
});
@ -71,13 +72,12 @@
}
saving = true;
try {
const vid = pb.authStore.record?.verein_id as string;
const data = { verein_id: vid, name: fName.trim(), betrag, rhythmus: fRhythmus, beschreibung: fBeschr.trim() };
const data = { name: fName.trim(), betrag, rhythmus: fRhythmus, beschreibung: fBeschr.trim() };
if (editId) {
await pb.collection('beitraege').update(editId, data);
await api.put('/beitraege/' + editId, data);
beitraege = beitraege.map(b => b.id === editId ? { ...b, ...data } as Beitrag : b);
} else {
const neu = await pb.collection('beitraege').create<Beitrag>(data);
const neu = await api.post<Beitrag>('/beitraege', data);
beitraege = [...beitraege, neu].sort((a, b) => a.name.localeCompare(b.name));
}
showForm = false;
@ -90,7 +90,7 @@
async function loeschen(b: Beitrag) {
if (!confirm(`"${b.name}" wirklich löschen?`)) return;
await pb.collection('beitraege').delete(b.id);
await api.del('/beitraege/' + b.id);
beitraege = beitraege.filter(x => x.id !== b.id);
}
@ -102,9 +102,7 @@
einzugsdatum = minEinzugsdatum();
sepaLoading = true;
try {
const alle = await pb.collection('mitglieder').getFullList<Mitglied>({
filter: 'status = "aktiv"', sort: 'nachname,vorname',
});
const alle = await api.get<Mitglied[]>('/mitglieder', { status: 'aktiv', sort: 'nachname,vorname' });
const mit = alle.filter(m => m.iban?.trim());
const ohne = alle.length - mit.length;
sepaPreview = { mitglieder: mit, ohne };
@ -157,10 +155,9 @@
downloadXml(xml, `sepa-einzug-${einzugsdatum}.xml`);
// Einzüge als "ausstehend" anlegen
const vid = pb.authStore.record?.verein_id as string;
await Promise.all(
sepaPreview.mitglieder.map((m) =>
pb.collection('einzuege').create({
api.post('/einzuege', {
mitglied_id: m.id,
beitrag_id: sepaFor!.id,
betrag: sepaFor!.betrag,

View file

@ -1,10 +1,12 @@
<script lang="ts">
import { pb } from '$lib/pb';
import { api } from '$lib/api';
import { user } from '$lib/user';
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import type { Verein, Gruppe } from '$lib/types';
const isAdmin = () => !pb.authStore.record?.rolle || pb.authStore.record?.rolle === 'admin';
const isAdmin = () => { const u = get(user); return !u?.rolle || u.rolle === 'admin'; };
let loading = $state(true);
let saving = $state(false);
@ -60,19 +62,19 @@
];
onMount(async () => {
vereinId = pb.authStore.record?.verein_id as string;
vereinId = get(user)?.verein_id ?? '';
const [v, alleUser, alleGruppen, mitgliederCount] = await Promise.all([
pb.collection('vereine').getOne<Verein>(vereinId),
api.get<Verein>('/vereine'),
isAdmin()
? pb.collection('users').getFullList({ filter: `verein_id = "${vereinId}"` })
? api.get<any[]>('/users')
: Promise.resolve([]),
pb.collection('gruppen').getFullList<Gruppe>({ sort: 'name' }),
pb.collection('mitglieder').getList(1, 1, { filter: `verein_id = "${vereinId}"` }).then(r => r.totalItems),
api.get<Gruppe[]>('/gruppen', { sort: 'name' }),
api.get<{ total: number }>('/mitglieder/count').catch(() => ({ total: 0 })),
]);
trainer = alleUser.filter((u: any) => u.rolle === 'trainer');
gruppen = alleGruppen;
plan = v.plan ?? 'free';
mitgliederAnz = mitgliederCount;
mitgliederAnz = (mitgliederCount as any).total ?? 0;
name = v.name ?? '';
adresse = v.adresse ?? '';
plz = v.plz ?? '';
@ -91,7 +93,7 @@
if (!name.trim()) { error = 'Vereinsname ist Pflichtfeld.'; return; }
error = ''; success = ''; saving = true;
try {
await pb.collection('vereine').update(vereinId, {
await api.patch('/vereine', {
name: name.trim(),
adresse: adresse.trim() || null,
plz: plz.trim() || null,
@ -113,17 +115,14 @@
}
async function trainerEinladen() {
const token = crypto.randomUUID().replace(/-/g, '');
await pb.collection('einladungen').create({
verein_id: vereinId, rolle: 'trainer', token, genutzt: false,
});
einladungUrl = `${window.location.origin}/invite/${token}`;
const inv = await api.post<{ token: string }>('/einladungen', { rolle: 'trainer' });
einladungUrl = `${window.location.origin}/invite/${inv.token}`;
einladungKopiert = false;
}
async function trainerEntfernen(uid: string) {
if (!confirm('Trainer wirklich entfernen?')) return;
await pb.collection('users').update(uid, { rolle: null, verein_id: null });
await api.del('/users/' + uid);
trainer = trainer.filter(t => t.id !== uid);
}
@ -135,7 +134,7 @@
}
function abmelden() {
pb.authStore.clear();
user.clear();
goto('/login');
}
</script>

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { pb } from '$lib/pb';
import { api } from '$lib/api';
import { onMount } from 'svelte';
import Papa from 'papaparse';
import type { Mitglied } from '$lib/types';
@ -39,10 +39,9 @@
];
onMount(async () => {
const vid = pb.authStore.record?.verein_id as string;
const [m, v] = await Promise.all([
pb.collection('mitglieder').getFullList<Mitglied>({ sort: 'nachname,vorname' }),
pb.collection('vereine').getOne<any>(vid),
api.get<Mitglied[]>('/mitglieder', { sort: 'nachname,vorname' }),
api.get<any>('/vereine'),
]);
mitglieder = m;
vereinName = v.name ?? '';
@ -93,14 +92,13 @@
}
async function exportiereBackup() {
const vid = pb.authStore.record?.verein_id as string;
const [verein, mitgl, gruppen, termine, beitraege, nachrichten] = await Promise.all([
pb.collection('vereine').getOne<any>(vid),
pb.collection('mitglieder').getFullList(),
pb.collection('gruppen').getFullList(),
pb.collection('termine').getFullList(),
pb.collection('beitraege').getFullList(),
pb.collection('nachrichten').getFullList(),
api.get<any>('/vereine'),
api.get<any[]>('/mitglieder'),
api.get<any[]>('/gruppen'),
api.get<any[]>('/termine'),
api.get<any[]>('/beitraege'),
api.get<any[]>('/nachrichten'),
]);
const backup = {
exportiert_am: new Date().toISOString(),
@ -177,12 +175,11 @@
async function importStarten() {
importLaeuft = true;
const vid = pb.authStore.record?.verein_id as string;
let ok = 0;
const fehler: string[] = [];
for (const row of csvRows) {
const record: Record<string, any> = { verein_id: vid, status: 'aktiv' };
const record: Record<string, any> = { status: 'aktiv' };
for (const [csvSpalte, ziel] of Object.entries(feldMapping)) {
if (!ziel) continue;
const wert = row[csvSpalte]?.trim() ?? '';
@ -200,7 +197,7 @@
continue;
}
try {
await pb.collection('mitglieder').create(record);
await api.post('/mitglieder', record);
ok++;
} catch (e: unknown) {
fehler.push(`${record.vorname} ${record.nachname}: ${e instanceof Error ? e.message : 'Fehler'}`);
@ -208,7 +205,7 @@
}
// Mitgliederliste aktualisieren
mitglieder = await pb.collection('mitglieder').getFullList<Mitglied>({ sort: 'nachname,vorname' });
mitglieder = await api.get<Mitglied[]>('/mitglieder', { sort: 'nachname,vorname' });
importResult = { ok, fehler };
importPhase = 'done';
importLaeuft = false;

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { pb } from '$lib/pb';
import { api } from '$lib/api';
import { onMount } from 'svelte';
let mitglieder = $state<any[]>([]);
@ -9,8 +9,8 @@
onMount(async () => {
[mitglieder, gruppen] = await Promise.all([
pb.collection('mitglieder').getFullList({ sort: 'nachname,vorname' }),
pb.collection('gruppen').getFullList({ sort: 'name' })
api.get<any[]>('/mitglieder', { sort: 'nachname,vorname' }),
api.get<any[]>('/gruppen', { sort: 'name' })
]);
loading = false;
});

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { pb } from '$lib/pb';
import { api } from '$lib/api';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
@ -61,8 +61,8 @@
onMount(async () => {
const [m, g] = await Promise.all([
pb.collection('mitglieder').getOne<Mitglied>(id),
pb.collection('gruppen').getFullList<Gruppe>({ sort: 'name' }),
api.get<Mitglied>('/mitglieder/' + id),
api.get<Gruppe[]>('/gruppen', { sort: 'name' }),
]);
loadRecord(m);
gruppen = g;
@ -78,7 +78,7 @@
async function speichern() {
error = ''; saving = true;
try {
await pb.collection('mitglieder').update(id, {
await api.put('/mitglieder/' + id, {
vorname: vorname.trim(),
nachname: nachname.trim(),
email: email.trim() || null,
@ -107,7 +107,7 @@
async function loeschen() {
try {
await pb.collection('mitglieder').delete(id);
await api.del('/mitglieder/' + id);
goto('/mitglieder');
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Fehler beim Löschen.';

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { pb } from '$lib/pb';
import { api } from '$lib/api';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
@ -31,7 +31,7 @@
let loading = $state(false);
onMount(async () => {
gruppen = await pb.collection('gruppen').getFullList({ sort: 'name' });
gruppen = await api.get<any[]>('/gruppen', { sort: 'name' });
});
function toggleGruppe(id: string) {
@ -43,9 +43,7 @@
async function speichern() {
error = ''; loading = true;
try {
const verein_id = pb.authStore.record?.verein_id as string;
await pb.collection('mitglieder').create({
verein_id,
await api.post('/mitglieder', {
vorname: vorname.trim(),
nachname: nachname.trim(),
email: email.trim() || null,

View file

@ -1,5 +1,7 @@
<script lang="ts">
import { pb } from '$lib/pb';
import { api } from '$lib/api';
import { user } from '$lib/user';
import { get } from 'svelte/store';
import { onMount } from 'svelte';
import type { Nachricht, Gruppe } from '$lib/types';
@ -18,8 +20,8 @@
onMount(async () => {
[nachrichten, gruppen] = await Promise.all([
pb.collection('nachrichten').getFullList<Nachricht>({ sort: '-gesendet_am' }),
pb.collection('gruppen').getFullList<Gruppe>({ sort: 'name' }),
api.get<Nachricht[]>('/nachrichten', { sort: '-gesendet_am' }),
api.get<Gruppe[]>('/gruppen', { sort: 'name' }),
]);
loading = false;
});
@ -43,12 +45,7 @@
sendError = ''; sending = true;
try {
const verein_id = pb.authStore.record?.verein_id as string;
const autor_id = pb.authStore.record?.id as string;
const record = await pb.collection('nachrichten').create<Nachricht>({
verein_id,
autor_id,
const record = await api.post<Nachricht>('/nachrichten', {
betreff: fBetreff.trim(),
text: fText.trim(),
gruppe_ids: fGruppeIds,
@ -63,7 +60,7 @@
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: pb.authStore.token,
Authorization: `Bearer ${get(user)?.token ?? ''}`,
},
body: JSON.stringify({
titel: fBetreff.trim(),

View file

@ -1,10 +1,12 @@
<script lang="ts">
import { pb } from '$lib/pb';
import { api } from '$lib/api';
import { user } from '$lib/user';
import { get } from 'svelte/store';
import { onMount } from 'svelte';
import type { Neuigkeit, Gruppe, Termin } from '$lib/types';
const canPost = () => !pb.authStore.record?.rolle || pb.authStore.record?.rolle !== null;
const userId = () => pb.authStore.record?.id as string;
const canPost = () => true; // alle eingeloggten User dürfen posten
const userId = () => get(user)?.id as string;
let neuigkeiten = $state<Neuigkeit[]>([]);
let gruppen = $state<Gruppe[]>([]);
@ -28,31 +30,25 @@
let ladeError = $state('');
onMount(async () => {
const vid = pb.authStore.record?.verein_id as string;
try {
// Queries einzeln damit ein Fehler sichtbar wird
const [nList, gList] = await Promise.all([
pb.collection('neuigkeiten').getFullList<Neuigkeit>({
sort: '-created',
}),
pb.collection('gruppen').getFullList<Gruppe>({ sort: 'name' }),
api.get<Neuigkeit[]>('/neuigkeiten', { sort: '-created' }),
api.get<Gruppe[]>('/gruppen', { sort: 'name' }),
]);
neuigkeiten = nList;
gruppen = gList;
// Termine der letzten 30 Tage + zukünftige
try {
const von = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().replace('T', ' ');
termine = await pb.collection('termine').getFullList<Termin>({
filter: `beginn >= '${von}'`, sort: '-beginn',
});
termine = await api.get<Termin[]>('/termine', { sort: '-beginn' });
} catch { termine = []; }
// Reaktionen separat damit Fehler nicht alles blockiert
try {
const [rList, meineList] = await Promise.all([
pb.collection('reaktionen').getFullList({ filter: `beitrag_id.verein_id = "${vid}"` }),
pb.collection('reaktionen').getFullList({ filter: `user_id = "${userId()}"` }),
api.get<any[]>('/reaktionen'),
api.get<any[]>('/reaktionen', { meine: 'true' }),
]);
const counts: Record<string, number> = {};
for (const r of rList) counts[r.beitrag_id] = (counts[r.beitrag_id] ?? 0) + 1;
@ -96,11 +92,9 @@
}
formError = ''; saving = true;
try {
const vid = pb.authStore.record?.verein_id as string;
const u = get(user);
const form = new FormData();
form.append('verein_id', vid);
form.append('autor_id', userId());
form.append('autor_name', pb.authStore.record?.name ?? '');
form.append('autor_name', u?.name ?? '');
if (fText.trim()) form.append('text', fText.trim());
if (fTerminId) form.append('termin_id', fTerminId);
for (const id of fGruppeIds) form.append('gruppe_ids', id);
@ -108,7 +102,7 @@
for (const file of Array.from(fDateien)) form.append('medien', file);
}
const neu = await pb.collection('neuigkeiten').create<Neuigkeit>(form);
const neu = await api.postForm<Neuigkeit>('/neuigkeiten', form);
neuigkeiten = [neu, ...neuigkeiten];
showForm = false;
fText = ''; fGruppeIds = []; fTerminId = ''; fDateien = null; previews = [];
@ -121,26 +115,24 @@
async function loeschen(n: Neuigkeit) {
if (!confirm('Beitrag löschen?')) return;
await pb.collection('neuigkeiten').delete(n.id);
await api.del('/neuigkeiten/' + n.id);
neuigkeiten = neuigkeiten.filter(x => x.id !== n.id);
}
async function toggleReaktion(n: Neuigkeit) {
if (meineReaktion[n.id]) {
await pb.collection('reaktionen').delete(meineReaktion[n.id]);
await api.del('/reaktionen/' + meineReaktion[n.id]);
meineReaktion = { ...meineReaktion, [n.id]: '' };
reaktionen = { ...reaktionen, [n.id]: Math.max(0, (reaktionen[n.id] ?? 1) - 1) };
} else {
const r = await pb.collection('reaktionen').create({
beitrag_id: n.id, user_id: userId(),
});
const r = await api.post<{ id: string }>('/reaktionen', { beitrag_id: n.id });
meineReaktion = { ...meineReaktion, [n.id]: r.id };
reaktionen = { ...reaktionen, [n.id]: (reaktionen[n.id] ?? 0) + 1 };
}
}
function mediaUrl(n: Neuigkeit, datei: string, thumb = false): string {
return pb.files.getURL(n as any, datei, thumb ? { thumb: '400x300' } : {});
return api.fileUrl(n.verein_id, n.id, datei, thumb);
}
function isVideo(datei: string): boolean {

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { pb } from '$lib/pb';
import { api } from '$lib/api';
import { onMount } from 'svelte';
import type { Veranstaltungsort, OrtAusfall } from '$lib/types';
@ -31,10 +31,9 @@
};
onMount(async () => {
const vid = pb.authStore.record?.verein_id as string;
[orte, ausfaelle] = await Promise.all([
pb.collection('veranstaltungsorte').getFullList<Veranstaltungsort>({ sort: 'name', filter: `verein_id = "${vid}"` }),
pb.collection('ort_ausfaelle').getFullList<OrtAusfall>({ sort: 'von' }),
api.get<Veranstaltungsort[]>('/veranstaltungsorte', { sort: 'name' }),
api.get<OrtAusfall[]>('/ort_ausfaelle', { sort: 'von' }),
]);
loading = false;
});
@ -54,13 +53,12 @@
if (!fName.trim()) { ortError = 'Name ist Pflichtfeld.'; return; }
ortError = ''; ortSaving = true;
try {
const vid = pb.authStore.record?.verein_id as string;
const data = { verein_id: vid, name: fName.trim(), adresse: fAdresse.trim() || null, typ: fTyp, aktiv: fAktiv };
const data = { name: fName.trim(), adresse: fAdresse.trim() || null, typ: fTyp, aktiv: fAktiv };
if (editOrtId) {
const u = await pb.collection('veranstaltungsorte').update<Veranstaltungsort>(editOrtId, data);
const u = await api.put<Veranstaltungsort>('/veranstaltungsorte/' + editOrtId, data);
orte = orte.map(o => o.id === editOrtId ? u : o);
} else {
const n = await pb.collection('veranstaltungsorte').create<Veranstaltungsort>(data);
const n = await api.post<Veranstaltungsort>('/veranstaltungsorte', data);
orte = [...orte, n].sort((a, b) => a.name.localeCompare(b.name));
}
showOrtForm = false;
@ -73,7 +71,7 @@
async function ortLoeschen(id: string) {
if (!confirm('Ort wirklich löschen? Alle verknüpften Termine verlieren die Ortzuordnung.')) return;
await pb.collection('veranstaltungsorte').delete(id);
await api.del('/veranstaltungsorte/' + id);
orte = orte.filter(o => o.id !== id);
ausfaelle = ausfaelle.filter(a => a.ort_id !== id);
}
@ -88,7 +86,7 @@
if (aVon > aBis) { ausfallError = 'Bis muss nach Von liegen.'; return; }
ausfallError = ''; ausfallSaving = true;
try {
const n = await pb.collection('ort_ausfaelle').create<OrtAusfall>({
const n = await api.post<OrtAusfall>('/ort_ausfaelle', {
ort_id: aOrtId, von: aVon, bis: aBis, grund: aGrund.trim() || null,
});
ausfaelle = [...ausfaelle, n].sort((a, b) => a.von.localeCompare(b.von));
@ -101,7 +99,7 @@
}
async function ausfallLoeschen(id: string) {
await pb.collection('ort_ausfaelle').delete(id);
await api.del('/ort_ausfaelle/' + id);
ausfaelle = ausfaelle.filter(a => a.id !== id);
}

View file

@ -1,13 +1,15 @@
<script lang="ts">
import { pb } from '$lib/pb';
import { api } from '$lib/api';
import { user } from '$lib/user';
import { get } from 'svelte/store';
import { onMount } from 'svelte';
import { RRule } from 'rrule';
import { Calendar, TimeGrid, DayGrid } from '@event-calendar/core';
import '@event-calendar/core/index.css';
import type { Termin, Gruppe, Verfuegbarkeit, Veranstaltungsort, OrtAusfall } from '$lib/types';
const isAdmin = () => !pb.authStore.record?.rolle || pb.authStore.record?.rolle === 'admin';
const userId = () => pb.authStore.record?.id as string;
const isAdmin = () => { const u = get(user); return !u?.rolle || u.rolle === 'admin'; };
const userId = () => get(user)?.id as string;
let termine = $state<Termin[]>([]);
let gruppen = $state<Gruppe[]>([]);
@ -59,15 +61,14 @@
);
onMount(async () => {
const vid = pb.authStore.record?.verein_id as string;
[termine, gruppen, alleUser, orte, ausfaelle] = await Promise.all([
pb.collection('termine').getFullList<Termin>({ sort: 'beginn' }),
pb.collection('gruppen').getFullList<Gruppe>({ sort: 'name' }),
api.get<Termin[]>('/termine', { sort: 'beginn' }),
api.get<Gruppe[]>('/gruppen', { sort: 'name' }),
isAdmin()
? pb.collection('users').getFullList({ filter: `verein_id = "${vid}" && rolle = "trainer"` })
? api.get<any[]>('/users', { rolle: 'trainer' })
: Promise.resolve([]),
pb.collection('veranstaltungsorte').getFullList<Veranstaltungsort>({ sort: 'name', filter: `verein_id = "${vid}" && aktiv = true` }),
pb.collection('ort_ausfaelle').getFullList<OrtAusfall>({ sort: 'von' }),
api.get<Veranstaltungsort[]>('/veranstaltungsorte', { sort: 'name', aktiv: 'true' }),
api.get<OrtAusfall[]>('/ort_ausfaelle', { sort: 'von' }),
]);
loading = false;
});
@ -110,9 +111,7 @@
}
function generiereTerminDaten(beginn: Date, rruleStr: string | null) {
const vid = pb.authStore.record?.verein_id as string;
return {
verein_id: vid,
titel: fTitel.trim(),
beschreibung: fBeschr.trim() || null,
ort: fOrtId ? null : (fOrt.trim() || null),
@ -146,7 +145,7 @@
const neu = await Promise.all(
dates.map(d =>
pb.collection('termine').create<Termin>({
api.post<Termin>('/termine', {
...generiereTerminDaten(d, rruleStr),
beginn: d.toISOString(),
ende: fEnde ? new Date(d.getTime() + dauer).toISOString() : null,
@ -162,10 +161,10 @@
ende: fEnde ? fromLocal(fEnde) : null,
};
if (editId) {
const updated = await pb.collection('termine').update<Termin>(editId, data);
const updated = await api.put<Termin>('/termine/' + editId, data);
termine = termine.map(t => t.id === editId ? updated : t);
} else {
const neu = await pb.collection('termine').create<Termin>(data);
const neu = await api.post<Termin>('/termine', data);
termine = [...termine, neu];
}
}
@ -180,10 +179,10 @@
async function loeschen(t: Termin, ganzeSerieLoeschen: boolean) {
if (ganzeSerieLoeschen && t.serie_id) {
const serie = termine.filter(x => x.serie_id === t.serie_id);
await Promise.all(serie.map(s => pb.collection('termine').delete(s.id)));
await Promise.all(serie.map(s => api.del('/termine/' + s.id)));
termine = termine.filter(x => x.serie_id !== t.serie_id);
} else {
await pb.collection('termine').delete(t.id);
await api.del('/termine/' + t.id);
termine = termine.filter(x => x.id !== t.id);
}
showDelete = null;
@ -202,7 +201,7 @@
});
async function setVerfuegbarkeit(t: Termin, v: Verfuegbarkeit) {
const updated = await pb.collection('termine').update<Termin>(t.id, { verfuegbarkeit: v });
const updated = await api.put<Termin>('/termine/' + t.id, { verfuegbarkeit: v });
termine = termine.map(x => x.id === t.id ? updated : x);
}

View file

@ -1,12 +1,13 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { pb } from '$lib/pb';
import { get } from 'svelte/store';
import { user } from '$lib/user';
let { children } = $props();
onMount(() => {
if (pb.authStore.isValid) {
if (!!get(user)) {
goto('/');
}
});

View file

@ -1,6 +1,8 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { pb } from '$lib/pb';
import { api } from '$lib/api';
import { user } from '$lib/user';
import type { AppUser } from '$lib/user';
let email = $state('');
let password = $state('');
@ -11,7 +13,8 @@
error = '';
loading = true;
try {
await pb.collection('users').authWithPassword(email, password);
const u = await api.post<AppUser & { token: string }>('/auth/login', { email, password });
user.set(u);
goto('/');
} catch {
error = 'E-Mail oder Passwort falsch.';

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { pb } from '$lib/pb';
import { api } from '$lib/api';
import { user } from '$lib/user';
let vereinsname = $state('');
let email = $state('');
@ -17,8 +18,8 @@
}
loading = true;
try {
await pb.collection('users').create({ email, password, passwordConfirm, name: vereinsname });
await pb.collection('users').authWithPassword(email, password);
const u = await api.post('/auth/register', { vereinName: vereinsname, email, password, name: vereinsname });
user.set(u as any);
goto('/onboarding');
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Registrierung fehlgeschlagen.';

View file

@ -0,0 +1,18 @@
import { json, error } from '@sveltejs/kit';
import { getDb } from '$lib/server/db';
import { checkPassword, signJwt } from '$lib/server/auth';
export async function POST({ request }) {
const { email, password } = await request.json();
if (!email || !password) throw error(400, 'E-Mail und Passwort erforderlich');
const db = getDb();
const u = db.prepare('SELECT * FROM users WHERE email = ?').get(email.toLowerCase()) as any;
if (!u || !(await checkPassword(password, u.password_hash))) throw error(401, 'Ungültige Zugangsdaten');
const token = await signJwt({
sub: u.id, verein_id: u.verein_id, rolle: u.rolle, name: u.name, email: u.email
});
return json({ token, id: u.id, verein_id: u.verein_id, rolle: u.rolle, name: u.name, email: u.email });
}

View file

@ -0,0 +1,11 @@
import { json } from '@sveltejs/kit';
import { requireAuth } from '$lib/server/auth';
import { getDb } from '$lib/server/db';
export async function GET({ request }) {
const u = await requireAuth(request);
const db = getDb();
const row = db.prepare('SELECT id, verein_id, email, name, rolle FROM users WHERE id = ?').get(u.sub) as any;
if (!row) return new Response(null, { status: 401 });
return json(row);
}

View file

@ -0,0 +1,24 @@
import { json, error } from '@sveltejs/kit';
import { getDb, newId } from '$lib/server/db';
import { hashPassword, signJwt } from '$lib/server/auth';
export async function POST({ request }) {
const { vereinName, email, password, name } = await request.json();
if (!vereinName || !email || !password) throw error(400, 'Pflichtfelder fehlen');
if (password.length < 8) throw error(400, 'Passwort mindestens 8 Zeichen');
const db = getDb();
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email.toLowerCase());
if (existing) throw error(409, 'E-Mail bereits registriert');
const vereinId = newId();
const userId = newId();
const hash = await hashPassword(password);
db.prepare('INSERT INTO vereine (id, name) VALUES (?, ?)').run(vereinId, vereinName);
db.prepare('INSERT INTO users (id, verein_id, email, password_hash, name, rolle) VALUES (?, ?, ?, ?, ?, NULL)')
.run(userId, vereinId, email.toLowerCase(), hash, name || email.split('@')[0]);
const token = await signJwt({ sub: userId, verein_id: vereinId, rolle: null, name: name || email.split('@')[0], email: email.toLowerCase() });
return json({ token, id: userId, verein_id: vereinId, rolle: null, name: name || email.split('@')[0], email: email.toLowerCase() }, { status: 201 });
}

View file

@ -0,0 +1,37 @@
import { json, error } from '@sveltejs/kit';
import { getDb, newId, rows, row } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function GET({ request }) {
const u = await requireAuth(request);
const db = getDb();
const items = db.prepare(
'SELECT * FROM beitraege WHERE verein_id = ? ORDER BY name'
).all(u.verein_id);
return json(rows(items as Record<string, unknown>[]));
}
export async function POST({ request }) {
const u = await requireAuth(request);
const db = getDb();
const body = await request.json();
if (!body.name || body.betrag == null) throw error(400, 'Name und Betrag sind Pflichtfelder');
const id = newId();
db.prepare(`
INSERT INTO beitraege (id, verein_id, name, betrag, rhythmus, beschreibung)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
id,
u.verein_id,
body.name,
body.betrag,
body.rhythmus ?? 'jaehrlich',
body.beschreibung ?? null
);
const beitrag = db.prepare('SELECT * FROM beitraege WHERE id = ?').get(id);
return json(row(beitrag as Record<string, unknown>), { status: 201 });
}

View file

@ -0,0 +1,52 @@
import { json, error } from '@sveltejs/kit';
import { getDb, row } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function GET({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const beitrag = db.prepare(
'SELECT * FROM beitraege WHERE id = ? AND verein_id = ?'
).get(params.id, u.verein_id);
if (!beitrag) throw error(404, 'Beitrag nicht gefunden');
return json(row(beitrag as Record<string, unknown>));
}
export async function PUT({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const existing = db.prepare(
'SELECT id FROM beitraege WHERE id = ? AND verein_id = ?'
).get(params.id, u.verein_id);
if (!existing) throw error(404, 'Beitrag nicht gefunden');
const body = await request.json();
db.prepare(`
UPDATE beitraege SET
name = ?, betrag = ?, rhythmus = ?, beschreibung = ?,
updated = strftime('%Y-%m-%dT%H:%M:%SZ','now')
WHERE id = ? AND verein_id = ?
`).run(
body.name,
body.betrag,
body.rhythmus ?? 'jaehrlich',
body.beschreibung ?? null,
params.id,
u.verein_id
);
const beitrag = db.prepare('SELECT * FROM beitraege WHERE id = ?').get(params.id);
return json(row(beitrag as Record<string, unknown>));
}
export async function DELETE({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const result = db.prepare(
'DELETE FROM beitraege WHERE id = ? AND verein_id = ?'
).run(params.id, u.verein_id);
if (result.changes === 0) throw error(404, 'Beitrag nicht gefunden');
return new Response(null, { status: 204 });
}

View file

@ -0,0 +1,23 @@
import { json, error } from '@sveltejs/kit';
import { getDb, newId, row } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function POST({ request }) {
const u = await requireAuth(request);
const db = getDb();
const body = await request.json();
const id = newId();
db.prepare(`
INSERT INTO einladungen (id, verein_id, rolle)
VALUES (?, ?, ?)
`).run(
id,
u.verein_id,
body.rolle ?? 'trainer'
);
const einladung = db.prepare('SELECT * FROM einladungen WHERE id = ?').get(id);
return json(row(einladung as Record<string, unknown>), { status: 201 });
}

View file

@ -0,0 +1,62 @@
import { json, error } from '@sveltejs/kit';
import { getDb, newId, row } from '$lib/server/db';
import { hashPassword, signJwt } from '$lib/server/auth';
export async function GET({ params }) {
const db = getDb();
const einladung = db.prepare(`
SELECT e.*, v.name as vereinName
FROM einladungen e JOIN vereine v ON v.id = e.verein_id
WHERE e.token = ? AND e.genutzt = 0
`).get(params.token);
if (!einladung) throw error(404, 'Einladung nicht gefunden oder bereits verwendet');
return json(row(einladung as Record<string, unknown>));
}
export async function POST({ request, params }) {
const db = getDb();
const body = await request.json();
if (!body.email || !body.password || !body.name) throw error(400, 'E-Mail, Passwort und Name sind Pflichtfelder');
if (body.password.length < 8) throw error(400, 'Passwort mindestens 8 Zeichen');
const einladung = db.prepare(
'SELECT * FROM einladungen WHERE token = ? AND genutzt = 0'
).get(params.token) as Record<string, unknown> | undefined;
if (!einladung) throw error(404, 'Einladung nicht gefunden oder bereits verwendet');
const verein_id = einladung.verein_id as string;
const rolle = einladung.rolle as string;
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(body.email.toLowerCase());
if (existing) throw error(409, 'E-Mail bereits registriert');
const userId = newId();
const hash = await hashPassword(body.password);
db.prepare(`
INSERT INTO users (id, verein_id, email, password_hash, name, rolle)
VALUES (?, ?, ?, ?, ?, ?)
`).run(userId, verein_id, body.email.toLowerCase(), hash, body.name, rolle);
db.prepare(
`UPDATE einladungen SET genutzt = 1, updated = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE token = ?`
).run(params.token);
const token = await signJwt({
sub: userId,
verein_id,
rolle,
name: body.name,
email: body.email.toLowerCase()
});
return json(
{ token, id: userId, verein_id, rolle, name: body.name, email: body.email.toLowerCase() },
{ status: 201 }
);
}

View file

@ -0,0 +1,58 @@
import { json, error } from '@sveltejs/kit';
import { getDb, newId, rows } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function GET({ request }) {
const u = await requireAuth(request);
const db = getDb();
const items = db.prepare(`
SELECT e.*, m.vorname, m.nachname, b.name as beitrag_name
FROM einzuege e
JOIN mitglieder m ON m.id = e.mitglied_id
JOIN beitraege b ON b.id = e.beitrag_id
WHERE e.verein_id = ?
ORDER BY e.faellig_am
`).all(u.verein_id);
return json(rows(items as Record<string, unknown>[]));
}
export async function POST({ request }) {
const u = await requireAuth(request);
const db = getDb();
const body = await request.json();
const einzuege = Array.isArray(body) ? body : [body];
if (einzuege.length === 0) throw error(400, 'Keine Einzüge angegeben');
const insert = db.prepare(`
INSERT INTO einzuege (id, verein_id, mitglied_id, beitrag_id, betrag, faellig_am, status)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const insertMany = db.transaction((items: typeof einzuege) => {
for (const e of items) {
insert.run(
newId(),
u.verein_id,
e.mitglied_id,
e.beitrag_id,
e.betrag,
e.faellig_am ?? null,
e.status ?? 'ausstehend'
);
}
});
insertMany(einzuege);
const created = db.prepare(`
SELECT e.*, m.vorname, m.nachname, b.name as beitrag_name
FROM einzuege e
JOIN mitglieder m ON m.id = e.mitglied_id
JOIN beitraege b ON b.id = e.beitrag_id
WHERE e.verein_id = ?
ORDER BY e.faellig_am
`).all(u.verein_id);
return json(rows(created as Record<string, unknown>[]), { status: 201 });
}

View file

@ -0,0 +1,26 @@
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
const UPLOAD_DIR = process.env.UPLOAD_DIR || './data/uploads';
export async function GET({ params }) {
const filePath = join(UPLOAD_DIR, params.path);
if (!existsSync(filePath)) return new Response(null, { status: 404 });
const data = readFileSync(filePath);
const ext = filePath.split('.').pop()?.toLowerCase() || '';
const mime: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
mp4: 'video/mp4',
mov: 'video/quicktime'
};
return new Response(data, {
headers: {
'Content-Type': mime[ext] || 'application/octet-stream',
'Cache-Control': 'public, max-age=31536000'
}
});
}

View file

@ -0,0 +1,36 @@
import { json, error } from '@sveltejs/kit';
import { getDb, newId, rows, row, toArr } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function GET({ request }) {
const u = await requireAuth(request);
const db = getDb();
const items = db.prepare(
'SELECT * FROM gruppen WHERE verein_id = ? ORDER BY name'
).all(u.verein_id);
return json(rows(items as Record<string, unknown>[]));
}
export async function POST({ request }) {
const u = await requireAuth(request);
const db = getDb();
const body = await request.json();
if (!body.name) throw error(400, 'Name ist ein Pflichtfeld');
const id = newId();
db.prepare(`
INSERT INTO gruppen (id, verein_id, name, beschreibung, trainer_ids)
VALUES (?, ?, ?, ?, ?)
`).run(
id,
u.verein_id,
body.name,
body.beschreibung ?? null,
toArr(body.trainer_ids)
);
const gruppe = db.prepare('SELECT * FROM gruppen WHERE id = ?').get(id);
return json(row(gruppe as Record<string, unknown>), { status: 201 });
}

View file

@ -0,0 +1,51 @@
import { json, error } from '@sveltejs/kit';
import { getDb, row, toArr } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function GET({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const gruppe = db.prepare(
'SELECT * FROM gruppen WHERE id = ? AND verein_id = ?'
).get(params.id, u.verein_id);
if (!gruppe) throw error(404, 'Gruppe nicht gefunden');
return json(row(gruppe as Record<string, unknown>));
}
export async function PUT({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const existing = db.prepare(
'SELECT id FROM gruppen WHERE id = ? AND verein_id = ?'
).get(params.id, u.verein_id);
if (!existing) throw error(404, 'Gruppe nicht gefunden');
const body = await request.json();
db.prepare(`
UPDATE gruppen SET
name = ?, beschreibung = ?, trainer_ids = ?,
updated = strftime('%Y-%m-%dT%H:%M:%SZ','now')
WHERE id = ? AND verein_id = ?
`).run(
body.name,
body.beschreibung ?? null,
toArr(body.trainer_ids),
params.id,
u.verein_id
);
const gruppe = db.prepare('SELECT * FROM gruppen WHERE id = ?').get(params.id);
return json(row(gruppe as Record<string, unknown>));
}
export async function DELETE({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const result = db.prepare(
'DELETE FROM gruppen WHERE id = ? AND verein_id = ?'
).run(params.id, u.verein_id);
if (result.changes === 0) throw error(404, 'Gruppe nicht gefunden');
return new Response(null, { status: 204 });
}

View file

@ -1,29 +1,23 @@
import ical from 'ical-generator';
import { env } from '$env/dynamic/private';
const PB_URL = () => env.PB_URL ?? 'http://localhost:8090';
import { getDb } from '$lib/server/db';
export async function GET({ params }) {
const { vereinId } = params;
// Verein laden (öffentlich lesbar via viewRule)
const vereinRes = await fetch(
`${PB_URL()}/api/collections/vereine/records/${vereinId}`,
);
if (!vereinRes.ok) {
const db = getDb();
const verein = db.prepare('SELECT * FROM vereine WHERE id = ?').get(vereinId) as { name: string } | undefined;
if (!verein) {
return new Response('Verein nicht gefunden.', { status: 404 });
}
const verein = await vereinRes.json();
// Termine der nächsten 365 Tage laden
const von = new Date().toISOString();
const bis = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString();
const filter = encodeURIComponent(`verein_id = "${vereinId}" && beginn >= "${von}" && beginn <= "${bis}"`);
const termineRes = await fetch(
`${PB_URL()}/api/collections/termine/records?filter=${filter}&sort=beginn&perPage=500`,
);
const { items: termine = [] } = termineRes.ok ? await termineRes.json() : {};
const termine = db.prepare(
`SELECT * FROM termine WHERE verein_id = ? AND beginn >= ? AND beginn <= ? ORDER BY beginn LIMIT 500`
).all(vereinId, von, bis) as { id: string; titel: string; beginn: string; ende: string | null; ort: string | null; beschreibung: string | null }[];
// iCal-Kalender aufbauen
const cal = ical({

View file

@ -0,0 +1,65 @@
import { json, error } from '@sveltejs/kit';
import { getDb, newId, rows, row, toArr } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function GET({ request, url }) {
const u = await requireAuth(request);
const db = getDb();
const status = url.searchParams.get('status');
let items;
if (status) {
items = db.prepare(
'SELECT * FROM mitglieder WHERE verein_id = ? AND status = ? ORDER BY nachname, vorname'
).all(u.verein_id, status);
} else {
items = db.prepare(
'SELECT * FROM mitglieder WHERE verein_id = ? ORDER BY nachname, vorname'
).all(u.verein_id);
}
return json(rows(items as Record<string, unknown>[]));
}
export async function POST({ request }) {
const u = await requireAuth(request);
const db = getDb();
const body = await request.json();
if (!body.vorname || !body.nachname) throw error(400, 'Vorname und Nachname sind Pflichtfelder');
const id = newId();
db.prepare(`
INSERT INTO mitglieder (
id, verein_id, vorname, nachname, email, telefon,
geburtsdatum, eintrittsdatum, austrittsdatum,
strasse, plz, ort, iban, bic,
gruppe_ids, status, notizen,
mandatsreferenz, mandatsdatum
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
u.verein_id,
body.vorname,
body.nachname,
body.email ?? null,
body.telefon ?? null,
body.geburtsdatum ?? null,
body.eintrittsdatum ?? null,
body.austrittsdatum ?? null,
body.strasse ?? null,
body.plz ?? null,
body.ort ?? null,
body.iban ?? null,
body.bic ?? null,
toArr(body.gruppe_ids),
body.status ?? 'aktiv',
body.notizen ?? null,
body.mandatsreferenz ?? null,
body.mandatsdatum ?? null
);
const mitglied = db.prepare('SELECT * FROM mitglieder WHERE id = ?').get(id);
return json(row(mitglied as Record<string, unknown>), { status: 201 });
}

View file

@ -0,0 +1,69 @@
import { json, error } from '@sveltejs/kit';
import { getDb, row, toArr } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function GET({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const mitglied = db.prepare(
'SELECT * FROM mitglieder WHERE id = ? AND verein_id = ?'
).get(params.id, u.verein_id);
if (!mitglied) throw error(404, 'Mitglied nicht gefunden');
return json(row(mitglied as Record<string, unknown>));
}
export async function PUT({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const existing = db.prepare(
'SELECT id FROM mitglieder WHERE id = ? AND verein_id = ?'
).get(params.id, u.verein_id);
if (!existing) throw error(404, 'Mitglied nicht gefunden');
const body = await request.json();
db.prepare(`
UPDATE mitglieder SET
vorname = ?, nachname = ?, email = ?, telefon = ?,
geburtsdatum = ?, eintrittsdatum = ?, austrittsdatum = ?,
strasse = ?, plz = ?, ort = ?, iban = ?, bic = ?,
gruppe_ids = ?, status = ?, notizen = ?,
mandatsreferenz = ?, mandatsdatum = ?,
updated = strftime('%Y-%m-%dT%H:%M:%SZ','now')
WHERE id = ? AND verein_id = ?
`).run(
body.vorname,
body.nachname,
body.email ?? null,
body.telefon ?? null,
body.geburtsdatum ?? null,
body.eintrittsdatum ?? null,
body.austrittsdatum ?? null,
body.strasse ?? null,
body.plz ?? null,
body.ort ?? null,
body.iban ?? null,
body.bic ?? null,
toArr(body.gruppe_ids),
body.status ?? 'aktiv',
body.notizen ?? null,
body.mandatsreferenz ?? null,
body.mandatsdatum ?? null,
params.id,
u.verein_id
);
const mitglied = db.prepare('SELECT * FROM mitglieder WHERE id = ?').get(params.id);
return json(row(mitglied as Record<string, unknown>));
}
export async function DELETE({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const result = db.prepare(
'DELETE FROM mitglieder WHERE id = ? AND verein_id = ?'
).run(params.id, u.verein_id);
if (result.changes === 0) throw error(404, 'Mitglied nicht gefunden');
return new Response(null, { status: 204 });
}

View file

@ -0,0 +1,39 @@
import { json, error } from '@sveltejs/kit';
import { getDb, newId, rows, row, toArr } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function GET({ request }) {
const u = await requireAuth(request);
const db = getDb();
const items = db.prepare(
'SELECT * FROM nachrichten WHERE verein_id = ? ORDER BY gesendet_am DESC, created DESC'
).all(u.verein_id);
return json(rows(items as Record<string, unknown>[]));
}
export async function POST({ request }) {
const u = await requireAuth(request);
const db = getDb();
const body = await request.json();
if (!body.betreff) throw error(400, 'Betreff ist ein Pflichtfeld');
const id = newId();
const gesendet_am = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
db.prepare(`
INSERT INTO nachrichten (id, verein_id, autor_id, betreff, text, gruppe_ids, gesendet_am)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
id,
u.verein_id,
u.sub,
body.betreff,
body.text ?? '',
toArr(body.gruppe_ids),
gesendet_am
);
const nachricht = db.prepare('SELECT * FROM nachrichten WHERE id = ?').get(id);
return json(row(nachricht as Record<string, unknown>), { status: 201 });
}

View file

@ -0,0 +1,13 @@
import { error } from '@sveltejs/kit';
import { getDb } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function DELETE({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const result = db.prepare(
'DELETE FROM nachrichten WHERE id = ? AND verein_id = ?'
).run(params.id, u.verein_id);
if (result.changes === 0) throw error(404, 'Nachricht nicht gefunden');
return new Response(null, { status: 204 });
}

View file

@ -0,0 +1,68 @@
import { json, error } from '@sveltejs/kit';
import { getDb, newId, rows, row } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
import { writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
const UPLOAD_DIR = process.env.UPLOAD_DIR || './data/uploads';
export async function GET({ request }) {
const u = await requireAuth(request);
const db = getDb();
const items = db.prepare(
'SELECT * FROM neuigkeiten WHERE verein_id = ? ORDER BY created DESC'
).all(u.verein_id);
return json(rows(items as Record<string, unknown>[]));
}
export async function POST({ request }) {
const u = await requireAuth(request);
const db = getDb();
const formData = await request.formData();
const text = formData.get('text') as string | null;
const gruppeIdsRaw = formData.get('gruppe_ids') as string | null;
const terminId = formData.get('termin_id') as string | null;
let gruppe_ids: string[] = [];
if (gruppeIdsRaw) {
try { gruppe_ids = JSON.parse(gruppeIdsRaw); } catch { gruppe_ids = []; }
}
const id = newId();
const uploadPath = join(UPLOAD_DIR, u.verein_id, id);
const medien: string[] = [];
const files = formData.getAll('medien') as File[];
if (files.length > 0) {
mkdirSync(uploadPath, { recursive: true });
for (const file of files) {
if (!(file instanceof File)) continue;
const ext = file.name.split('.').pop() || 'bin';
const filename = `${newId()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
writeFileSync(join(uploadPath, filename), buffer);
medien.push(filename);
}
}
if (!text && medien.length === 0) throw error(400, 'Text oder Medien sind erforderlich');
db.prepare(`
INSERT INTO neuigkeiten (id, verein_id, autor_id, autor_name, text, medien, gruppe_ids, termin_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
u.verein_id,
u.sub,
u.name,
text ?? null,
JSON.stringify(medien),
JSON.stringify(gruppe_ids),
terminId ?? null
);
const neuigkeit = db.prepare('SELECT * FROM neuigkeiten WHERE id = ?').get(id);
return json(row(neuigkeit as Record<string, unknown>), { status: 201 });
}

View file

@ -0,0 +1,27 @@
import { error } from '@sveltejs/kit';
import { getDb, parseArr } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
import { rmSync, existsSync } from 'fs';
import { join } from 'path';
const UPLOAD_DIR = process.env.UPLOAD_DIR || './data/uploads';
export async function DELETE({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const neuigkeit = db.prepare(
'SELECT * FROM neuigkeiten WHERE id = ? AND verein_id = ?'
).get(params.id, u.verein_id) as Record<string, unknown> | undefined;
if (!neuigkeit) throw error(404, 'Neuigkeit nicht gefunden');
const uploadPath = join(UPLOAD_DIR, u.verein_id, params.id);
if (existsSync(uploadPath)) {
rmSync(uploadPath, { recursive: true, force: true });
}
db.prepare('DELETE FROM neuigkeiten WHERE id = ? AND verein_id = ?').run(params.id, u.verein_id);
return new Response(null, { status: 204 });
}

View file

@ -0,0 +1,58 @@
import { json, error } from '@sveltejs/kit';
import { getDb, newId, rows, row } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function GET({ request, url }) {
const u = await requireAuth(request);
const db = getDb();
const ort_id = url.searchParams.get('ort_id');
let items;
if (ort_id) {
items = db.prepare(`
SELECT a.* FROM ort_ausfaelle a
JOIN veranstaltungsorte o ON o.id = a.ort_id
WHERE a.ort_id = ? AND o.verein_id = ?
ORDER BY a.von
`).all(ort_id, u.verein_id);
} else {
items = db.prepare(`
SELECT a.* FROM ort_ausfaelle a
JOIN veranstaltungsorte o ON o.id = a.ort_id
WHERE o.verein_id = ?
ORDER BY a.von
`).all(u.verein_id);
}
return json(rows(items as Record<string, unknown>[]));
}
export async function POST({ request }) {
const u = await requireAuth(request);
const db = getDb();
const body = await request.json();
if (!body.ort_id || !body.von || !body.bis) throw error(400, 'ort_id, von und bis sind Pflichtfelder');
const ort = db.prepare(
'SELECT id FROM veranstaltungsorte WHERE id = ? AND verein_id = ?'
).get(body.ort_id, u.verein_id);
if (!ort) throw error(404, 'Ort nicht gefunden');
const id = newId();
db.prepare(`
INSERT INTO ort_ausfaelle (id, ort_id, von, bis, grund)
VALUES (?, ?, ?, ?, ?)
`).run(
id,
body.ort_id,
body.von,
body.bis,
body.grund ?? null
);
const ausfall = db.prepare('SELECT * FROM ort_ausfaelle WHERE id = ?').get(id);
return json(row(ausfall as Record<string, unknown>), { status: 201 });
}

View file

@ -0,0 +1,21 @@
import { error } from '@sveltejs/kit';
import { getDb } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function DELETE({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
// Verify the ausfall belongs to an ort in the user's Verein
const ausfall = db.prepare(`
SELECT a.id FROM ort_ausfaelle a
JOIN veranstaltungsorte o ON o.id = a.ort_id
WHERE a.id = ? AND o.verein_id = ?
`).get(params.id, u.verein_id);
if (!ausfall) throw error(404, 'Ausfall nicht gefunden');
db.prepare('DELETE FROM ort_ausfaelle WHERE id = ?').run(params.id);
return new Response(null, { status: 204 });
}

View file

@ -0,0 +1,37 @@
import { json, error } from '@sveltejs/kit';
import { getDb, newId, rows, row } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function GET({ request }) {
const u = await requireAuth(request);
const db = getDb();
const items = db.prepare(
'SELECT * FROM veranstaltungsorte WHERE verein_id = ? ORDER BY name'
).all(u.verein_id);
return json(rows(items as Record<string, unknown>[]));
}
export async function POST({ request }) {
const u = await requireAuth(request);
const db = getDb();
const body = await request.json();
if (!body.name) throw error(400, 'Name ist ein Pflichtfeld');
const id = newId();
db.prepare(`
INSERT INTO veranstaltungsorte (id, verein_id, name, adresse, typ, aktiv)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
id,
u.verein_id,
body.name,
body.adresse ?? null,
body.typ ?? 'sonstiges',
body.aktiv !== false ? 1 : 0
);
const ort = db.prepare('SELECT * FROM veranstaltungsorte WHERE id = ?').get(id);
return json(row(ort as Record<string, unknown>), { status: 201 });
}

View file

@ -0,0 +1,52 @@
import { json, error } from '@sveltejs/kit';
import { getDb, row } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function GET({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const ort = db.prepare(
'SELECT * FROM veranstaltungsorte WHERE id = ? AND verein_id = ?'
).get(params.id, u.verein_id);
if (!ort) throw error(404, 'Ort nicht gefunden');
return json(row(ort as Record<string, unknown>));
}
export async function PUT({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const existing = db.prepare(
'SELECT id FROM veranstaltungsorte WHERE id = ? AND verein_id = ?'
).get(params.id, u.verein_id);
if (!existing) throw error(404, 'Ort nicht gefunden');
const body = await request.json();
db.prepare(`
UPDATE veranstaltungsorte SET
name = ?, adresse = ?, typ = ?, aktiv = ?,
updated = strftime('%Y-%m-%dT%H:%M:%SZ','now')
WHERE id = ? AND verein_id = ?
`).run(
body.name,
body.adresse ?? null,
body.typ ?? 'sonstiges',
body.aktiv !== false ? 1 : 0,
params.id,
u.verein_id
);
const ort = db.prepare('SELECT * FROM veranstaltungsorte WHERE id = ?').get(params.id);
return json(row(ort as Record<string, unknown>));
}
export async function DELETE({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const result = db.prepare(
'DELETE FROM veranstaltungsorte WHERE id = ? AND verein_id = ?'
).run(params.id, u.verein_id);
if (result.changes === 0) throw error(404, 'Ort nicht gefunden');
return new Response(null, { status: 204 });
}

View file

@ -1,11 +1,13 @@
import { json } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import webpush from 'web-push';
const PB_URL = () => env.PB_URL ?? 'http://localhost:8090';
import { requireAuth } from '$lib/server/auth';
import { getDb } from '$lib/server/db';
export async function POST({ request }) {
const authHeader = request.headers.get('Authorization') ?? '';
const authUser = await requireAuth(request).catch(() => null);
if (!authUser) return json({ error: 'Nicht authentifiziert.' }, { status: 401 });
const { titel, body, url = '/nachrichten' } = await request.json();
if (!titel) return json({ error: 'Titel fehlt.' }, { status: 400 });
@ -20,21 +22,18 @@ export async function POST({ request }) {
webpush.setVapidDetails(vapidSubject, vapidPublic, vapidPrivate);
// Alle Push-Subscriptions des Vereins laden (listRule erlaubt verein-weite Abfrage)
const subRes = await fetch(
`${PB_URL()}/api/collections/push_subscriptions/records?perPage=500`,
{ headers: { Authorization: authHeader } },
);
const db = getDb();
const items = db.prepare(
'SELECT * FROM push_subscriptions WHERE verein_id = ?'
).all(authUser.verein_id) as { endpoint: string; p256dh: string; auth: string; id: string }[];
if (!subRes.ok) return json({ sent: 0 });
const { items } = await subRes.json();
if (!items?.length) return json({ sent: 0 });
if (!items.length) return json({ sent: 0 });
const payload = JSON.stringify({ title: titel, body, url });
let sent = 0;
await Promise.allSettled(
items.map(async (sub: { endpoint: string; p256dh: string; auth: string; id: string }) => {
items.map(async (sub) => {
try {
await webpush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
@ -44,10 +43,7 @@ export async function POST({ request }) {
} catch (err: unknown) {
// 410 Gone = Subscription abgelaufen → löschen
if ((err as { statusCode?: number }).statusCode === 410) {
await fetch(
`${PB_URL()}/api/collections/push_subscriptions/records/${sub.id}`,
{ method: 'DELETE', headers: { Authorization: authHeader } },
).catch(() => {});
db.prepare('DELETE FROM push_subscriptions WHERE id = ?').run(sub.id);
}
}
}),

View file

@ -1,67 +1,44 @@
import { json } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
const PB_URL = () => env.PB_URL ?? 'http://localhost:8090';
import { requireAuth } from '$lib/server/auth';
import { getDb, newId } from '$lib/server/db';
export async function POST({ request }) {
const authHeader = request.headers.get('Authorization') ?? '';
const { subscription, userId } = await request.json();
const authUser = await requireAuth(request).catch(() => null);
if (!authUser) return json({ error: 'Nicht authentifiziert.' }, { status: 401 });
if (!subscription?.endpoint || !userId) {
const { subscription } = await request.json();
if (!subscription?.endpoint) {
return json({ error: 'Ungültige Subscription.' }, { status: 400 });
}
// Alte Subscription dieses Users löschen (Gerätewechsel)
const listRes = await fetch(
`${PB_URL()}/api/collections/push_subscriptions/records?filter=user_id%3D"${userId}"&perPage=50`,
{ headers: { Authorization: authHeader } },
);
if (listRes.ok) {
const { items } = await listRes.json();
await Promise.all(
(items ?? []).map((r: { id: string }) =>
fetch(`${PB_URL()}/api/collections/push_subscriptions/records/${r.id}`, {
method: 'DELETE',
headers: { Authorization: authHeader },
}),
),
);
}
const db = getDb();
// Alte Subscriptions dieses Users löschen (Gerätewechsel)
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(authUser.sub);
// Neue Subscription speichern
const res = await fetch(`${PB_URL()}/api/collections/push_subscriptions/records`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
body: JSON.stringify({
user_id: userId,
endpoint: subscription.endpoint,
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
}),
});
db.prepare(`
INSERT INTO push_subscriptions (id, user_id, verein_id, endpoint, p256dh, auth)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
newId(),
authUser.sub,
authUser.verein_id,
subscription.endpoint,
subscription.keys.p256dh,
subscription.keys.auth,
);
if (!res.ok) return json({ error: 'Fehler beim Speichern.' }, { status: 500 });
return json({ success: true });
}
export async function DELETE({ request }) {
const authHeader = request.headers.get('Authorization') ?? '';
const { userId } = await request.json();
const authUser = await requireAuth(request).catch(() => null);
if (!authUser) return json({ success: true });
const listRes = await fetch(
`${PB_URL()}/api/collections/push_subscriptions/records?filter=user_id%3D"${userId}"&perPage=50`,
{ headers: { Authorization: authHeader } },
);
if (!listRes.ok) return json({ success: true });
const db = getDb();
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(authUser.sub);
const { items } = await listRes.json();
await Promise.all(
(items ?? []).map((r: { id: string }) =>
fetch(`${PB_URL()}/api/collections/push_subscriptions/records/${r.id}`, {
method: 'DELETE',
headers: { Authorization: authHeader },
}),
),
);
return json({ success: true });
}

View file

@ -0,0 +1,58 @@
import { json, error } from '@sveltejs/kit';
import { getDb, newId, rows, row } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function GET({ request, url }) {
const u = await requireAuth(request);
const db = getDb();
const beitrag_id = url.searchParams.get('beitrag_id');
let items;
if (beitrag_id) {
items = db.prepare(`
SELECT r.* FROM reaktionen r
JOIN neuigkeiten n ON n.id = r.beitrag_id
WHERE r.beitrag_id = ? AND n.verein_id = ?
ORDER BY r.created
`).all(beitrag_id, u.verein_id);
} else {
items = db.prepare(`
SELECT r.* FROM reaktionen r
JOIN neuigkeiten n ON n.id = r.beitrag_id
WHERE n.verein_id = ?
ORDER BY r.created
`).all(u.verein_id);
}
return json(rows(items as Record<string, unknown>[]));
}
export async function POST({ request }) {
const u = await requireAuth(request);
const db = getDb();
const body = await request.json();
if (!body.beitrag_id) throw error(400, 'beitrag_id ist erforderlich');
const beitrag = db.prepare(
'SELECT id FROM neuigkeiten WHERE id = ? AND verein_id = ?'
).get(body.beitrag_id, u.verein_id);
if (!beitrag) throw error(404, 'Beitrag nicht gefunden');
const id = newId();
try {
db.prepare(`
INSERT INTO reaktionen (id, beitrag_id, user_id)
VALUES (?, ?, ?)
`).run(id, body.beitrag_id, u.sub);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes('UNIQUE')) throw error(409, 'Reaktion bereits vorhanden');
throw e;
}
const reaktion = db.prepare('SELECT * FROM reaktionen WHERE id = ?').get(id);
return json(row(reaktion as Record<string, unknown>), { status: 201 });
}

View file

@ -0,0 +1,13 @@
import { error } from '@sveltejs/kit';
import { getDb } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function DELETE({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const result = db.prepare(
'DELETE FROM reaktionen WHERE id = ? AND user_id = ?'
).run(params.id, u.sub);
if (result.changes === 0) throw error(404, 'Reaktion nicht gefunden oder keine Berechtigung');
return new Response(null, { status: 204 });
}

View file

@ -0,0 +1,63 @@
import { json, error } from '@sveltejs/kit';
import { getDb, newId, rows, row, toArr } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function GET({ request, url }) {
const u = await requireAuth(request);
const db = getDb();
const von = url.searchParams.get('von');
const bis = url.searchParams.get('bis');
let query = 'SELECT * FROM termine WHERE verein_id = ?';
const params: unknown[] = [u.verein_id];
if (von) {
query += ' AND beginn >= ?';
params.push(von);
}
if (bis) {
query += ' AND beginn <= ?';
params.push(bis);
}
query += ' ORDER BY beginn';
const items = db.prepare(query).all(...params);
return json(rows(items as Record<string, unknown>[]));
}
export async function POST({ request }) {
const u = await requireAuth(request);
const db = getDb();
const body = await request.json();
if (!body.titel || !body.beginn) throw error(400, 'Titel und Beginn sind Pflichtfelder');
const id = newId();
db.prepare(`
INSERT INTO termine (
id, verein_id, titel, beschreibung, beginn, ende,
ort, ort_id, gruppe_ids, durchfuehrender_id,
verfuegbarkeit, rrule, serie_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
u.verein_id,
body.titel,
body.beschreibung ?? null,
body.beginn,
body.ende ?? null,
body.ort ?? null,
body.ort_id ?? null,
toArr(body.gruppe_ids),
body.durchfuehrender_id ?? null,
body.verfuegbarkeit ?? 'offen',
body.rrule ?? null,
body.serie_id ?? null
);
const termin = db.prepare('SELECT * FROM termine WHERE id = ?').get(id);
return json(row(termin as Record<string, unknown>), { status: 201 });
}

View file

@ -0,0 +1,83 @@
import { json, error } from '@sveltejs/kit';
import { getDb, row, toArr } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function GET({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const termin = db.prepare(
'SELECT * FROM termine WHERE id = ? AND verein_id = ?'
).get(params.id, u.verein_id);
if (!termin) throw error(404, 'Termin nicht gefunden');
return json(row(termin as Record<string, unknown>));
}
export async function PUT({ request, params }) {
const u = await requireAuth(request);
const db = getDb();
const existing = db.prepare(
'SELECT id FROM termine WHERE id = ? AND verein_id = ?'
).get(params.id, u.verein_id);
if (!existing) throw error(404, 'Termin nicht gefunden');
const body = await request.json();
db.prepare(`
UPDATE termine SET
titel = ?, beschreibung = ?, beginn = ?, ende = ?,
ort = ?, ort_id = ?, gruppe_ids = ?, durchfuehrender_id = ?,
verfuegbarkeit = ?, rrule = ?, serie_id = ?,
updated = strftime('%Y-%m-%dT%H:%M:%SZ','now')
WHERE id = ? AND verein_id = ?
`).run(
body.titel,
body.beschreibung ?? null,
body.beginn,
body.ende ?? null,
body.ort ?? null,
body.ort_id ?? null,
toArr(body.gruppe_ids),
body.durchfuehrender_id ?? null,
body.verfuegbarkeit ?? 'offen',
body.rrule ?? null,
body.serie_id ?? null,
params.id,
u.verein_id
);
const termin = db.prepare('SELECT * FROM termine WHERE id = ?').get(params.id);
return json(row(termin as Record<string, unknown>));
}
export async function DELETE({ request, params, url }) {
const u = await requireAuth(request);
const db = getDb();
const deleteSerie = url.searchParams.get('serie') === 'true';
if (deleteSerie) {
const termin = db.prepare(
'SELECT serie_id FROM termine WHERE id = ? AND verein_id = ?'
).get(params.id, u.verein_id) as { serie_id: string | null } | undefined;
if (!termin) throw error(404, 'Termin nicht gefunden');
if (termin.serie_id) {
db.prepare(
'DELETE FROM termine WHERE serie_id = ? AND verein_id = ?'
).run(termin.serie_id, u.verein_id);
} else {
db.prepare(
'DELETE FROM termine WHERE id = ? AND verein_id = ?'
).run(params.id, u.verein_id);
}
} else {
const result = db.prepare(
'DELETE FROM termine WHERE id = ? AND verein_id = ?'
).run(params.id, u.verein_id);
if (result.changes === 0) throw error(404, 'Termin nicht gefunden');
}
return new Response(null, { status: 204 });
}

View file

@ -0,0 +1,39 @@
import { json, error } from '@sveltejs/kit';
import { getDb, row } from '$lib/server/db';
import { requireAuth } from '$lib/server/auth';
export async function GET({ request }) {
const u = await requireAuth(request);
const db = getDb();
const verein = db.prepare('SELECT * FROM vereine WHERE id = ?').get(u.verein_id);
if (!verein) throw error(404, 'Verein nicht gefunden');
return json(row(verein as Record<string, unknown>));
}
export async function PATCH({ request }) {
const u = await requireAuth(request);
const db = getDb();
const body = await request.json();
const allowed = [
'name', 'adresse', 'plz', 'ort', 'bundesland',
'email', 'telefon', 'website',
'glaeubigerid', 'iban', 'bic',
'dosb_mitglied'
];
const fields = Object.keys(body).filter(k => allowed.includes(k));
if (fields.length === 0) throw error(400, 'Keine gültigen Felder');
const sets = fields.map(k => `${k} = ?`).join(', ');
const vals = fields.map(k => {
if (k === 'dosb_mitglied') return body[k] ? 1 : 0;
return body[k];
});
db.prepare(`UPDATE vereine SET ${sets}, updated = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE id = ?`)
.run(...vals, u.verein_id);
const verein = db.prepare('SELECT * FROM vereine WHERE id = ?').get(u.verein_id);
return json(row(verein as Record<string, unknown>));
}

View file

@ -2,7 +2,9 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { pb } from '$lib/pb';
import { get } from 'svelte/store';
import { api } from '$lib/api';
import { user } from '$lib/user';
import type { Einladung } from '$lib/types';
const token = $derived($page.params.token as string);
@ -20,17 +22,14 @@
let formError = $state('');
onMount(async () => {
if (pb.authStore.isValid) {
if (!!get(user)) {
goto('/');
return;
}
try {
const inv = await pb.collection('einladungen')
.getFirstListItem<Einladung>(`token = "${token}" && genutzt = false`, {
expand: 'verein_id',
});
const inv = await api.get<Einladung & { vereinName: string }>('/einladungen/' + token);
einladung = inv;
vereinName = (inv as any).expand?.verein_id?.name ?? '';
vereinName = (inv as any).vereinName ?? '';
} catch {
fehler = 'Dieser Einladungslink ist ungültig oder wurde bereits verwendet.';
} finally {
@ -43,16 +42,12 @@
if (password !== passwordConfirm) { formError = 'Passwörter stimmen nicht überein.'; return; }
formError = ''; saving = true;
try {
await pb.collection('users').create({
email: email.trim(),
const u = await api.post<any>(`/einladungen/${token}`, {
email: email.trim(),
password,
passwordConfirm,
name: name.trim(),
verein_id: einladung.verein_id,
rolle: einladung.rolle,
name: name.trim(),
});
await pb.collection('users').authWithPassword(email.trim(), password);
await pb.collection('einladungen').update(einladung.id, { genutzt: true });
user.set(u);
goto('/');
} catch (e: unknown) {
formError = e instanceof Error ? e.message : 'Registrierung fehlgeschlagen.';

View file

@ -1,7 +1,9 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { pb } from '$lib/pb';
import { get } from 'svelte/store';
import { api } from '$lib/api';
import { user } from '$lib/user';
let schritt = $state(1);
let loading = $state(false);
@ -13,36 +15,31 @@
let fertigName = $state('');
onMount(() => {
if (!pb.authStore.isValid) {
const u = get(user);
if (!u) {
goto('/login');
return;
}
if (pb.authStore.record?.verein_id) {
if (u.verein_id) {
goto('/');
return;
}
// Vereinsname aus Registration vorbelegen
vereinsname = pb.authStore.record?.name ?? '';
vereinsname = u.name ?? '';
});
async function vereinAnlegen() {
if (!vereinsname.trim()) return;
error = ''; loading = true;
try {
const verein = await pb.collection('vereine').create({
name: vereinsname.trim(),
ort: ort.trim() || null,
plan: 'free',
dosb_mitglied: false,
const updated = await api.post<any>('/onboarding/verein', {
name: vereinsname.trim(),
ort: ort.trim() || null,
});
await pb.collection('users').update(pb.authStore.record!.id, {
verein_id: verein.id,
});
// Auth-Token aktualisieren damit verein_id im Record steht
await pb.collection('users').authRefresh();
fertigName = verein.name;
// user store mit aktualisiertem verein_id updaten
const u = get(user);
if (u) user.set({ ...u, verein_id: updated.verein_id });
fertigName = vereinsname.trim();
schritt = 3;
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Fehler beim Anlegen.';

View file

@ -1,42 +1,27 @@
version: "3.8"
services:
pocketbase-staging:
image: ghcr.io/muchobien/pocketbase:latest
container_name: vereinshaus-staging-pocketbase
restart: unless-stopped
command: ["--migrationsDir=/pb_data/migrations"]
volumes:
- /volume1/docker/vereinshaus-staging/pocketbase/data:/pb_data
- /volume1/docker/vereinshaus-staging/pocketbase/storage:/pb_public
- /volume1/docker/vereinshaus-staging/pocketbase/data/pb_hooks:/pb_hooks
- /volume1/docker/vereinshaus-staging/pocketbase/migrations:/pb_data/migrations
environment:
- TZ=Europe/Berlin
- BREVO_KEY=${BREVO_KEY}
- BREVO_SENDER=${BREVO_SENDER:-noreply@vereins.haus}
networks:
default: {}
npm_bridge:
ipv4_address: 172.25.0.14
app-staging:
build:
context: ./app
dockerfile: Dockerfile
args:
VITE_PB_URL: https://api-staging.vereins.haus
image: vereinshaus-staging-app
container_name: vereinshaus-staging-app
restart: unless-stopped
volumes:
- /volume1/docker/vereinshaus-staging/data:/data
environment:
- TZ=Europe/Berlin
- HOST=0.0.0.0
- PORT=3000
- DB_PATH=/data/vereinshaus.db
- UPLOAD_DIR=/data/uploads
- JWT_SECRET=${JWT_SECRET:-staging-secret-change-me}
- PUBLIC_VAPID_KEY=${PUBLIC_VAPID_KEY}
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
- VAPID_SUBJECT=${VAPID_SUBJECT:-mailto:info@vereins.haus}
- PB_URL=http://pocketbase-staging:8090
- BREVO_KEY=${BREVO_KEY}
- BREVO_SENDER=${BREVO_SENDER:-noreply@vereins.haus}
networks:
default: {}
npm_bridge:

View file

@ -1,42 +1,27 @@
version: "3.8"
services:
pocketbase:
image: ghcr.io/muchobien/pocketbase:latest
container_name: vereinshaus-pocketbase
restart: unless-stopped
command: ["--migrationsDir=/pb_data/migrations"]
volumes:
- /volume1/docker/vereinshaus/pocketbase/data:/pb_data
- /volume1/docker/vereinshaus/pocketbase/storage:/pb_public
- /volume1/docker/vereinshaus/pocketbase/data/pb_hooks:/pb_hooks
- /volume1/docker/vereinshaus/pocketbase/migrations:/pb_data/migrations
environment:
- TZ=Europe/Berlin
- BREVO_KEY=${BREVO_KEY}
- BREVO_SENDER=${BREVO_SENDER:-noreply@vereins.haus}
networks:
default: {}
npm_bridge:
ipv4_address: 172.25.0.12
app:
build:
context: ./app
dockerfile: Dockerfile
args:
VITE_PB_URL: https://api.vereins.haus
image: vereinshaus-app
container_name: vereinshaus-app
restart: unless-stopped
volumes:
- /volume1/docker/vereinshaus/data:/data
environment:
- TZ=Europe/Berlin
- HOST=0.0.0.0
- PORT=3000
- DB_PATH=/data/vereinshaus.db
- UPLOAD_DIR=/data/uploads
- JWT_SECRET=${JWT_SECRET}
- PUBLIC_VAPID_KEY=${PUBLIC_VAPID_KEY}
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
- VAPID_SUBJECT=${VAPID_SUBJECT:-mailto:info@vereins.haus}
- PB_URL=http://pocketbase:8090
- BREVO_KEY=${BREVO_KEY}
- BREVO_SENDER=${BREVO_SENDER:-noreply@vereins.haus}
networks:
default: {}
npm_bridge:

View file

@ -1,264 +1,153 @@
#!/usr/bin/env node
// Testdaten für vereins.haus Staging
// Aufruf: PB_URL=http://localhost:8090 PB_EMAIL=admin@test.de PB_PASSWORD=Test123456! node scripts/seed.js
// Seed-Script für vereins.haus (SvelteKit + SQLite, kein PocketBase)
// Verwendung: APP_URL=https://staging.vereins.haus node scripts/seed.js
const PB_URL = process.env.PB_URL || 'http://localhost:8090';
const PB_EMAIL = process.env.PB_EMAIL || '';
const PB_PWD = process.env.PB_PASSWORD || '';
const BASE = process.env.APP_URL || 'http://localhost:3000';
if (!PB_EMAIL || !PB_PWD) {
console.error('Fehler: PB_EMAIL und PB_PASSWORD setzen.');
process.exit(1);
}
let token = '';
async function pb(method, path, body) {
const res = await fetch(`${PB_URL}/api/${path}`, {
method,
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: token } : {}) },
body: body ? JSON.stringify(body) : undefined,
async function req(method, path, body, token) {
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${BASE}/api${path}`, {
method, headers, body: body ? JSON.stringify(body) : undefined,
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(`${method} /${path}${res.status}: ${JSON.stringify(json)}`);
if (!res.ok) throw new Error(`${method} ${path}${res.status}: ${JSON.stringify(json)}`);
return json;
}
async function find(collection, filter) {
const r = await pb('GET', `collections/${collection}/records?filter=${encodeURIComponent(filter)}&perPage=1`);
return r.items?.[0] ?? null;
}
async function main() {
console.log(`→ Seed gegen ${BASE}`);
async function create(collection, data) {
return pb('POST', `collections/${collection}/records`, data);
}
async function update(collection, id, data) {
return pb('PATCH', `collections/${collection}/records/${id}`, data);
}
// ── 1. Admin-Login ──────────────────────────────────────────────────────────
console.log('→ Admin-Login…');
const auth = await pb('POST', 'collections/_superusers/auth-with-password', {
identity: PB_EMAIL, password: PB_PWD,
});
token = auth.token;
console.log(' ✓ Eingeloggt als', PB_EMAIL);
// ── 2. Verein ────────────────────────────────────────────────────────────────
console.log('→ Verein anlegen…');
let verein = await find('vereine', 'name = "TSV Musterstadt 1983 e.V."');
if (!verein) {
verein = await create('vereine', {
name: 'TSV Musterstadt 1983 e.V.',
adresse: 'Vereinsstraße 12',
plz: '80333',
ort: 'Musterstadt',
bundesland: 'BY',
plan: 'starter',
dosb_mitglied: true,
email: 'info@tsv-musterstadt.de',
telefon: '089 123456',
website: 'https://tsv-musterstadt.de',
glaeubigerid: 'DE98ZZZ09999999999',
iban: 'DE89370400440532013000',
bic: 'COBADEFFXXX',
});
}
console.log(' ✓ Verein:', verein.name, `(${verein.id})`);
// ── 3. Admin-User ────────────────────────────────────────────────────────────
console.log('→ Admin-User…');
let adminUser = await find('users', 'email = "vorstand@tsv-musterstadt.de"');
if (!adminUser) {
adminUser = await create('users', {
// 1. Verein + Admin registrieren
console.log('→ Registrierung...');
const auth = await req('POST', '/auth/register', {
vereinName: 'TSV Musterstadt 1983 e.V.',
email: 'vorstand@tsv-musterstadt.de',
password: 'Test123456!', passwordConfirm: 'Test123456!',
name: 'Max Mustermann', verein_id: verein.id, rolle: null, // null = admin
emailVisibility: true,
password: 'Test123456!',
name: 'Max Vorstand',
});
}
console.log(' ✓ Admin:', adminUser.email);
const T = auth.token;
const VID = auth.verein_id;
console.log(` ✓ Verein ID: ${VID}`);
console.log(` ✓ User: vorstand@tsv-musterstadt.de`);
// ── 4. Gruppen ───────────────────────────────────────────────────────────────
console.log('→ Gruppen…');
const gruppenDef = ['Vorstand', 'Aktive Mitglieder', 'Jugend U15', 'Senioren'];
const gruppen = {};
for (const name of gruppenDef) {
let g = await find('gruppen', `name = "${name}" && verein_id = "${verein.id}"`);
if (!g) g = await create('gruppen', { verein_id: verein.id, name });
gruppen[name] = g.id;
}
console.log(' ✓', Object.keys(gruppen).join(', '));
// 2. Verein-Details ergänzen
await req('PATCH', '/vereine', {
adresse: 'Musterstraße 1', plz: '12345', ort: 'Musterstadt',
bundesland: 'Bayern', plan: 'starter', dosb_mitglied: true,
email: 'info@tsv-musterstadt.de', telefon: '01234 56789',
glaeubigerid: 'DE98ZZZ09999999999',
iban: 'DE89370400440532013000', bic: 'COBADEFFXXX',
}, T);
// ── 5. Mitglieder ────────────────────────────────────────────────────────────
console.log('→ Mitglieder (18)…');
const mitgliederDef = [
// Vorstand
{ vorname: 'Max', nachname: 'Mustermann', email: 'max.mustermann@example.de', telefon: '0170 1111111', geburtsdatum: '1975-03-15', eintrittsdatum: '2000-01-01', strasse: 'Hauptstraße 1', plz: '80333', ort: 'Musterstadt', iban: 'DE89370400440532013000', bic: 'COBADEFFXXX', status: 'aktiv', gruppe: 'Vorstand', mandatsreferenz: 'MANDAT-001', mandatsdatum: '2020-01-10' },
{ vorname: 'Sabine', nachname: 'Richter', email: 'sabine.richter@example.de', telefon: '0171 2222222', geburtsdatum: '1980-07-22', eintrittsdatum: '2005-03-01', strasse: 'Birkenweg 5', plz: '80334', ort: 'Musterstadt', iban: 'DE27100777770209299700', bic: 'SSKMDEMMXXX', status: 'aktiv', gruppe: 'Vorstand', mandatsreferenz: 'MANDAT-002', mandatsdatum: '2020-01-10' },
// Aktive mit IBAN
{ vorname: 'Anna', nachname: 'Schmidt', email: 'anna.schmidt@example.de', telefon: '0172 3333333', geburtsdatum: '1990-11-05', eintrittsdatum: '2015-09-01', strasse: 'Rosenstraße 8', plz: '80333', ort: 'Musterstadt', iban: 'DE61500105179767440929', bic: 'BELADEBEXXX', status: 'aktiv', gruppe: 'Aktive Mitglieder', mandatsreferenz: 'MANDAT-003', mandatsdatum: '2021-04-15' },
{ vorname: 'Peter', nachname: 'Wagner', email: 'peter.wagner@example.de', telefon: '0173 4444444', geburtsdatum: '1985-02-28', eintrittsdatum: '2010-01-15', strasse: 'Gartenweg 12', plz: '80334', ort: 'Musterstadt', iban: 'DE24500105171911148770', bic: 'BELADEBEXXX', status: 'aktiv', gruppe: 'Aktive Mitglieder', mandatsreferenz: 'MANDAT-004', mandatsdatum: '2021-04-15' },
{ vorname: 'Thomas', nachname: 'Fischer', email: 'thomas.fischer@example.de', telefon: '0174 5555555', geburtsdatum: '1978-06-10', eintrittsdatum: '2008-04-01', strasse: 'Lindenallee 3', plz: '80335', ort: 'Musterstadt', iban: 'DE12500105176228935005', bic: 'BELADEBEXXX', status: 'aktiv', gruppe: 'Aktive Mitglieder', mandatsreferenz: 'MANDAT-005', mandatsdatum: '2021-04-15' },
{ vorname: 'Claudia', nachname: 'König', email: 'claudia.koenig@example.de', telefon: '0175 6666666', geburtsdatum: '1992-09-17', eintrittsdatum: '2018-06-01', strasse: 'Feldweg 7', plz: '80333', ort: 'Musterstadt', iban: 'DE67200501001234567890', bic: 'HASPDEHHXXX', status: 'aktiv', gruppe: 'Aktive Mitglieder', mandatsreferenz: 'MANDAT-006', mandatsdatum: '2022-01-20' },
{ vorname: 'Michael', nachname: 'Koch', email: 'michael.koch@example.de', telefon: '0176 7777777', geburtsdatum: '1983-04-03', eintrittsdatum: '2012-02-01', strasse: 'Bergstraße 22', plz: '80336', ort: 'Musterstadt', iban: 'DE86200400600526015800', bic: 'COBADEFFXXX', status: 'aktiv', gruppe: 'Aktive Mitglieder', mandatsreferenz: 'MANDAT-007', mandatsdatum: '2021-04-15' },
{ vorname: 'Lisa', nachname: 'Zimmermann', email: 'lisa.zimm@example.de', telefon: '0177 8888888', geburtsdatum: '1995-12-25', eintrittsdatum: '2020-10-01', strasse: 'Blumenstraße 4', plz: '80333', ort: 'Musterstadt', iban: 'DE21700519950021267002', bic: 'BYLADEM1AUG', status: 'aktiv', gruppe: 'Aktive Mitglieder', mandatsreferenz: 'MANDAT-008', mandatsdatum: '2022-03-05' },
{ vorname: 'Petra', nachname: 'Schreiber', email: 'petra.schreiber@example.de', telefon: '0178 9999999', geburtsdatum: '1988-08-14', eintrittsdatum: '2014-07-01', strasse: 'Weinbergweg 9', plz: '80334', ort: 'Musterstadt', iban: 'DE36200400600532013004', bic: 'COBADEFFXXX', status: 'aktiv', gruppe: 'Aktive Mitglieder', mandatsreferenz: 'MANDAT-009', mandatsdatum: '2021-04-15' },
// Aktive ohne IBAN
{ vorname: 'Maria', nachname: 'Becker', email: 'maria.becker@example.de', telefon: '0179 1010101', geburtsdatum: '1987-01-30', eintrittsdatum: '2016-03-01', strasse: 'Kirchgasse 6', plz: '80335', ort: 'Musterstadt', iban: null, bic: null, status: 'aktiv', gruppe: 'Aktive Mitglieder', mandatsreferenz: null, mandatsdatum: null },
{ vorname: 'Julia', nachname: 'Müller', email: 'julia.mueller@example.de', telefon: '0151 1111222', geburtsdatum: '1993-05-19', eintrittsdatum: '2019-01-01', strasse: 'Parkstraße 11', plz: '80333', ort: 'Musterstadt', iban: null, bic: null, status: 'aktiv', gruppe: 'Aktive Mitglieder', mandatsreferenz: null, mandatsdatum: null },
{ vorname: 'Markus', nachname: 'Schäfer', email: 'markus.schaefer@example.de', telefon: '0152 3333444', geburtsdatum: '1991-07-07', eintrittsdatum: '2021-05-01', strasse: 'Schulweg 2', plz: '80336', ort: 'Musterstadt', iban: null, bic: null, status: 'aktiv', gruppe: 'Aktive Mitglieder', mandatsreferenz: null, mandatsdatum: null },
// Jugend
{ vorname: 'Lena', nachname: 'Bauer', email: null, telefon: '0160 1234567', geburtsdatum: '2012-03-22', eintrittsdatum: '2023-09-01', strasse: 'Schulstraße 14', plz: '80333', ort: 'Musterstadt', iban: 'DE47200400600128491600', bic: 'COBADEFFXXX', status: 'aktiv', gruppe: 'Jugend U15', mandatsreferenz: 'MANDAT-013', mandatsdatum: '2023-09-01' },
{ vorname: 'Kevin', nachname: 'Hoffmann', email: null, telefon: '0160 7654321', geburtsdatum: '2013-11-08', eintrittsdatum: '2023-09-01', strasse: 'Waldweg 3', plz: '80334', ort: 'Musterstadt', iban: null, bic: null, status: 'aktiv', gruppe: 'Jugend U15', mandatsreferenz: null, mandatsdatum: null },
{ vorname: 'Emma', nachname: 'Klein', email: null, telefon: '0160 1122334', geburtsdatum: '2011-06-15', eintrittsdatum: '2022-09-01', strasse: 'Seeweg 7', plz: '80335', ort: 'Musterstadt', iban: null, bic: null, status: 'aktiv', gruppe: 'Jugend U15', mandatsreferenz: null, mandatsdatum: null },
// Senioren
{ vorname: 'Gertrude', nachname: 'Neumann', email: 'g.neumann@example.de', telefon: '089 9876543', geburtsdatum: '1948-04-12', eintrittsdatum: '1990-01-01', strasse: 'Ahornweg 1', plz: '80333', ort: 'Musterstadt', iban: 'DE43500105176118506698', bic: 'BELADEBEXXX', status: 'aktiv', gruppe: 'Senioren', mandatsreferenz: 'MANDAT-016', mandatsdatum: '2019-06-01' },
// Passiv / Ausgetreten
{ vorname: 'Hans', nachname: 'Schneider', email: 'hans.schneider@example.de', telefon: '0173 0000001', geburtsdatum: '1965-10-20', eintrittsdatum: '1995-04-01', strasse: 'Bergblick 5', plz: '80334', ort: 'Musterstadt', iban: null, bic: null, status: 'passiv', gruppe: 'Senioren', mandatsreferenz: null, mandatsdatum: null },
{ vorname: 'Horst', nachname: 'Braun', email: 'horst.braun@example.de', telefon: null, geburtsdatum: '1960-02-14', eintrittsdatum: '1988-01-01', strasse: 'Alte Gasse 3', plz: '80336', ort: 'Musterstadt', iban: null, bic: null, status: 'ausgetreten', gruppe: 'Aktive Mitglieder', mandatsreferenz: null, mandatsdatum: null, austrittsdatum: '2024-12-31' },
];
// 3. Gruppen
console.log('→ Gruppen...');
const gruppen = {};
for (const name of ['Vorstand', 'Aktive Mitglieder', 'Jugend U15', 'Senioren']) {
const g = await req('POST', '/gruppen', { name }, T);
gruppen[name] = g.id;
}
console.log(`${Object.keys(gruppen).length} Gruppen`);
let mitgliederIds = [];
for (const m of mitgliederDef) {
let rec = await find('mitglieder', `vorname = "${m.vorname}" && nachname = "${m.nachname}" && verein_id = "${verein.id}"`);
if (!rec) {
rec = await create('mitglieder', {
verein_id: verein.id,
vorname: m.vorname,
nachname: m.nachname,
email: m.email || null,
telefon: m.telefon || null,
geburtsdatum: m.geburtsdatum,
eintrittsdatum: m.eintrittsdatum,
austrittsdatum: m.austrittsdatum || null,
strasse: m.strasse,
plz: m.plz,
ort: m.ort,
iban: m.iban || null,
bic: m.bic || null,
mandatsreferenz:m.mandatsreferenz || null,
mandatsdatum: m.mandatsdatum || null,
status: m.status,
gruppe_ids: [gruppen[m.gruppe]].filter(Boolean),
// 4. Mitglieder
console.log('→ Mitglieder (18)...');
const mitgliederDaten = [
{ vorname: 'Max', nachname: 'Vorstand', email: 'vorstand@tsv-musterstadt.de', status: 'aktiv', gruppe_ids: [gruppen['Vorstand'], gruppen['Aktive Mitglieder']], eintrittsdatum: '2010-01-01' },
{ vorname: 'Anna', nachname: 'Schmidt', email: 'anna.schmidt@example.de', status: 'aktiv', gruppe_ids: [gruppen['Aktive Mitglieder']], eintrittsdatum: '2015-03-15', iban: 'DE89370400440532013001' },
{ vorname: 'Thomas', nachname: 'Müller', email: 'thomas.mueller@example.de', status: 'aktiv', gruppe_ids: [gruppen['Aktive Mitglieder'], gruppen['Senioren']], eintrittsdatum: '2012-06-01' },
{ vorname: 'Lisa', nachname: 'Weber', email: 'lisa.weber@example.de', status: 'aktiv', gruppe_ids: [gruppen['Jugend U15']], eintrittsdatum: '2022-09-01', geburtsdatum: '2010-04-12' },
{ vorname: 'Klaus', nachname: 'Fischer', email: 'klaus.fischer@example.de', status: 'aktiv', gruppe_ids: [gruppen['Senioren']], eintrittsdatum: '2008-01-15' },
{ vorname: 'Maria', nachname: 'Bauer', email: 'maria.bauer@example.de', status: 'aktiv', gruppe_ids: [gruppen['Aktive Mitglieder']], eintrittsdatum: '2019-02-20' },
{ vorname: 'Peter', nachname: 'Wagner', email: 'peter.wagner@example.de', status: 'aktiv', gruppe_ids: [gruppen['Aktive Mitglieder']], eintrittsdatum: '2017-05-10', iban: 'DE89370400440532013002' },
{ vorname: 'Julia', nachname: 'Becker', email: 'julia.becker@example.de', status: 'aktiv', gruppe_ids: [gruppen['Jugend U15']], eintrittsdatum: '2023-01-01', geburtsdatum: '2011-07-22' },
{ vorname: 'Stefan', nachname: 'Hoffmann', email: 'stefan.hoffmann@example.de', status: 'passiv', gruppe_ids: [], eintrittsdatum: '2005-03-01' },
{ vorname: 'Sandra', nachname: 'Koch', email: 'sandra.koch@example.de', status: 'aktiv', gruppe_ids: [gruppen['Senioren']], eintrittsdatum: '2014-11-15' },
{ vorname: 'Michael', nachname: 'Schäfer', email: 'michael.schaefer@example.de', status: 'aktiv', gruppe_ids: [gruppen['Aktive Mitglieder']], eintrittsdatum: '2020-04-01' },
{ vorname: 'Sabine', nachname: 'Zimmermann', email: 'sabine.zimm@example.de', status: 'aktiv', gruppe_ids: [gruppen['Aktive Mitglieder']], eintrittsdatum: '2018-08-20' },
{ vorname: 'Andreas', nachname: 'Braun', email: 'andreas.braun@example.de', status: 'aktiv', gruppe_ids: [gruppen['Senioren']], eintrittsdatum: '2011-01-01' },
{ vorname: 'Monika', nachname: 'Richter', email: 'monika.richter@example.de', status: 'passiv', gruppe_ids: [], eintrittsdatum: '2009-06-15' },
{ vorname: 'Tobias', nachname: 'Wolf', email: 'tobias.wolf@example.de', status: 'aktiv', gruppe_ids: [gruppen['Jugend U15']], eintrittsdatum: '2022-01-15', geburtsdatum: '2012-01-30' },
{ vorname: 'Eva', nachname: 'Krause', email: 'eva.krause@example.de', status: 'aktiv', gruppe_ids: [gruppen['Aktive Mitglieder']], eintrittsdatum: '2021-03-01' },
{ vorname: 'Markus', nachname: 'Schwarz', email: 'markus.schwarz@example.de', status: 'aktiv', gruppe_ids: [gruppen['Aktive Mitglieder'], gruppen['Vorstand']], eintrittsdatum: '2013-09-01' },
{ vorname: 'Nina', nachname: 'Lange', email: 'nina.lange@example.de', status: 'ausgetreten', gruppe_ids: [], eintrittsdatum: '2016-02-01', austrittsdatum: '2023-12-31' },
];
for (const m of mitgliederDaten) await req('POST', '/mitglieder', m, T);
console.log(` ✓ 18 Mitglieder`);
// 5. Veranstaltungsorte
console.log('→ Orte...');
const orte = {};
for (const o of [
{ name: 'Turnhalle Grundschule Muster', adresse: 'Schulweg 5, 12345 Musterstadt', typ: 'halle', aktiv: true },
{ name: 'Vereinsheim TSV', adresse: 'Musterstraße 3, 12345 Musterstadt', typ: 'gebaeude', aktiv: true },
{ name: 'Sportplatz West', adresse: 'Weststraße 10, 12345 Musterstadt', typ: 'platz', aktiv: true },
]) {
const r = await req('POST', '/orte', o, T);
orte[o.name] = r.id;
}
console.log(` ✓ 3 Orte`);
// 6. Beitragsarten
console.log('→ Beitragsarten...');
for (const b of [
{ name: 'Jahresbeitrag Erwachsene', betrag: 120, rhythmus: 'jaehrlich', beschreibung: 'Normalbeitrag für erwachsene Mitglieder' },
{ name: 'Jahresbeitrag Jugend', betrag: 60, rhythmus: 'jaehrlich', beschreibung: 'Ermäßigter Beitrag bis 18 Jahre' },
{ name: 'Aufnahmegebühr', betrag: 25, rhythmus: 'einmalig', beschreibung: 'Einmalige Gebühr bei Eintritt' },
{ name: 'Monatsbeitrag Fitness', betrag: 15, rhythmus: 'monatlich', beschreibung: 'Zusatzbeitrag Fitnessraum' },
]) {
await req('POST', '/beitraege', b, T);
}
console.log(` ✓ 4 Beitragsarten`);
// 7. Termine
console.log('→ Termine...');
const now = new Date();
const dt = (offsetDays, h = 18, m = 0) => {
const d = new Date(now);
d.setDate(d.getDate() + offsetDays);
d.setHours(h, m, 0, 0);
return d.toISOString().slice(0, 19);
};
const termine = [
{ titel: 'Vorstandssitzung', beginn: dt(3, 19), ort_id: orte['Vereinsheim TSV'], gruppe_ids: [gruppen['Vorstand']], verfuegbarkeit: 'bestaetigt' },
{ titel: 'Vereinsmeisterschaft', beginn: dt(14, 9), ort_id: orte['Sportplatz West'], gruppe_ids: [], beschreibung: 'Jährliche Meisterschaft', verfuegbarkeit: 'bestaetigt' },
{ titel: 'Jugendtraining', beginn: dt(2, 16), ort_id: orte['Turnhalle Grundschule Muster'], gruppe_ids: [gruppen['Jugend U15']], verfuegbarkeit: 'bestaetigt' },
{ titel: 'Mitgliederversammlung', beginn: dt(21, 19,30),ort_id: orte['Vereinsheim TSV'], gruppe_ids: [], beschreibung: 'Ordentliche Jahreshauptversammlung', verfuegbarkeit: 'offen' },
{ titel: 'Seniorensport', beginn: dt(7, 10), ort_id: orte['Turnhalle Grundschule Muster'], gruppe_ids: [gruppen['Senioren']], verfuegbarkeit: 'bestaetigt' },
{ titel: 'Sommerfest', beginn: dt(45, 14), ort_id: orte['Sportplatz West'], gruppe_ids: [], beschreibung: 'Großes Vereinssommerfest', verfuegbarkeit: 'offen' },
];
// 8 Wochen Dienstags-Training
let di = new Date(now);
di.setDate(di.getDate() + ((2 - di.getDay() + 7) % 7 || 7));
di.setHours(18, 0, 0, 0);
for (let i = 0; i < 8; i++) {
const b = new Date(di); b.setDate(b.getDate() + i * 7);
termine.push({
titel: 'Training Aktive', beginn: b.toISOString().slice(0, 19),
ort_id: orte['Turnhalle Grundschule Muster'],
gruppe_ids: [gruppen['Aktive Mitglieder']], verfuegbarkeit: 'offen',
rrule: 'FREQ=WEEKLY;BYDAY=TU',
});
}
mitgliederIds.push(rec.id);
}
console.log(`${mitgliederIds.length} Mitglieder`);
for (const t of termine) await req('POST', '/termine', t, T);
console.log(`${termine.length} Termine`);
// ── 6. Veranstaltungsorte ────────────────────────────────────────────────────
console.log('→ Veranstaltungsorte…');
const orteDef = [
{ name: 'Turnhalle Grundschule Muster', adresse: 'Schulstraße 1, 80333 Musterstadt', typ: 'halle', aktiv: true },
{ name: 'Vereinsheim TSV', adresse: 'Vereinsstraße 12, 80333 Musterstadt', typ: 'gebaeude', aktiv: true },
{ name: 'Sportplatz West', adresse: 'Weststraße 99, 80335 Musterstadt', typ: 'platz', aktiv: true },
];
const orte = {};
for (const o of orteDef) {
let rec = await find('veranstaltungsorte', `name = "${o.name}" && verein_id = "${verein.id}"`);
if (!rec) rec = await create('veranstaltungsorte', { verein_id: verein.id, ...o });
orte[o.name] = rec.id;
}
// Ausfall: Turnhalle gesperrt wegen Schulveranstaltung
const turnhalleId = orte['Turnhalle Grundschule Muster'];
const existingAusfall = await find('ort_ausfaelle', `ort_id = "${turnhalleId}"`);
if (!existingAusfall) {
const nextWeek = new Date(); nextWeek.setDate(nextWeek.getDate() + 7);
const nextWeek2 = new Date(); nextWeek2.setDate(nextWeek2.getDate() + 9);
await create('ort_ausfaelle', {
ort_id: turnhalleId,
von: nextWeek.toISOString().slice(0,10),
bis: nextWeek2.toISOString().slice(0,10),
grund: 'Schulveranstaltung Halle nicht verfügbar',
});
}
console.log(' ✓ Orte:', Object.keys(orte).join(', '));
// 8. Nachricht
console.log('→ Nachricht...');
await req('POST', '/nachrichten', {
betreff: 'Willkommen in vereins.haus!',
text: '<p>Hallo und herzlich willkommen! Dies ist eine Beispiel-Nachricht an alle Mitglieder.</p>',
gruppe_ids: [],
}, T);
console.log(` ✓ Nachricht erstellt`);
// ── 7. Beitragsarten ─────────────────────────────────────────────────────────
console.log('→ Beitragsarten…');
const beitraegeDef = [
{ name: 'Jahresbeitrag Erwachsene', betrag: 48, rhythmus: 'jaehrlich', beschreibung: 'Für Mitglieder ab 18 Jahren' },
{ name: 'Jahresbeitrag Jugend', betrag: 24, rhythmus: 'jaehrlich', beschreibung: 'Für Mitglieder unter 18 Jahren' },
{ name: 'Aufnahmegebühr', betrag: 20, rhythmus: 'einmalig', beschreibung: 'Einmalig bei Vereinseintritt' },
{ name: 'Monatsbeitrag Fitness', betrag: 15, rhythmus: 'monatlich', beschreibung: 'Für die Fitnessgruppe (optional)' },
];
for (const b of beitraegeDef) {
const ex = await find('beitraege', `name = "${b.name}" && verein_id = "${verein.id}"`);
if (!ex) await create('beitraege', { verein_id: verein.id, ...b });
}
console.log(' ✓', beitraegeDef.map(b => b.name).join(', '));
// ── 8. Termine ───────────────────────────────────────────────────────────────
console.log('→ Termine…');
const d = (offsetDays, h = 0, m = 0) => {
const dt = new Date();
dt.setDate(dt.getDate() + offsetDays);
dt.setHours(h, m, 0, 0);
return dt.toISOString();
};
const termineDef = [
// Vergangen
{ titel: 'Jahreshauptversammlung 2026', beginn: d(-60, 19, 0), ende: d(-60, 21, 0), ort_id: orte['Vereinsheim TSV'], gruppe_ids: Object.values(gruppen), beschreibung: 'Jahresabrechnung, Vorstandswahl, Planung Sommerfest' },
{ titel: 'Trainingssession', beginn: d(-14, 18, 0), ende: d(-14, 20, 0), ort_id: orte['Turnhalle Grundschule Muster'], gruppe_ids: [gruppen['Aktive Mitglieder']] },
{ titel: 'Jugendtraining', beginn: d(-7, 16, 0), ende: d(-7, 17, 30), ort_id: orte['Turnhalle Grundschule Muster'], gruppe_ids: [gruppen['Jugend U15']] },
// Upcoming
{ titel: 'Vorstandssitzung', beginn: d(3, 19, 30), ende: d(3, 21, 0), ort_id: orte['Vereinsheim TSV'], gruppe_ids: [gruppen['Vorstand']], beschreibung: 'Vorbereitung Sommerfest, Kassenstand Q1' },
{ titel: 'Sommerfest TSV', beginn: d(32, 14, 0), ende: d(32, 22, 0), ort_id: orte['Sportplatz West'], gruppe_ids: Object.values(gruppen), beschreibung: 'Für Mitglieder und Familien Grillen, Spiele, Live-Musik' },
{ titel: 'Auswärtsspiel Musterliga', beginn: d(18, 11, 0), ende: d(18, 13, 0), ort_id: null, gruppe_ids: [gruppen['Aktive Mitglieder']], ort: 'FC Gegner Stadionstraße 1', beschreibung: 'Hinfahrt 10:00 Uhr am Vereinsheim' },
];
// Wöchentliches Training als Serie
const serie_id = 'seed-serie-dienstag-2026';
const trainingsTermine = [];
for (let i = 1; i <= 8; i++) {
// Nächster Dienstag + i Wochen
const dt = new Date();
const daysToTue = (2 - dt.getDay() + 7) % 7 || 7;
dt.setDate(dt.getDate() + daysToTue + (i - 1) * 7);
dt.setHours(18, 30, 0, 0);
trainingsTermine.push({
titel: 'Dienstags-Training',
beginn: dt.toISOString(),
ende: new Date(dt.getTime() + 90 * 60 * 1000).toISOString(),
ort_id: orte['Turnhalle Grundschule Muster'],
gruppe_ids:[gruppen['Aktive Mitglieder']],
rrule: 'FREQ=WEEKLY;BYDAY=TU',
serie_id,
});
console.log('');
console.log('✓ Seed abgeschlossen!');
console.log('');
console.log(` Verein: TSV Musterstadt 1983 e.V.`);
console.log(` Login: vorstand@tsv-musterstadt.de / Test123456!`);
console.log(` App: ${BASE}`);
}
const allTermine = [...termineDef, ...trainingsTermine];
let termineAngelegt = 0;
for (const t of allTermine) {
const ex = await find('termine', `titel = "${t.titel}" && verein_id = "${verein.id}" && beginn = "${t.beginn}"`);
if (!ex) {
await create('termine', { verein_id: verein.id, verfuegbarkeit: 'offen', ...t });
termineAngelegt++;
}
}
console.log(`${termineAngelegt} Termine (inkl. ${trainingsTermine.length} in Dienstags-Serie)`);
// ── 9. Nachricht ─────────────────────────────────────────────────────────────
console.log('→ Beispiel-Nachricht…');
const exMsg = await find('nachrichten', `verein_id = "${verein.id}"`);
if (!exMsg) {
await create('nachrichten', {
verein_id: verein.id,
autor_id: adminUser.id,
betreff: 'Willkommen bei vereins.haus!',
text: 'Liebe Mitglieder,\n\nwir haben unsere Vereinsverwaltung auf vereins.haus umgestellt. Hier findet ihr alle Termine, Nachrichten und Informationen rund um unseren Verein.\n\nBei Fragen wendet euch an den Vorstand.\n\nEuer Vorstand\nTSV Musterstadt 1983 e.V.',
gruppe_ids: Object.values(gruppen),
gesendet_am: new Date().toISOString(),
});
}
// ── Zusammenfassung ──────────────────────────────────────────────────────────
console.log('\n✓ Seed abgeschlossen!\n');
console.log(' Verein: ', verein.name);
console.log(' Login: vorstand@tsv-musterstadt.de / Test123456!');
console.log(' Mitglieder:', mitgliederIds.length);
console.log(' PocketBase:', PB_URL);
main().catch(e => { console.error('✗ Seed-Fehler:', e.message); process.exit(1); });