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

106
Makefile
View file

@ -1,22 +1,18 @@
# ============================================================== # ==============================================================
# VEREINS.HAUS — Makefile # VEREINS.HAUS — Makefile
# Deploy-Strategie: SSH zur DS, Docker Compose # Deploy-Strategie: SSH zur DS, Docker Compose
# Stack: SvelteKit + better-sqlite3 (kein PocketBase)
# ============================================================== # ==============================================================
DS_HOST := ds DS_HOST := ds
DS_IP := 10.47.11.10 DS_IP := 10.47.11.10
DS_SSH_PORT := 4711 DS_SSH_PORT := 4711
DS_PATH := /volume1/docker/vereinshaus DS_PATH := /volume1/docker/vereinshaus
CONTAINER_PB := vereinshaus-pocketbase
CONTAINER_APP := vereinshaus-app CONTAINER_APP := vereinshaus-app
DOCKER := sudo /usr/local/bin/docker DOCKER := sudo /usr/local/bin/docker
STAGING_PATH := /volume1/docker/vereinshaus-staging STAGING_PATH := /volume1/docker/vereinshaus-staging
CONTAINER_PB_STAGING := vereinshaus-staging-pocketbase
CONTAINER_APP_STAGING := vereinshaus-staging-app 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
TAR_EXCLUDE := --exclude='.git' \ TAR_EXCLUDE := --exclude='.git' \
--exclude='./app/node_modules' \ --exclude='./app/node_modules' \
@ -25,13 +21,8 @@ TAR_EXCLUDE := --exclude='.git' \
--exclude='./.env' \ --exclude='./.env' \
--exclude='./.DS_Store' --exclude='./.DS_Store'
HOOKS_SRC := pocketbase/pb_hooks .PHONY: help check-ssh start stop restart status logs logs-app logs-f deploy \
HOOKS_DST := /volume1/docker/vereinshaus/pocketbase/data/pb_hooks staging-deploy staging-reset staging-seed staging-logs staging-status staging-stop
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
# ---------------------------------------------------------- # ----------------------------------------------------------
# Hilfe # Hilfe
@ -41,16 +32,16 @@ help:
@echo " vereins.haus — verfügbare Befehle:" @echo " vereins.haus — verfügbare Befehle:"
@echo "" @echo ""
@echo " make deploy App bauen + zur DS übertragen + Container neu starten" @echo " make deploy App bauen + zur DS übertragen + Container neu starten"
@echo " make start Alle Container starten" @echo " make start Container starten"
@echo " make stop Alle Container stoppen" @echo " make stop Container stoppen"
@echo " make restart Alle Container neu starten" @echo " make restart Container neu starten"
@echo " make status Container-Status anzeigen" @echo " make status Container-Status anzeigen"
@echo " make logs App-Logs (100 Zeilen)"
@echo " make logs-f App Live-Log"
@echo "" @echo ""
@echo " make logs PocketBase-Logs (100 Zeilen)" @echo " make staging-deploy Staging deployen"
@echo " make logs-app App-Logs (100 Zeilen)" @echo " make staging-seed Testdaten einfügen"
@echo " make logs-f PocketBase Live-Log" @echo " make staging-reset Staging-DB löschen (Neustart)"
@echo " make shell-pb Shell in PocketBase-Container"
@echo " make pb-admin PocketBase Admin-URL anzeigen"
@echo "" @echo ""
# ---------------------------------------------------------- # ----------------------------------------------------------
@ -65,7 +56,7 @@ check-ssh:
fi fi
# ---------------------------------------------------------- # ----------------------------------------------------------
# DEPLOY # DEPLOY (Production)
# ---------------------------------------------------------- # ----------------------------------------------------------
deploy: check-ssh deploy: check-ssh
@echo "→ Sync zu DS..." @echo "→ Sync zu DS..."
@ -74,23 +65,6 @@ deploy: check-ssh
@if [ -f .env ]; then \ @if [ -f .env ]; then \
cat .env | ssh $(DS_HOST) "cat > $(DS_PATH)/.env"; \ cat .env | ssh $(DS_HOST) "cat > $(DS_PATH)/.env"; \
fi 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..." @echo "→ Docker rebuild + restart..."
@ssh $(DS_HOST) " \ @ssh $(DS_HOST) " \
cd $(DS_PATH) && \ cd $(DS_PATH) && \
@ -98,7 +72,7 @@ deploy: check-ssh
$(DOCKER) compose build app && \ $(DOCKER) compose build app && \
$(DOCKER) compose up -d" $(DOCKER) compose up -d"
@echo " ✓ Deploy fertig." @echo " ✓ Deploy fertig."
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_APP) --tail=10" @ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_APP) --tail=15"
# ---------------------------------------------------------- # ----------------------------------------------------------
# Container-Steuerung # Container-Steuerung
@ -124,27 +98,16 @@ status: check-ssh
# Logs # Logs
# ---------------------------------------------------------- # ----------------------------------------------------------
logs: check-ssh 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" @ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_APP) --tail=100"
logs-app: logs
logs-f: check-ssh logs-f: check-ssh
@ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_PB) -f" @ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER_APP) -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/_/"
# ============================================================== # ==============================================================
# STAGING # STAGING
# ============================================================== # ==============================================================
.PHONY: staging-deploy staging-reset staging-seed staging-logs staging-status staging-stop
staging-deploy: check-ssh staging-deploy: check-ssh
@echo "→ Sync zu DS (Staging)..." @echo "→ Sync zu DS (Staging)..."
@ -153,20 +116,6 @@ staging-deploy: check-ssh
@if [ -f .env ]; then \ @if [ -f .env ]; then \
cat .env | ssh $(DS_HOST) "cat > $(STAGING_PATH)/.env"; \ cat .env | ssh $(DS_HOST) "cat > $(STAGING_PATH)/.env"; \
fi 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)..." @echo "→ Docker rebuild + restart (Staging)..."
@ssh $(DS_HOST) " \ @ssh $(DS_HOST) " \
cd $(STAGING_PATH) && \ cd $(STAGING_PATH) && \
@ -175,30 +124,25 @@ staging-deploy: check-ssh
$(DOCKER) compose -f docker-compose.staging.yml up -d" $(DOCKER) compose -f docker-compose.staging.yml up -d"
@echo " ✓ Staging bereit." @echo " ✓ Staging bereit."
@echo " App: https://staging.vereins.haus" @echo " App: https://staging.vereins.haus"
@echo " PocketBase: https://api-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 # Danach: make staging-deploy && make staging-seed
staging-reset: check-ssh staging-stop staging-reset: check-ssh staging-stop
@echo "→ PocketBase-Daten auf Staging löschen..." @echo "→ Staging-Daten löschen..."
@ssh $(DS_HOST) "rm -rf \ @ssh $(DS_HOST) "rm -f \
$(STAGING_PATH)/pocketbase/data/storage \ $(STAGING_PATH)/data/vereinshaus.db \
'$(STAGING_PATH)/pocketbase/data/data.db' \ $(STAGING_PATH)/data/vereinshaus.db-wal \
'$(STAGING_PATH)/pocketbase/data/data.db-wal' \ $(STAGING_PATH)/data/vereinshaus.db-shm && \
'$(STAGING_PATH)/pocketbase/data/data.db-shm' \ rm -rf $(STAGING_PATH)/data/uploads"
$(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 " ✓ Reset fertig. Jetzt: make staging-deploy && make staging-seed" @echo " ✓ Reset fertig. Jetzt: make staging-deploy && make staging-seed"
staging-seed: staging-seed:
@echo "→ Testdaten in Staging einfügen..." @echo "→ Testdaten in Staging einfügen..."
@echo " Voraussetzung: PB_EMAIL + PB_PASSWORD in .env gesetzt (Staging-Superuser)"
@if [ -f .env ]; then \ @if [ -f .env ]; then \
export $$(grep -v '^#' .env | xargs) && \ 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 \ else \
PB_URL=https://api-staging.vereins.haus node scripts/seed.js; \ APP_URL=https://staging.vereins.haus node scripts/seed.js; \
fi fi
staging-logs: check-ssh staging-logs: check-ssh

View file

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

449
app/package-lock.json generated
View file

@ -9,7 +9,10 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@event-calendar/core": "^5.7.0", "@event-calendar/core": "^5.7.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.10.0",
"ical-generator": "^10.2.0", "ical-generator": "^10.2.0",
"jose": "^6.2.3",
"papaparse": "^5.5.3", "papaparse": "^5.5.3",
"pocketbase": "^0.26.9", "pocketbase": "^0.26.9",
"rrule": "^2.8.1", "rrule": "^2.8.1",
@ -19,6 +22,8 @@
"@sveltejs/adapter-node": "^5.5.4", "@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.57.0", "@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.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/papaparse": "^5.5.2",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
"svelte": "^5.55.2", "svelte": "^5.55.2",
@ -2637,6 +2642,23 @@
"tslib": "^2.4.0" "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": { "node_modules/@types/cookie": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
@ -2895,6 +2917,26 @@
"node": "18 || 20 || >=22" "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": { "node_modules/baseline-browser-mapping": {
"version": "2.10.31", "version": "2.10.31",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz",
@ -2908,6 +2950,49 @@
"node": ">=6.0.0" "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": { "node_modules/bn.js": {
"version": "4.12.3", "version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", "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": "^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": { "node_modules/buffer-equal-constant-time": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "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/" "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": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "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": { "node_modules/deepmerge": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@ -3271,7 +3410,6 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -3330,6 +3468,15 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/es-abstract": {
"version": "1.24.2", "version": "1.24.2",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", "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" "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": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "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": { "node_modules/filelist": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
@ -3651,6 +3813,12 @@
"url": "https://github.com/sponsors/isaacs" "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": { "node_modules/fs-extra": {
"version": "9.1.0", "version": "9.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
@ -3807,6 +3975,12 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/glob": {
"version": "11.1.0", "version": "11.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
@ -4030,12 +4204,38 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "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": { "node_modules/internal-slot": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@ -4508,6 +4708,15 @@
"node": ">=10" "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": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -4922,6 +5131,18 @@
"node": ">= 0.4" "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": { "node_modules/minimalistic-assert": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
@ -4963,6 +5184,12 @@
"node": ">=16 || 14 >=14.17" "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": { "node_modules/mri": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "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": "^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": { "node_modules/node-releases": {
"version": "2.0.44", "version": "2.0.44",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz",
@ -5070,6 +5327,15 @@
], ],
"license": "MIT" "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": { "node_modules/own-keys": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@ -5210,6 +5476,33 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/pretty-bytes": {
"version": "6.1.1", "version": "6.1.1",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
@ -5223,6 +5516,16 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -5233,6 +5536,35 @@
"node": ">=6" "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": { "node_modules/readdirp": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@ -5758,6 +6090,51 @@
"url": "https://github.com/sponsors/isaacs" "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": { "node_modules/sirv": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
@ -5842,6 +6219,15 @@
"node": ">= 0.4" "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": { "node_modules/string.prototype.matchall": {
"version": "4.0.12", "version": "4.0.12",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
@ -5954,6 +6340,15 @@
"node": ">=10" "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": { "node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "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" "@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": { "node_modules/temp-dir": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
@ -6118,6 +6541,18 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "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": { "node_modules/type-fest": {
"version": "0.16.0", "version": "0.16.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
@ -6358,6 +6793,12 @@
"browserslist": ">= 4.21.0" "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": { "node_modules/vite": {
"version": "8.0.13", "version": "8.0.13",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
@ -6850,6 +7291,12 @@
"workbox-core": "7.4.1" "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": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View file

@ -15,6 +15,8 @@
"@sveltejs/adapter-node": "^5.5.4", "@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.57.0", "@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.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/papaparse": "^5.5.2",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
"svelte": "^5.55.2", "svelte": "^5.55.2",
@ -27,7 +29,10 @@
}, },
"dependencies": { "dependencies": {
"@event-calendar/core": "^5.7.0", "@event-calendar/core": "^5.7.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.10.0",
"ical-generator": "^10.2.0", "ical-generator": "^10.2.0",
"jose": "^6.2.3",
"papaparse": "^5.5.3", "papaparse": "^5.5.3",
"pocketbase": "^0.26.9", "pocketbase": "^0.26.9",
"rrule": "^2.8.1", "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 { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { pb } from '$lib/pb'; import { user } from '$lib/user';
import Icon from '$lib/components/Icon.svelte'; import Icon from '$lib/components/Icon.svelte';
import type { IconName } from '$lib/icons'; import type { IconName } from '$lib/icons';
let { children } = $props(); let { children } = $props();
onMount(() => { onMount(() => {
if (!pb.authStore.isValid) { if (!$user) { goto('/login'); return; }
goto('/login'); if (!$user.verein_id) { goto('/onboarding'); return; }
return;
}
if (!pb.authStore.record?.verein_id) {
goto('/onboarding');
return;
}
registerPush(); registerPush();
}); });
async function registerPush() { async function registerPush() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return; if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
const permission = await Notification.requestPermission(); const permission = await Notification.requestPermission();
if (permission !== 'granted') return; if (permission !== 'granted') return;
try { try {
const reg = await navigator.serviceWorker.ready; const reg = await navigator.serviceWorker.ready;
// VAPID public key vom Server holen
const keyRes = await fetch('/api/push/key'); const keyRes = await fetch('/api/push/key');
const { publicKey } = await keyRes.json(); const { publicKey } = await keyRes.json();
if (!publicKey) return; if (!publicKey) return;
// Bestehende oder neue Subscription
let sub = await reg.pushManager.getSubscription(); let sub = await reg.pushManager.getSubscription();
if (!sub) { if (!sub) {
sub = await reg.pushManager.subscribe({ sub = await reg.pushManager.subscribe({
@ -42,18 +30,10 @@
applicationServerKey: urlBase64ToUint8Array(publicKey) as BufferSource, applicationServerKey: urlBase64ToUint8Array(publicKey) as BufferSource,
}); });
} }
// In PocketBase speichern
await fetch('/api/push/subscribe', { await fetch('/api/push/subscribe', {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${$user?.token}` },
'Content-Type': 'application/json', body: JSON.stringify({ subscription: sub.toJSON(), userId: $user?.id }),
Authorization: pb.authStore.token,
},
body: JSON.stringify({
subscription: sub.toJSON(),
userId: pb.authStore.record?.id,
}),
}); });
} catch (e) { } catch (e) {
console.warn('[push] Registrierung fehlgeschlagen:', e); console.warn('[push] Registrierung fehlgeschlagen:', e);
@ -67,7 +47,7 @@
return Uint8Array.from([...raw].map((c) => c.charCodeAt(0))); 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 }[] = [ const allNavItems: { href: string; label: string; icon: IconName; adminOnly?: boolean }[] = [
{ href: '/neuigkeiten', label: 'Neuigkeiten', icon: 'images' }, { href: '/neuigkeiten', label: 'Neuigkeiten', icon: 'images' },

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { pb } from '$lib/pb'; import { api } from '$lib/api';
import { user } from '$lib/user';
let vereinsname = $state(''); let vereinsname = $state('');
let email = $state(''); let email = $state('');
@ -17,8 +18,8 @@
} }
loading = true; loading = true;
try { try {
await pb.collection('users').create({ email, password, passwordConfirm, name: vereinsname }); const u = await api.post('/auth/register', { vereinName: vereinsname, email, password, name: vereinsname });
await pb.collection('users').authWithPassword(email, password); user.set(u as any);
goto('/onboarding'); goto('/onboarding');
} catch (e: unknown) { } catch (e: unknown) {
error = e instanceof Error ? e.message : 'Registrierung fehlgeschlagen.'; 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 ical from 'ical-generator';
import { env } from '$env/dynamic/private'; import { getDb } from '$lib/server/db';
const PB_URL = () => env.PB_URL ?? 'http://localhost:8090';
export async function GET({ params }) { export async function GET({ params }) {
const { vereinId } = params; const { vereinId } = params;
// Verein laden (öffentlich lesbar via viewRule) const db = getDb();
const vereinRes = await fetch(
`${PB_URL()}/api/collections/vereine/records/${vereinId}`, const verein = db.prepare('SELECT * FROM vereine WHERE id = ?').get(vereinId) as { name: string } | undefined;
); if (!verein) {
if (!vereinRes.ok) {
return new Response('Verein nicht gefunden.', { status: 404 }); return new Response('Verein nicht gefunden.', { status: 404 });
} }
const verein = await vereinRes.json();
// Termine der nächsten 365 Tage laden // Termine der nächsten 365 Tage laden
const von = new Date().toISOString(); const von = new Date().toISOString();
const bis = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).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( const termine = db.prepare(
`${PB_URL()}/api/collections/termine/records?filter=${filter}&sort=beginn&perPage=500`, `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 }[];
const { items: termine = [] } = termineRes.ok ? await termineRes.json() : {};
// iCal-Kalender aufbauen // iCal-Kalender aufbauen
const cal = ical({ 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 { json } from '@sveltejs/kit';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import webpush from 'web-push'; import webpush from 'web-push';
import { requireAuth } from '$lib/server/auth';
const PB_URL = () => env.PB_URL ?? 'http://localhost:8090'; import { getDb } from '$lib/server/db';
export async function POST({ request }) { 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(); const { titel, body, url = '/nachrichten' } = await request.json();
if (!titel) return json({ error: 'Titel fehlt.' }, { status: 400 }); if (!titel) return json({ error: 'Titel fehlt.' }, { status: 400 });
@ -20,21 +22,18 @@ export async function POST({ request }) {
webpush.setVapidDetails(vapidSubject, vapidPublic, vapidPrivate); webpush.setVapidDetails(vapidSubject, vapidPublic, vapidPrivate);
// Alle Push-Subscriptions des Vereins laden (listRule erlaubt verein-weite Abfrage) const db = getDb();
const subRes = await fetch( const items = db.prepare(
`${PB_URL()}/api/collections/push_subscriptions/records?perPage=500`, 'SELECT * FROM push_subscriptions WHERE verein_id = ?'
{ headers: { Authorization: authHeader } }, ).all(authUser.verein_id) as { endpoint: string; p256dh: string; auth: string; id: string }[];
);
if (!subRes.ok) return json({ sent: 0 }); if (!items.length) return json({ sent: 0 });
const { items } = await subRes.json();
if (!items?.length) return json({ sent: 0 });
const payload = JSON.stringify({ title: titel, body, url }); const payload = JSON.stringify({ title: titel, body, url });
let sent = 0; let sent = 0;
await Promise.allSettled( await Promise.allSettled(
items.map(async (sub: { endpoint: string; p256dh: string; auth: string; id: string }) => { items.map(async (sub) => {
try { try {
await webpush.sendNotification( await webpush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
@ -44,10 +43,7 @@ export async function POST({ request }) {
} catch (err: unknown) { } catch (err: unknown) {
// 410 Gone = Subscription abgelaufen → löschen // 410 Gone = Subscription abgelaufen → löschen
if ((err as { statusCode?: number }).statusCode === 410) { if ((err as { statusCode?: number }).statusCode === 410) {
await fetch( db.prepare('DELETE FROM push_subscriptions WHERE id = ?').run(sub.id);
`${PB_URL()}/api/collections/push_subscriptions/records/${sub.id}`,
{ method: 'DELETE', headers: { Authorization: authHeader } },
).catch(() => {});
} }
} }
}), }),

View file

@ -1,67 +1,44 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { env } from '$env/dynamic/private'; import { requireAuth } from '$lib/server/auth';
import { getDb, newId } from '$lib/server/db';
const PB_URL = () => env.PB_URL ?? 'http://localhost:8090';
export async function POST({ request }) { export async function POST({ request }) {
const authHeader = request.headers.get('Authorization') ?? ''; const authUser = await requireAuth(request).catch(() => null);
const { subscription, userId } = await request.json(); 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 }); return json({ error: 'Ungültige Subscription.' }, { status: 400 });
} }
// Alte Subscription dieses Users löschen (Gerätewechsel) const db = getDb();
const listRes = await fetch(
`${PB_URL()}/api/collections/push_subscriptions/records?filter=user_id%3D"${userId}"&perPage=50`, // Alte Subscriptions dieses Users löschen (Gerätewechsel)
{ headers: { Authorization: authHeader } }, db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(authUser.sub);
);
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 },
}),
),
);
}
// Neue Subscription speichern // Neue Subscription speichern
const res = await fetch(`${PB_URL()}/api/collections/push_subscriptions/records`, { db.prepare(`
method: 'POST', INSERT INTO push_subscriptions (id, user_id, verein_id, endpoint, p256dh, auth)
headers: { 'Content-Type': 'application/json', Authorization: authHeader }, VALUES (?, ?, ?, ?, ?, ?)
body: JSON.stringify({ `).run(
user_id: userId, newId(),
endpoint: subscription.endpoint, authUser.sub,
p256dh: subscription.keys.p256dh, authUser.verein_id,
auth: subscription.keys.auth, subscription.endpoint,
}), subscription.keys.p256dh,
}); subscription.keys.auth,
);
if (!res.ok) return json({ error: 'Fehler beim Speichern.' }, { status: 500 });
return json({ success: true }); return json({ success: true });
} }
export async function DELETE({ request }) { export async function DELETE({ request }) {
const authHeader = request.headers.get('Authorization') ?? ''; const authUser = await requireAuth(request).catch(() => null);
const { userId } = await request.json(); if (!authUser) return json({ success: true });
const listRes = await fetch( const db = getDb();
`${PB_URL()}/api/collections/push_subscriptions/records?filter=user_id%3D"${userId}"&perPage=50`, db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(authUser.sub);
{ headers: { Authorization: authHeader } },
);
if (!listRes.ok) return json({ success: true });
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 }); 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 { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { onMount } from 'svelte'; 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'; import type { Einladung } from '$lib/types';
const token = $derived($page.params.token as string); const token = $derived($page.params.token as string);
@ -20,17 +22,14 @@
let formError = $state(''); let formError = $state('');
onMount(async () => { onMount(async () => {
if (pb.authStore.isValid) { if (!!get(user)) {
goto('/'); goto('/');
return; return;
} }
try { try {
const inv = await pb.collection('einladungen') const inv = await api.get<Einladung & { vereinName: string }>('/einladungen/' + token);
.getFirstListItem<Einladung>(`token = "${token}" && genutzt = false`, {
expand: 'verein_id',
});
einladung = inv; einladung = inv;
vereinName = (inv as any).expand?.verein_id?.name ?? ''; vereinName = (inv as any).vereinName ?? '';
} catch { } catch {
fehler = 'Dieser Einladungslink ist ungültig oder wurde bereits verwendet.'; fehler = 'Dieser Einladungslink ist ungültig oder wurde bereits verwendet.';
} finally { } finally {
@ -43,16 +42,12 @@
if (password !== passwordConfirm) { formError = 'Passwörter stimmen nicht überein.'; return; } if (password !== passwordConfirm) { formError = 'Passwörter stimmen nicht überein.'; return; }
formError = ''; saving = true; formError = ''; saving = true;
try { try {
await pb.collection('users').create({ const u = await api.post<any>(`/einladungen/${token}`, {
email: email.trim(), email: email.trim(),
password, password,
passwordConfirm,
name: name.trim(), name: name.trim(),
verein_id: einladung.verein_id,
rolle: einladung.rolle,
}); });
await pb.collection('users').authWithPassword(email.trim(), password); user.set(u);
await pb.collection('einladungen').update(einladung.id, { genutzt: true });
goto('/'); goto('/');
} catch (e: unknown) { } catch (e: unknown) {
formError = e instanceof Error ? e.message : 'Registrierung fehlgeschlagen.'; formError = e instanceof Error ? e.message : 'Registrierung fehlgeschlagen.';

View file

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

View file

@ -1,42 +1,27 @@
version: "3.8" version: "3.8"
services: 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: app-staging:
build: build:
context: ./app context: ./app
dockerfile: Dockerfile dockerfile: Dockerfile
args:
VITE_PB_URL: https://api-staging.vereins.haus
image: vereinshaus-staging-app image: vereinshaus-staging-app
container_name: vereinshaus-staging-app container_name: vereinshaus-staging-app
restart: unless-stopped restart: unless-stopped
volumes:
- /volume1/docker/vereinshaus-staging/data:/data
environment: environment:
- TZ=Europe/Berlin - TZ=Europe/Berlin
- HOST=0.0.0.0 - HOST=0.0.0.0
- PORT=3000 - 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} - PUBLIC_VAPID_KEY=${PUBLIC_VAPID_KEY}
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY} - VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
- VAPID_SUBJECT=${VAPID_SUBJECT:-mailto:info@vereins.haus} - 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: networks:
default: {} default: {}
npm_bridge: npm_bridge:

View file

@ -1,42 +1,27 @@
version: "3.8" version: "3.8"
services: 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: app:
build: build:
context: ./app context: ./app
dockerfile: Dockerfile dockerfile: Dockerfile
args:
VITE_PB_URL: https://api.vereins.haus
image: vereinshaus-app image: vereinshaus-app
container_name: vereinshaus-app container_name: vereinshaus-app
restart: unless-stopped restart: unless-stopped
volumes:
- /volume1/docker/vereinshaus/data:/data
environment: environment:
- TZ=Europe/Berlin - TZ=Europe/Berlin
- HOST=0.0.0.0 - HOST=0.0.0.0
- PORT=3000 - PORT=3000
- DB_PATH=/data/vereinshaus.db
- UPLOAD_DIR=/data/uploads
- JWT_SECRET=${JWT_SECRET}
- PUBLIC_VAPID_KEY=${PUBLIC_VAPID_KEY} - PUBLIC_VAPID_KEY=${PUBLIC_VAPID_KEY}
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY} - VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
- VAPID_SUBJECT=${VAPID_SUBJECT:-mailto:info@vereins.haus} - 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: networks:
default: {} default: {}
npm_bridge: npm_bridge:

View file

@ -1,264 +1,153 @@
#!/usr/bin/env node #!/usr/bin/env node
// Testdaten für vereins.haus Staging // Seed-Script für vereins.haus (SvelteKit + SQLite, kein PocketBase)
// Aufruf: PB_URL=http://localhost:8090 PB_EMAIL=admin@test.de PB_PASSWORD=Test123456! node scripts/seed.js // Verwendung: APP_URL=https://staging.vereins.haus node scripts/seed.js
const PB_URL = process.env.PB_URL || 'http://localhost:8090'; const BASE = process.env.APP_URL || 'http://localhost:3000';
const PB_EMAIL = process.env.PB_EMAIL || '';
const PB_PWD = process.env.PB_PASSWORD || '';
if (!PB_EMAIL || !PB_PWD) { async function req(method, path, body, token) {
console.error('Fehler: PB_EMAIL und PB_PASSWORD setzen.'); const headers = { 'Content-Type': 'application/json' };
process.exit(1); if (token) headers['Authorization'] = `Bearer ${token}`;
} const res = await fetch(`${BASE}/api${path}`, {
method, headers, body: body ? JSON.stringify(body) : undefined,
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,
}); });
const json = await res.json().catch(() => ({})); 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; return json;
} }
async function find(collection, filter) { async function main() {
const r = await pb('GET', `collections/${collection}/records?filter=${encodeURIComponent(filter)}&perPage=1`); console.log(`→ Seed gegen ${BASE}`);
return r.items?.[0] ?? null;
}
async function create(collection, data) { // 1. Verein + Admin registrieren
return pb('POST', `collections/${collection}/records`, data); console.log('→ Registrierung...');
} const auth = await req('POST', '/auth/register', {
vereinName: 'TSV Musterstadt 1983 e.V.',
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', {
email: 'vorstand@tsv-musterstadt.de', email: 'vorstand@tsv-musterstadt.de',
password: 'Test123456!', passwordConfirm: 'Test123456!', password: 'Test123456!',
name: 'Max Mustermann', verein_id: verein.id, rolle: null, // null = admin name: 'Max Vorstand',
emailVisibility: true,
}); });
} const T = auth.token;
console.log(' ✓ Admin:', adminUser.email); const VID = auth.verein_id;
console.log(` ✓ Verein ID: ${VID}`);
console.log(` ✓ User: vorstand@tsv-musterstadt.de`);
// ── 4. Gruppen ─────────────────────────────────────────────────────────────── // 2. Verein-Details ergänzen
console.log('→ Gruppen…'); await req('PATCH', '/vereine', {
const gruppenDef = ['Vorstand', 'Aktive Mitglieder', 'Jugend U15', 'Senioren']; 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);
// 3. Gruppen
console.log('→ Gruppen...');
const gruppen = {}; const gruppen = {};
for (const name of gruppenDef) { for (const name of ['Vorstand', 'Aktive Mitglieder', 'Jugend U15', 'Senioren']) {
let g = await find('gruppen', `name = "${name}" && verein_id = "${verein.id}"`); const g = await req('POST', '/gruppen', { name }, T);
if (!g) g = await create('gruppen', { verein_id: verein.id, name });
gruppen[name] = g.id; gruppen[name] = g.id;
} }
console.log(' ✓', Object.keys(gruppen).join(', ')); console.log(`${Object.keys(gruppen).length} Gruppen`);
// ── 5. Mitglieder ──────────────────────────────────────────────────────────── // 4. Mitglieder
console.log('→ Mitglieder (18)…'); console.log('→ Mitglieder (18)...');
const mitgliederDef = [ const mitgliederDaten = [
// Vorstand { vorname: 'Max', nachname: 'Vorstand', email: 'vorstand@tsv-musterstadt.de', status: 'aktiv', gruppe_ids: [gruppen['Vorstand'], gruppen['Aktive Mitglieder']], eintrittsdatum: '2010-01-01' },
{ 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: 'Anna', nachname: 'Schmidt', email: 'anna.schmidt@example.de', status: 'aktiv', gruppe_ids: [gruppen['Aktive Mitglieder']], eintrittsdatum: '2015-03-15', iban: 'DE89370400440532013001' },
{ 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' }, { vorname: 'Thomas', nachname: 'Müller', email: 'thomas.mueller@example.de', status: 'aktiv', gruppe_ids: [gruppen['Aktive Mitglieder'], gruppen['Senioren']], eintrittsdatum: '2012-06-01' },
// Aktive mit IBAN { 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: '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: 'Klaus', nachname: 'Fischer', email: 'klaus.fischer@example.de', status: 'aktiv', gruppe_ids: [gruppen['Senioren']], eintrittsdatum: '2008-01-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: 'Maria', nachname: 'Bauer', email: 'maria.bauer@example.de', status: 'aktiv', gruppe_ids: [gruppen['Aktive Mitglieder']], eintrittsdatum: '2019-02-20' },
{ 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: 'Peter', nachname: 'Wagner', email: 'peter.wagner@example.de', status: 'aktiv', gruppe_ids: [gruppen['Aktive Mitglieder']], eintrittsdatum: '2017-05-10', iban: 'DE89370400440532013002' },
{ 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: 'Julia', nachname: 'Becker', email: 'julia.becker@example.de', status: 'aktiv', gruppe_ids: [gruppen['Jugend U15']], eintrittsdatum: '2023-01-01', geburtsdatum: '2011-07-22' },
{ 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: 'Stefan', nachname: 'Hoffmann', email: 'stefan.hoffmann@example.de', status: 'passiv', gruppe_ids: [], eintrittsdatum: '2005-03-01' },
{ 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: 'Sandra', nachname: 'Koch', email: 'sandra.koch@example.de', status: 'aktiv', gruppe_ids: [gruppen['Senioren']], eintrittsdatum: '2014-11-15' },
{ 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' }, { vorname: 'Michael', nachname: 'Schäfer', email: 'michael.schaefer@example.de', status: 'aktiv', gruppe_ids: [gruppen['Aktive Mitglieder']], eintrittsdatum: '2020-04-01' },
// Aktive ohne IBAN { vorname: 'Sabine', nachname: 'Zimmermann', email: 'sabine.zimm@example.de', status: 'aktiv', gruppe_ids: [gruppen['Aktive Mitglieder']], eintrittsdatum: '2018-08-20' },
{ 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: 'Andreas', nachname: 'Braun', email: 'andreas.braun@example.de', status: 'aktiv', gruppe_ids: [gruppen['Senioren']], eintrittsdatum: '2011-01-01' },
{ 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: 'Monika', nachname: 'Richter', email: 'monika.richter@example.de', status: 'passiv', gruppe_ids: [], eintrittsdatum: '2009-06-15' },
{ 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 }, { vorname: 'Tobias', nachname: 'Wolf', email: 'tobias.wolf@example.de', status: 'aktiv', gruppe_ids: [gruppen['Jugend U15']], eintrittsdatum: '2022-01-15', geburtsdatum: '2012-01-30' },
// Jugend { vorname: 'Eva', nachname: 'Krause', email: 'eva.krause@example.de', status: 'aktiv', gruppe_ids: [gruppen['Aktive Mitglieder']], eintrittsdatum: '2021-03-01' },
{ 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: 'Markus', nachname: 'Schwarz', email: 'markus.schwarz@example.de', status: 'aktiv', gruppe_ids: [gruppen['Aktive Mitglieder'], gruppen['Vorstand']], eintrittsdatum: '2013-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: 'Nina', nachname: 'Lange', email: 'nina.lange@example.de', status: 'ausgetreten', gruppe_ids: [], eintrittsdatum: '2016-02-01', austrittsdatum: '2023-12-31' },
{ 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' },
]; ];
for (const m of mitgliederDaten) await req('POST', '/mitglieder', m, T);
console.log(` ✓ 18 Mitglieder`);
let mitgliederIds = []; // 5. Veranstaltungsorte
for (const m of mitgliederDef) { console.log('→ Orte...');
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),
});
}
mitgliederIds.push(rec.id);
}
console.log(`${mitgliederIds.length} Mitglieder`);
// ── 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 = {}; const orte = {};
for (const o of orteDef) { for (const o of [
let rec = await find('veranstaltungsorte', `name = "${o.name}" && verein_id = "${verein.id}"`); { name: 'Turnhalle Grundschule Muster', adresse: 'Schulweg 5, 12345 Musterstadt', typ: 'halle', aktiv: true },
if (!rec) rec = await create('veranstaltungsorte', { verein_id: verein.id, ...o }); { name: 'Vereinsheim TSV', adresse: 'Musterstraße 3, 12345 Musterstadt', typ: 'gebaeude', aktiv: true },
orte[o.name] = rec.id; { 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;
} }
// Ausfall: Turnhalle gesperrt wegen Schulveranstaltung console.log(` ✓ 3 Orte`);
const turnhalleId = orte['Turnhalle Grundschule Muster'];
const existingAusfall = await find('ort_ausfaelle', `ort_id = "${turnhalleId}"`); // 6. Beitragsarten
if (!existingAusfall) { console.log('→ Beitragsarten...');
const nextWeek = new Date(); nextWeek.setDate(nextWeek.getDate() + 7); for (const b of [
const nextWeek2 = new Date(); nextWeek2.setDate(nextWeek2.getDate() + 9); { name: 'Jahresbeitrag Erwachsene', betrag: 120, rhythmus: 'jaehrlich', beschreibung: 'Normalbeitrag für erwachsene Mitglieder' },
await create('ort_ausfaelle', { { name: 'Jahresbeitrag Jugend', betrag: 60, rhythmus: 'jaehrlich', beschreibung: 'Ermäßigter Beitrag bis 18 Jahre' },
ort_id: turnhalleId, { name: 'Aufnahmegebühr', betrag: 25, rhythmus: 'einmalig', beschreibung: 'Einmalige Gebühr bei Eintritt' },
von: nextWeek.toISOString().slice(0,10), { name: 'Monatsbeitrag Fitness', betrag: 15, rhythmus: 'monatlich', beschreibung: 'Zusatzbeitrag Fitnessraum' },
bis: nextWeek2.toISOString().slice(0,10), ]) {
grund: 'Schulveranstaltung Halle nicht verfügbar', await req('POST', '/beitraege', b, T);
});
} }
console.log(' ✓ Orte:', Object.keys(orte).join(', ')); console.log(` ✓ 4 Beitragsarten`);
// ── 7. Beitragsarten ───────────────────────────────────────────────────────── // 7. Termine
console.log('→ Beitragsarten…'); console.log('→ Termine...');
const beitraegeDef = [ const now = new Date();
{ name: 'Jahresbeitrag Erwachsene', betrag: 48, rhythmus: 'jaehrlich', beschreibung: 'Für Mitglieder ab 18 Jahren' }, const dt = (offsetDays, h = 18, m = 0) => {
{ name: 'Jahresbeitrag Jugend', betrag: 24, rhythmus: 'jaehrlich', beschreibung: 'Für Mitglieder unter 18 Jahren' }, const d = new Date(now);
{ name: 'Aufnahmegebühr', betrag: 20, rhythmus: 'einmalig', beschreibung: 'Einmalig bei Vereinseintritt' }, d.setDate(d.getDate() + offsetDays);
{ name: 'Monatsbeitrag Fitness', betrag: 15, rhythmus: 'monatlich', beschreibung: 'Für die Fitnessgruppe (optional)' }, d.setHours(h, m, 0, 0);
]; return d.toISOString().slice(0, 19);
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 = [ const termine = [
// Vergangen { titel: 'Vorstandssitzung', beginn: dt(3, 19), ort_id: orte['Vereinsheim TSV'], gruppe_ids: [gruppen['Vorstand']], verfuegbarkeit: 'bestaetigt' },
{ 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: 'Vereinsmeisterschaft', beginn: dt(14, 9), ort_id: orte['Sportplatz West'], gruppe_ids: [], beschreibung: 'Jährliche Meisterschaft', verfuegbarkeit: 'bestaetigt' },
{ 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: dt(2, 16), ort_id: orte['Turnhalle Grundschule Muster'], gruppe_ids: [gruppen['Jugend U15']], verfuegbarkeit: 'bestaetigt' },
{ titel: 'Jugendtraining', beginn: d(-7, 16, 0), ende: d(-7, 17, 30), ort_id: orte['Turnhalle Grundschule Muster'], gruppe_ids: [gruppen['Jugend U15']] }, { titel: 'Mitgliederversammlung', beginn: dt(21, 19,30),ort_id: orte['Vereinsheim TSV'], gruppe_ids: [], beschreibung: 'Ordentliche Jahreshauptversammlung', verfuegbarkeit: 'offen' },
// Upcoming { titel: 'Seniorensport', beginn: dt(7, 10), ort_id: orte['Turnhalle Grundschule Muster'], gruppe_ids: [gruppen['Senioren']], verfuegbarkeit: 'bestaetigt' },
{ 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', beginn: dt(45, 14), ort_id: orte['Sportplatz West'], gruppe_ids: [], beschreibung: 'Großes Vereinssommerfest', verfuegbarkeit: 'offen' },
{ 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' },
]; ];
// 8 Wochen Dienstags-Training
// Wöchentliches Training als Serie let di = new Date(now);
const serie_id = 'seed-serie-dienstag-2026'; di.setDate(di.getDate() + ((2 - di.getDay() + 7) % 7 || 7));
const trainingsTermine = []; di.setHours(18, 0, 0, 0);
for (let i = 1; i <= 8; i++) { for (let i = 0; i < 8; i++) {
// Nächster Dienstag + i Wochen const b = new Date(di); b.setDate(b.getDate() + i * 7);
const dt = new Date(); termine.push({
const daysToTue = (2 - dt.getDay() + 7) % 7 || 7; titel: 'Training Aktive', beginn: b.toISOString().slice(0, 19),
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'], ort_id: orte['Turnhalle Grundschule Muster'],
gruppe_ids:[gruppen['Aktive Mitglieder']], gruppe_ids: [gruppen['Aktive Mitglieder']], verfuegbarkeit: 'offen',
rrule: 'FREQ=WEEKLY;BYDAY=TU', rrule: 'FREQ=WEEKLY;BYDAY=TU',
serie_id,
}); });
} }
for (const t of termine) await req('POST', '/termine', t, T);
console.log(`${termine.length} Termine`);
const allTermine = [...termineDef, ...trainingsTermine]; // 8. Nachricht
let termineAngelegt = 0; console.log('→ Nachricht...');
for (const t of allTermine) { await req('POST', '/nachrichten', {
const ex = await find('termine', `titel = "${t.titel}" && verein_id = "${verein.id}" && beginn = "${t.beginn}"`); betreff: 'Willkommen in vereins.haus!',
if (!ex) { text: '<p>Hallo und herzlich willkommen! Dies ist eine Beispiel-Nachricht an alle Mitglieder.</p>',
await create('termine', { verein_id: verein.id, verfuegbarkeit: 'offen', ...t }); gruppe_ids: [],
termineAngelegt++; }, T);
} console.log(` ✓ Nachricht erstellt`);
}
console.log(`${termineAngelegt} Termine (inkl. ${trainingsTermine.length} in Dienstags-Serie)`);
// ── 9. Nachricht ───────────────────────────────────────────────────────────── console.log('');
console.log('→ Beispiel-Nachricht…'); console.log('✓ Seed abgeschlossen!');
const exMsg = await find('nachrichten', `verein_id = "${verein.id}"`); console.log('');
if (!exMsg) { console.log(` Verein: TSV Musterstadt 1983 e.V.`);
await create('nachrichten', { console.log(` Login: vorstand@tsv-musterstadt.de / Test123456!`);
verein_id: verein.id, console.log(` App: ${BASE}`);
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 ────────────────────────────────────────────────────────── main().catch(e => { console.error('✗ Seed-Fehler:', e.message); process.exit(1); });
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);