Futbol Veritabanı
8대 리그 클럽·선수·통계—Transfermarkt 데이터
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
Verwaltungsplattform für Handball-Vereine: Funktionsträger (Schiedsrichter, Zeitnehmer, Sekretäre, Trainer, Ordner, Kioskdienst), Terminverwaltung inkl. ICS-Feed-Sync für Schiedsrichter, und perspektivisch Spielzuordnung, Selbst-Anmeldung und eine einfache Zuschussberechnung. Geplante Erweiterungen siehe Abschnitt "Roadmap" weiter unten; das ursprüngliche Planungsdokument lag im .claude/plans-Verzeichnis der Konversation, in der dieses Projekt aufgesetzt wurde.README 전문 보기 ↗
Verwaltungsplattform für Handball-Vereine: Funktionsträger (Schiedsrichter,
Zeitnehmer, Sekretäre, Trainer, Ordner, Kioskdienst), Terminverwaltung inkl.
ICS-Feed-Sync für Schiedsrichter, und perspektivisch Spielzuordnung,
Selbst-Anmeldung und eine einfache Zuschussberechnung. Geplante Erweiterungen
siehe Abschnitt "Roadmap" weiter unten; das ursprüngliche Planungsdokument
lag im .claude/plans-Verzeichnis der Konversation, in der dieses Projekt
aufgesetzt wurde.
Tech-Stack: Next.js 16 (App Router) auf Vercel, Neon.tech (Postgres) mit Drizzle ORM, Auth.js (Magic-Link-Login), SMTP-Mailversand (Nodemailer).
Abhängigkeiten installieren:
npm install
.env.example nach .env kopieren und Werte eintragen:
DATABASE_ADMIN_URL: der Connection String, den Neon initial ausgibt
(privilegierte Owner-Rolle, z.B. neondb_owner).DATABASE_URL: vorerst identisch mit DATABASE_ADMIN_URL lassen — wird
in Schritt 3 durch die eingeschränkte app_user-Rolle ersetzt.AUTH_SECRET: z.B. mit openssl rand -base64 32 erzeugen.SMTP_*: Zugangsdaten eines SMTP-Kontos für Magic-Link-Login und
Terminerinnerungen.CRON_SECRET: z.B. mit openssl rand -base64 32 erzeugen (schützt den
täglichen ICS-Sync-Cron-Endpoint).Datenbankschema + Rollentrennung anwenden:
npx drizzle-kit migrate
Das legt u.a. die Rolle app_user an (siehe
Mandantentrennung unten). Danach
einmalig ein Passwort für sie vergeben und DATABASE_URL darauf umstellen:
ALTER ROLE app_user WITH PASSWORD 'ein-sicheres-passwort';
# DATABASE_URL in .env: gleicher Connection String wie DATABASE_ADMIN_URL,
# aber mit user=app_user und dem oben vergebenen Passwort.
Dev-Server starten:
npm run dev
Öffne http://localhost:3000.
Alle Vereine teilen sich dieselbe Datenbank. Isolation erfolgt über
Postgres Row-Level-Security (src/db/migrations/0001_enable_rls_multi_tenant.sql):
jede mandantenbezogene Query muss innerhalb einer Transaktion laufen, die
zuvor app.current_verein_id gesetzt hat — siehe withTenant() in
src/db/index.ts. Domain-Code sollte immer withTenant(vereinId, tx => ...)
verwenden statt der rohen db-Instanz, sobald ein Verein-Kontext bekannt ist.
Zwei DB-Rollen, das ist wichtig: Neons Standard-Owner-Rolle (z.B.
neondb_owner) hat das Attribut BYPASSRLS — sie ignoriert RLS-Policies
komplett, unabhängig von FORCE ROW LEVEL SECURITY. Migration 0002 legt
deshalb eine zweite, unprivilegierte Rolle app_user an (kein BYPASSRLS),
die der reguläre App-Traffic verwendet (DATABASE_URL). Die privilegierte
Owner-Rolle (DATABASE_ADMIN_URL, src/db/admin.ts) ist ausschließlich für
Migrationen und den Cron-Job reserviert, der bewusst vereinsübergreifend
lesen muss. DATABASE_URL darf niemals auf die privilegierte Rolle
zeigen — sonst ist die Mandantentrennung wirkungslos, obwohl die Policies
existieren (das war der ursprüngliche Bug in diesem Projekt).
Die user-Tabelle (Auth.js-Identitäten) ist bewusst ohne RLS, da Auth.js
beim Login einen Nutzer per E-Mail finden muss, bevor der Verein-Kontext
überhaupt bekannt ist. Jede Admin-Query gegen user muss deshalb explizit
nach verein_id filtern — siehe Kommentar in der Migration.
Neuen Verein anlegen (Onboarding): Da verein selbst RLS-geschützt ist
(id = current_verein_id), muss die UUID vorab generiert und der
Tenant-Kontext vor dem Insert auf genau diese UUID gesetzt werden, z.B.:
const id = crypto.randomUUID();
await withTenant(id, (tx) => tx.insert(vereine).values({ id, name }));
vercel.json registriert GET /api/cron/ics-sync, einmal täglich
ausgeführt von Vercel Cron. Ein häufigerer Schedule (z.B. alle 6 Stunden)
wurde versucht, aber der Vercel-Hobby-Plan lehnt das Deployment komplett
ab, sobald ein Cron öfter als 1x/Tag laufen würde ("Hobby accounts are
limited to daily cron jobs") — kein stilles Herunterregeln, sondern ein
harter Deploy-Fehler. Ohne Upgrade auf den Pro-Plan bleibt es daher bei
einmal täglich. (Eine zwischenzeitliche Vermutung, der Hobby-Plan begrenze
zusätzlich die Anzahl an Cron Jobs pro Projekt, war ein Irrweg — der
eigentliche Grund für weitere fehlgeschlagene Deployments war ein fehlendes
DATABASE_URL in der Vercel-Projektkonfiguration, siehe
Lokales Setup für die vollständige Liste nötiger
Env-Variablen.) Die Route ist per CRON_SECRET geschützt (Vercel sendet den
Header Authorization: Bearer $CRON_SECRET automatisch mit, wenn die
Env-Variable gesetzt ist). Lokal manuell auslösen:
curl -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/cron/ics-sync
Die Route nutzt adminDb (privilegierte, RLS-freie Verbindung) nur, um
vereinsübergreifend alle Schiedsrichter mit hinterlegter ICS-Feed-URL
aufzulisten. Der eigentliche Sync pro Schiedsrichter läuft danach über
syncSchiedsrichterIcsFeed(), das intern withTenant() verwendet — die
Schreibzugriffe bleiben also RLS-konform pro Verein.
syncSchiedsrichterIcsFeed() normalisiert webcal:///webcals://-URLs zu
https://, bevor der Feed abgerufen wird — viele Verbands-Kalendersysteme
(getestet mit nuLiga, u.a. beim Hessischen Handballverband im Einsatz) geben
ihre Abo-Links standardmäßig in diesem Schema aus, das fetch() sonst nicht
versteht. Erfolgreich end-to-end mit einem echten Schiedsrichter-Kalender der
Saison 2025/26 getestet (Import + wiederholter Sync ohne Duplikate).
/admin/auswertung zeigt alle Termine des Vereins, filterbar nach Zeitraum,
Typ und Schiedsrichter (src/lib/termin-auswertung.ts). Zwei Export-Formate,
beide mit denselben Filtern als Query-Parametern:
/admin/auswertung/export, UTF-8 mit BOM, Excel-kompatibel) —
Datengrundlage für die spätere Zuschussberechnung (Phase 3)./admin/auswertung/export/pdf, src/lib/termin-pdf.ts, via
pdfkit) — druckbare Tabellenansicht, z.B. zum Aushängen/Weitergeben.pdfkit lädt seine Font-Metrik-Dateien zur Laufzeit vom Dateisystem und
verträgt sich deshalb nicht mit dem Server-Bundling — daher steht es in
serverExternalPackages in next.config.ts.
vercel.json registriert zusätzlich GET /api/cron/terminerinnerungen,
täglich eine Stunde nach dem ICS-Sync. Sucht Termine, die innerhalb der
nächsten 36 Stunden starten (src/lib/terminerinnerungen.ts), und schickt
eine E-Mail (plus Push, siehe unten) an:
/admin/zuordnung oder Selbst-Anmeldung einem Termin
zugeordneten Personen (Zeitnehmer, Sekretäre, Ordner, Kioskdienst, weitere
Schiedsrichter) — schließt die Lücke bei Testspielen/Turnieren ohne
Mannschaft, bei denen sonst niemand erreicht würde.Bereits verschickte Erinnerungen werden in benachrichtigung protokolliert
und beim nächsten Lauf übersprungen (kein Doppel-Versand). Schlägt der
Mailversand fehl (z.B. SMTP nicht erreichbar), wird keine
Benachrichtigung protokolliert, sodass der nächste Cron-Lauf automatisch
erneut versucht. Push ist dabei ein zusätzlicher, unabhängiger Kanal — ein
fehlgeschlagener Push-Versand markiert die (bereits erfolgreiche) E-Mail
nicht als fehlgeschlagen. Lokal manuell auslösen:
curl -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/cron/terminerinnerungen
Derselbe Cron-Lauf verschickt außerdem eine Erinnerung an alle Admins eines
Vereins, wenn in den nächsten 3 Tagen noch Ordner-/Kioskdienst- oder
Zeitnehmer-/Sekretär-Bedarf offen ist (src/lib/dienste-erinnerung.ts,
Digest über alle betroffenen Termine statt einer Mail pro Termin) — ergänzt
die rein passive Anzeige auf /admin/dienste um eine aktive Erinnerung.
Bewusst kein Dedup wie oben: eine offene Lücke soll täglich in Erinnerung
bleiben, solange sie besteht (maximal 3 Mails pro Termin, da nur innerhalb
des 3-Tage-Fensters versendet wird).
vercel.json registriert GET /api/cron/wochen-digest, wöchentlich
montags. Schickt jeder Person mit mindestens einem Einsatz in den nächsten 7
Tagen eine Übersichts-Mail (src/lib/wochen-digest.ts, nutzt dieselbe
Kalender-Logik wie /profil) — wer nichts eingetragen hat, bekommt keine
Mail. Lokal manuell auslösen:
curl -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/cron/wochen-digest
public/manifest.json + public/sw.js machen die App auf Mobilgeräten als
"App" installierbar (Add to Home Screen). Das Icon (src/app/icon.svg) kommt
über die Next.js Metadata-File-Convention automatisch als Favicon zum
Einsatz; public/icon-192.png/icon-512.png/apple-touch-icon.png sind
daraus gerasterte Fallbacks für das Manifest bzw. iOS-Homescreen-Icons, wo
SVG nicht zuverlässig unterstützt wird. Der Service Worker registriert sich
global (src/components/sw-register.tsx, Root Layout) — bewusst ohne
Offline-Caching/Fetch-Handler, da die App durchgehend serverseitig (Server
Actions, RLS-Session) rendert.
/admin/zuordnung (src/lib/zuordnung.ts): Admin ordnet Zeitnehmer,
Sekretäre (oder weitere Schiedsrichter, z.B. für Testspiele/Turniere)
konkreten anstehenden Terminen zu (termin_zuordnung, quelle = zugeordnet_durch_admin). Schiedsrichter aus dem ICS-Feed werden weiterhin
automatisch über termin.ics_schiedsrichter_id abgebildet, nicht über
termin_zuordnung.
Selbst-Anmeldung (/profil, src/app/profil/actions.ts): Nutzer mit
Rolle ordner oder kioskdienst sehen anstehende Termine des Vereins und
können sich selbst ein-/austragen (quelle = selbst_angemeldet). Erscheint
bei /admin/zuordnung entsprechend gekennzeichnet.
Öffentliche, login-freie Selbsteintragung gibt es zusätzlich für zwei
Rollen-Paare, je über einen Token-Link (Kenntnis des Tokens ist die
Berechtigung, analog zu /turnier/[token]), vom jeweiligen Wart auf seiner
Profilseite aktivierbar/deaktivierbar:
/zeitnehmer-eintragen/[token]
(vereine.zeitnehmer_selbstanmeldung_token, aktivierbar auf
/profil/zeitnehmerwart)./ordner-eintragen/[token]
(vereine.ordner_selbstanmeldung_token, aktivierbar auf
/profil/ordnerwart).Beide teilen sich dieselbe Mehrfachauswahl-UI (TerminMehrfachAuswahl in
src/components/mehrfachauswahl.tsx, rollenneutral über Props) und
denselben Namensabgleich (findeNamensVorschlag in
src/lib/namens-abgleich.ts): ein exakter Namenstreffer trägt die
gefundene Person direkt ein, sonst landet die Zuordnung mit externerName
und optionalem matchVorschlagUserId-Vorschlag beim jeweiligen Wart zur
Bestätigung. quelle = selbst_eingetragen_oeffentlich ist für beide
Rollen-Paare identisch — Auswertungen, die danach filtern, müssen
zusätzlich nach funktionstraeger_typ unterscheiden (siehe
unbestaetigteSelbsteintragungen in den jeweiligen Wart-Seiten).
/admin/zuschuesse (src/lib/zuschuss.ts): Zuschüsse sind ein
Opt-in pro Verein (verein.zuschuesse_aktiviert, Schalter oben auf der
Seite) und gelten bewusst nur für Schiedsrichter (nicht
Zeitnehmer/Sekretär). Der Admin pflegt zuerst einen Katalog an
(: Bezeichnung + Satz, z.B.
"Aufwandsentschädigung Schiedsrichter" / 20 €). "Offene Einsätze" listet
vergangene Schiedsrichter-Einsätze (aus und
zusammengeführt) ohne bestehenden Zuschuss; der
Admin wählt dort eine Zuschussart aus (kein freier Betrag) → legt einen
-Datensatz an. "Offene als CSV exportieren" liefert eine CSV
aller offenen Zuschüsse und markiert sie im selben Request als
— bewusst kein eigenes Rechnungs-/Zahlungsmodul, das läuft im
externen Abrechnungssystem. Schiedsrichter sehen den Status ihrer eigenen
Zuschüsse (offen/exportiert) transparent auf
( in ), statt nur der
Admin-Ansicht vertrauen zu müssen.
/admin/einstellungen → „Dienste“ legt pro Verein in EINER Liste fest, wie viele
Personen jeder Dienst (Ordner, Kioskdienst, Kassierer, Zeitnehmer/Sekretär und
eigene Dienste wie Fahrer) je Heimspiel, Auswärtsspiel (nur eigene Dienste),
Freundschaftsspiel und Turnier braucht und für welche Mannschaften er gilt
(feste Dienste: verein.<veranstaltung><Rolle>_bedarf +
mannschaft.<rolle>_bedarf_deaktiviert, src/lib/dienste.ts; eigene Dienste:
dienst_art.bedarf_*, src/lib/eigene-dienste.ts). Gilt nicht für spiel_ics-Termine: das sind die
persönlichen Einsätze des Schiedsrichters (oft bei fremden Vereinen), keine
Veranstaltungen des eigenen Vereins mit eigenem Personalbedarf.
Auf /profil sehen Nutzer mit Rolle ordner/kioskdienst die aktuelle
Kapazität ("2/3") und können sich anmelden, solange der Bedarf nicht erreicht
ist. Ist er erreicht, verschwindet der Anmelden-Button (serverseitig in
selbstAnmelden() zusätzlich abgesichert, falls zwei Personen gleichzeitig
den letzten Platz beanspruchen).
Der Hallenspielplan-Tab auf /admin/termine importiert Pflichtspiele aus dem
Liga-Spielplan —
enthält bewusst alle Spiele an der eigenen Halle (nicht nur die eigenen
Mannschaften), da die Halle für jedes dort stattfindende Spiel
Ordner-/Kioskdienst braucht. Zwei Wege dorthin, beide über dieselbe
Import-/Dedup-Logik (importiereRundenspielEreignisse in
src/lib/rundenspiel-sync.ts, Match über termin.ics_uid — ein erneuter
Import aktualisiert bestehende Spiele bei Terminverlegung statt sie zu
duplizieren):
parseRundenspielJson,
src/lib/rundenspiel-import.ts): JSON-Export pro Halle, z.B. aus einem
bestehenden Scraping-Workflow.src/lib/nuliga-scraper.ts): Auf
/admin/einstellungen bis zu drei Hallen-IDs hinterlegen und aktivieren —
dieselben Angaben, die zuvor manuell in einen externen Scraping-Workflow
eingetragen wurden. Holt das nuLiga-Hallen-HTML direkt per fetch() (fest
verdrahtet auf hhv-handball.liga.nu/HHV, siehe Kommentar in
nuliga-scraper.ts — andere Landesverbände brauchen eine weitere
Domain-Konstante) für ein rollierendes 10-Monats-Fenster ab dem
aktuellen Monat, statt eines fest einzutragenden Start-/Endmonats, der mit
der Zeit veralten würde. Läuft täglich per Vercel Cron
(/api/cron/rundenspiel-sync) für alle Vereine mit aktiviertem
Auto-Import; nach dem Speichern der Hallen-IDs läuft zusätzlich sofort ein
erster Sync, statt auf den nächsten Cron-Termin zu warten.Die Beschreibung importierter Rundenspiele kennzeichnet zusätzlich
Freundschaftsspiel/Turnier vs. echtes Ligaspiel (anhand der vom Verband
vergebenen Spielnummer, gameNumber — siehe bildeUid in
rundenspiel-import.ts) sowie ggf. weitere Zellen nach Heim-/Auswärtsteam
im nuLiga-Export (z.B. ein Schiedsrichter-Kürzel, Format je Halle/Verband
unbestätigt, daher roh statt interpretiert angehängt).
Duplikat-Erkennung (src/lib/duplikat-erkennung.ts): Admins legen
Testspiele oft manuell an, bevor ein Spiel offiziell im Verbandssystem
geführt wird (z.B. weil noch kein Schiedsrichter feststeht). Erscheint
dieselbe Begegnung später über den nuLiga-Sync als Rundenspiel, existieren
beide Termine parallel und doppeln sich im Kalender. Der Hallenspielplan-Tab
auf /admin/termine zeigt solche Fälle (gleicher Kalendertag + Team-Name-Match oder zeitliche
Nähe) als Vorschlagsliste zum manuellen Aufräumen — bewusst kein
automatisches Löschen, da beide Signale nicht zuverlässig genug für eine
automatische Entscheidung sind.
Der Trainingsplan (/admin/trainingsplan, Tabelle trainingszeit) gilt jede Woche gleich. Fällt ein Training einmal aus, meldet das ein Admin oder ein
Trainer der Mannschaft auf /profil/training („Fällt aus“, Grund optional; training_ausfall, je Trainingszeit und Datum höchstens einmal, RLS über
verein_id, im Mandantentrennungs-Test). Die Seite zeigt die Trainings der nächsten 14 Tage (lib/training-ausfall.ts, rein/getestet:
konkreteTrainings) und oben „Freie Hallenzeiten“. Alle aktiven Trainer des Vereins bekommen eine Info-Mail (lib/training-ausfall-mail.ts, auch bei der
Rücknahme „Findet doch statt“, nicht an die meldende Person). Persönlicher Schalter users.training_ausfall_hinweise_aktiviert (Default an, Profil →
Benachrichtigungen, nur für Trainer; Abmelde-Link Art training-ausfall), bewusst KEIN Vereins-Schalter. Der Extra-Punkt „Training“ (im Kalender über „Zum Training“ und unter „Mehr“) ist für Admins und Trainer immer sichtbar, für alle anderen nur mit vereine.training_fuer_alle_sichtbar (Einstellungen →
Training, Default aus). Auf der öffentlichen Vereinsseite erscheint es nur über den eigenen Schalter vereine.training_oeffentlich (siehe unten „Öffentlicher Reiter Training“). Frei gewordene Zeiten lassen sich belegen (training_belegung, Migration 0110, Unique je Trainingszeit+Datum = wer zuerst bucht, bekommt sie): Modal „Zeit belegen“
(components/zeit-belegen-dialog.tsx, Action trainingBelegen), Trainer buchen für ihre Mannschaft, Admins für jede andere, nie für die ausfallende; Aufheben durch die buchende Mannschaft oder Admins (trainingBelegungAufheben), Rücknahme
des Ausfalls bei belegter Zeit nur durch einen Admin (die Belegung entfällt). Regeln rein in lib/training-belegung.ts, Mails in lib/training-belegung-mail.ts (belegt: Trainer der ausfallenden Mannschaft + Admins; wieder frei: alle Trainer; entfällt: Trainer der buchenden
Mannschaft). Plätze (z.B. Beachhandball): halle.art ("halle" | "platz") und optionale Saison saison_von_monat/saison_bis_monat (Migration 0109; von > bis läuft über den Jahreswechsel, istInSaison, lib/trainingsplan.ts). Außerhalb der Saison entfallen die Trainings
des Ortes in „Training“, im Kalender und bei Ausfallmeldungen; der Wochenplan bleibt unverändert, Wortwahl „Platz frei“ statt „Halle frei“.
Termine des Vereins, die keine Spiele sind (Sitzung, Versammlung, Arbeitseinsatz …). Tabellen vereinstermin, vereinstermin_sichtbar (Auswahl je Rolle ODER Person) und
vereinstermin_antwort (Zusage/Vielleicht/Absage + Notiz), Migration 0107, alle mit verein_id + RLS (im Mandantentrennungs-Test). Ein-/ausschaltbar je Verein
(vereine.vereinstermine_aktiv, Default aus, Einstellungen → Vereinstermine); aus = keine Menüpunkte, Seiten, Mails. Verwaltung nur durch Admins unter
/admin/vereinstermine, Ansicht und Antworten für alle unter /profil/vereinstermine. Sichtbarkeit je Termin: alle / nur Admins / Auswahl (Rollen und/oder
Personen); Admins sehen alles (darfVereinsterminSehen, lib/vereinstermine.ts, rein/getestet). Antworten bis zum Beginn, nur bei „Um Rückmeldung bitten“; wer
geantwortet hat, sehen nur Admins, alle anderen Zahlen. Mails (lib/vereinstermin-mail.ts): Einladung, Änderung (Titel/Beginn/Ende/Ort), Absage an Zusagende; vom
Admin je Termin anhakbar, persönlich abschaltbar (users.vereinstermin_hinweise_aktiviert, Abmelde-Art vereinstermin). Die Termine stehen auch im persönlichen
ICS-Abo. Nie auf der öffentlichen Seite. Wiederholungen (Migration 0108, serie_id + wiederholung): jede Serie besteht aus einzelnen Terminen (höchstens 52; wöchentlich, alle 2 Wochen, monatlich am n-ten Wochentag; Rechnung rein in
lib/wiederholung.ts, deutsche Wanduhrzeit), eine Mail je Serie, Bearbeiten/Löschen „nur diesen“ oder „diesen und alle folgenden“, im Profil je Serie die nächsten drei Termine (kuerzeSerien).
Noch offen: nichts Geplantes mehr in dieser Reihe.
Der Kalender (/admin/kalender, „Mein Kalender“ auf /profil) zeigt alles, was im Verein stattfindet, in EINER Ansicht: Spiele & Dienste wie bisher, dazu Trainings (aus dem Wochenplan, mit Ausfällen durchgestrichen)
und Vereinstermine (nur, was die Person sehen darf). Zusätzliche Einträge liefert lib/kalender-ergaenzung.ts (holeKalenderErgaenzung), sie werden je Tag nach Uhrzeit eingemischt (mischeNachZeit, lib/kalender.ts).
Filter „Anzeigen“ über dem Kalender (components/kalender-filter.tsx, Suchparameter zeige, reine Hilfen in lib/kalender-arten.ts; bleibt beim Monatswechsel). Training im Admin-Kalender alle; im Profil Admins und
(bei „Training für alle“) alle Personen alle, sonst Trainer die ihrer Mannschaften. Menü aufgeräumt: „Termine“ heißt „Spiele & Turniere“, Trainingsplan und Vereinstermine stehen unter „Verwaltung“ (mobil unter „Mehr“),
der Kalender ist der Einstieg. Die Wochenübersicht-Mail (lib/wochen-digest.ts) enthält dieselben Ergänzungen (Trainings der eigenen Mannschaften, Vereinstermine); sie geht nur, wenn es Einsätze, Vereinstermine oder ein ausfallendes Training gibt (istDigestNeuigkeit), reguläre Trainings allein lösen keine Mail aus.
Optional je Verein (vereine.training_oeffentlich, Migration 0111, Default aus, Einstellungen → Training, nur mit öffentlicher Vereinsseite): fünfter Reiter /verein/[slug]/training in der öffentlichen App (components/liga/vereins-nav.tsx: ohne Training vier
Reiter, mit Training fünf, mitTraining aus dem Layout; Seite sonst 404). Zeigt die Trainings der nächsten zwei Wochen nach Tagen, Filter nach Gruppe und Favoriten wie bei „Spiele“, Ausfälle durchgestrichen. Aufbereitung als Whitelist in lib/training-oeffentlich.ts
(rein/getestet): NIE Grund eines Ausfalls, Notizen, Buchungen oder Trainernamen. Favoriten über mannschaft.liga_mannschaft_id, Gruppe aus der Liga-Kategorie, sonst aus Name/Altersklasse. Orte mit Saison erscheinen nur in der Saison. Daten über adminDb mit Vereinsfilter
(lib/training-oeffentlich-daten.ts), Sitemap enthält /training nur bei eingeschaltetem Reiter. Datenschutzseite und /app-hilfe#training erklären es.
Nutzer mit user.ist_system_admin = true haben kein verein_id (gehören
keinem einzelnen Verein an) und verwalten stattdessen unter
/system/vereine (src/app/system/) alle Vereine im System — Liste
(via adminDb, bewusst privilegiert/vereinsübergreifend) und ein Formular,
das analog zu /setup einen neuen Verein + dessen ersten Admin anlegt
(withTenant-Insert, RLS-konform). Das ersetzt den SETUP_SECRET-Bootstrap
für den Regelbetrieb — /setup bleibt als Notfall-Fallback bestehen, falls
kein Systemadmin mehr erreichbar ist.
requireSystemAdmin() (src/lib/session.ts) prüft nur die Session, nicht
vereinId (im Gegensatz zu requireSession()/requireAdmin()), da
Systemadmins bewusst keinem Verein zugeordnet sind. Auf der Startseite
(src/app/page.tsx) werden sie vor der normalen Admin-Weiterleitung zuerst
auf /system/vereine geleitet.
/admin/funktionstraeger → "Aus Excel importieren": Kopfzeile mit den
Spalten Name, E-Mail, Rolle (deutsche Bezeichnung oder Rollen-Key) und
optional Mannschaft (nur bei Trainer, muss namentlich zu einer bestehenden
Mannschaft passen). Parsing über exceljs
(src/lib/funktionstraeger-import.ts) — bewusst nicht das populäre
xlsx/SheetJS-Paket, dessen npm-Version ungepatchte Prototype-Pollution-
und ReDoS-Lücken hat (kritisch bei nutzergeladenen Dateien). Bereits
vorhandene Personen/Rollen werden beim Import übersprungen statt dupliziert;
das Ergebnis (angelegt/übersprungen/Fehler pro Zeile) wird nach dem Import
als Banner auf der Seite angezeigt.
Für die eigenen Stammdaten (Name, Telefonnummer) danach gibt es keinen
erneuten Import-Bedarf: Funktionsträger pflegen das selbst auf /profil
(updateStammdaten in src/app/profil/actions.ts) — bewusst ohne
E-Mail-Änderung, die bleibt login-kritisch Admin-Aufgabe
(updateFunktionstraeger).
/admin/termine/[id] erlaubt das Bearbeiten und Löschen manuell angelegter
Termine (Testspiele/Turniere). Termine aus dem ICS-Feed (quelle: "ics_feed")
sind davon bewusst ausgeschlossen — sie werden vom Sync verwaltet und würden
bei manueller Änderung beim nächsten Lauf wieder überschrieben.
Beim Anlegen eines Funktionsträgers (einzeln oder per Excel-Import) kann per
Schalter gewählt werden, ob sofort eine Willkommens-Mail mit Login-Hinweis
verschickt wird, oder ob die Person zunächst ohne Login angelegt wird.
Im zweiten Fall taucht sie inaktiv in der Liste auf; erst wenn ein Admin sie
über "Aktivieren" freischaltet, geht die Mail raus (funktionstraegerAktivToggeln
in src/app/admin/actions.ts). Das Aktiv-Flag lebt pro Rolle
(funktionstraeger_rolle.aktiv), nicht pro Person — inaktive Rollen tauchen
nicht mehr in Zuordnung oder Selbst-Anmeldung auf, bleiben aber in der
Zuordnungs-/Zuschuss-Historie erhalten (bewusst kein Löschen).
Ausnahme: Wer sich über die öffentliche Selbsteintragung (/eintragen/…) mit E-Mail einträgt, bekommt die Rolle sofort aktiv; der
zuständige Wart wird nur per Mail informiert und kann die Person bei einer Fremdeintragung ansprechen oder löschen.
Wird jemand einem Termin als Schiedsrichter/Zeitnehmer/Sekretär zugeordnet
(/admin/zuordnung), geht ebenfalls automatisch eine Mail raus
(src/app/admin/zuordnung/actions.ts).
Bei der Einzelanlage können mehrere Rollen gleichzeitig per Checkbox-Gruppe
ausgewählt werden (typen statt typ im Formular) — ein Nutzer kann
beliebig viele Funktionsträger-Rollen gleichzeitig haben.
/admin zeigt eine Übersicht (nächste Termine, unbesetzte Ordner-/
Kioskdienst-Termine, offene Zuschuss-Einsätze) statt direkt auf Mannschaften
zu leiten. /admin/kalender zeigt einen Monatskalender mit allen
Vereinsterminen; /profil/kalender zeigt jedem Funktionsträger die Termine,
bei denen er/sie beteiligt ist (ICS-Zuordnung, Termin-Zuordnung oder eigene
Mannschaft als Trainer). Die Kalender-Logik (Monatsraster, Wochenstart
Montag) ist bewusst dependency-frei in src/lib/kalender.ts implementiert.
npm test führt Vitest über die reine Business-Logik aus (Dienste-Bedarf,
Kalender-Berechnung, CSV-Formatierer, Excel-Import-Parser) — kein Browser,
keine Testdatenbank nötig. Da "server-only" kein echtes npm-Paket ist
(Next.js löst es intern auf), aliast vitest.config.mts es auf ein leeres
Stub-Modul (test/server-only-stub.ts).
src/db/tenant-isolation.test.ts ist die einzige Ausnahme, die eine echte
Postgres-Instanz braucht — er prüft direkt gegen die DB, dass Verein A
niemals Daten von Verein B sehen oder anlegen kann (siehe migrations/0001).
Ohne TEST_DATABASE_URL/TEST_DATABASE_ADMIN_URL wird er übersprungen,
npm test bleibt also auch ohne lokale DB grün. Zum Ausführen: eine
Postgres-Instanz mit allen Migrationen aus src/db/migrations (in
Journal-Reihenfolge) anlegen, dann analog zu Schritt 2/3 oben eine
Owner-Rolle (bypassrls) und app_user (ohne bypassrls, Passwort setzen)
einrichten und
TEST_DATABASE_ADMIN_URL="postgres://<owner>:<pw>@localhost:5432/<db>" \
TEST_DATABASE_URL="postgres://app_user:<pw>@localhost:5432/<db>" \
npx vitest run src/db/tenant-isolation.test.ts
Nach jeder Änderung an den RLS-Policies (migrations/0001, 0006, 0012, 0026, 0043) sollte dieser Test erneut laufen.
Die Oberfläche nutzt shadcn/ui im "Nova"-Stil auf
Basis von Base UI (nicht Radix — neuere
shadcn-Generation) und Tailwind v4, siehe components.json und
src/components/ui/. Wichtige Stolpersteine, falls weitere Komponenten
ergänzt werden:
<form action={serverAction}>-Elemente ohne
Client-JS-State. Select funktioniert trotzdem mit reinen Server
Actions, weil Base UI bei gesetztem name-Prop einen versteckten nativen
<input> rendert. Wiederverwendbare Wrapper-Komponente:
src/components/labeled-select.tsx.<Select>
bräuchte eigentlich eine Render-Funktion für das Label, aber Server
Components dürfen keine Funktionen als Props/Children an Client
Components weiterreichen (RSC-Serialisierungsgrenze). Deshalb kapselt
LabeledSelect diese Funktion selbst als Client Component; Server
Components übergeben ihr nur reine Daten ({value, label}[]).<Button render={<a href="..." />}> braucht nativeButton={false},
sonst wirft Base UI eine Konsolen-Warnung (Button erwartet standardmäßig
ein natives <button>-Element hinter dem render-Prop).defaultChecked/defaultValue an einer
unveränderten Komponentenposition (z.B. nach revalidatePath()) wird von
React als "uncontrolled component ändert sich nach Initialisierung"
gewarnt — beheben mit key={...}, um einen echten Remount zu erzwingen
(siehe Switch in src/app/admin/zuschuesse/page.tsx).Idee: Ein vom Vereins-Login getrennter, für alle offener Zugang. Jede Person (auch ohne Konto) sieht eine Liste aller angebundenen Vereine, wählt einen aus und sieht dessen Mannschaften mit aktueller Tabelle, Spielplan und Ergebnissen — ähnlich wie in gängigen Handball-Apps (Vereinsliste mit Favoriten-Stern und "+" zum Hinzufügen; pro Mannschaft die Tabs Tabelle / Ergebnisse / Spielplan, optional Kader; Filter "Nur eigene Spiele"). Der bisherige Bereich für Funktionsträger/Admins bleibt davon unberührt; die Startseite verweist künftig auf beide Zugänge.
Status: Erste Version umgesetzt unter /verein/[slug] (Branch
claude/oeffentliche-vereinsseiten): nuLiga-Sync (src/lib/nuliga/),
Vereins- und Mannschaftsseiten (Übersicht/Spielplan/Ergebnisse/Tabelle),
Favoriten für eingeloggte Konten, Sitemap/robots. Abweichungen von den
Vorschlägen unten: Seite entsteht, sobald ein Admin in
/admin/einstellungen die nuLiga-Vereins-ID hinterlegt (kein Systemadmin-
Import beliebiger Vereine); Favoriten liegen in der DB (nicht localStorage);
URL /verein/… statt /vereine/…. Der Rest der Liste unten gilt weiter als
Vorschlag (z.B. handball.net ab der 3. Liga, Besucher-Konten).
Offene Fragen vorab
/admin/einstellungen die nuLiga-Vereins-ID hinterlegt
— kein zusätzlicher Verein-Schalter, kein Systemadmin-Import.main, nach
Durchsicht der Quellen — nicht selbst gegen die Live-Seiten getestet):
clubTeams?club=<id>, Parser
src/lib/nuliga/parsers/club-teams.ts) werden alle Mannschaften eines
Vereins gefunden; von dort aus werden Gruppenseite (Tabelle + Spielplan,
parsers/group-page.ts) und Mannschaftsportrait
(parsers/team-portrait.ts) gelesen. Sync in src/lib/nuliga/sync.ts
(Struktur und Spiele getrennt, idempotent, fehlertolerant), Cron
/api/cron/liga-sync über sync-cron.ts (spieltagsnah häufiger; nur nuLiga,
handball.net hat den eigenen Cron /api/cron/handball-net-sync, stündlich um :45).
Tabellen liegen als liga_tabellenzeile in der DB (gecacht, kein Abruf
pro Seitenaufruf). nuLiga ist bisher nur für den HHV konfiguriert
(src/lib/nuliga/verbaende.ts; weitere Verbände = weiterer Eintrag).
Die Spalte "Mannschaftsverantwortlicher" wird bewusst nie gelesen
(Datenschutz). Zurückgezogene Mannschaften werden erkannt und
ausgeblendet (#178, #181).src/lib/handball-net/), Einstieg über die handball.net-Vereins-ID,
optional manuell ergänzte Team-IDs; die Tabelle kommt von
/api/new/standings?phase_id=… (parseOffizielleTabelle, nur Zahlen und
Teamname — die Antwort enthält Vereinskontakte); nur als Rückfall wird sie
aus den Spielen berechnet (berechneTabelle, ohne direkten Vergleich).
Offen: der Parameter round des Endpunkts wird bisher nicht gesetzt —
nach dem ersten Sync prüfen, ob die Tabelle dem Stand auf handball.net
entspricht. Unabhängig von nuLiga (ein
Ausfall betrifft die andere Quelle nicht).termin.typ = 'rundenspiel', siehe Abschnitt "Rundenspiele").
Vorschlag: pro Mannschaft bzw. nuLiga-Zuordnung
nutzen und die Tabelle ebenfalls dort abholen.Technische Leitplanken
/vereine, /vereine/[slug],
/vereine/[slug]/[mannschaft]) in publicRoutes bzw. als Präfix in
src/proxy.ts freigeben; bestehendes Muster: /turnier/, /kalender/.adminDb mit explizitem Filter auf
den freigeschalteten Verein (kein withTenant ohne Session) — Ausnahme
analog zu den öffentlichen Token-Seiten. Nach dem Bau
src/db/tenant-isolation.test.ts ergänzen bzw. laufen lassen und prüfen,
dass keine nicht freigeschalteten Vereine und keine internen Daten
(Zuordnungen, Personen, Schiedsrichter-Besetzung, E-Mails) ausgeliefert
werden.CLAUDE.md); die
Mannschafts-Ansicht nutzt die vorhandene Bottom-Navigation
(src/components/bottom-nav.tsx) mit Tabs Tabelle / Ergebnisse /
Spielplan. Favoriten ("Meine Vereine") zunächst lokal im Browser
(localStorage, mit try/catch), kein Konto nötig./roadmap (Inhalt und Stand in src/lib/roadmap.ts, bei jeder Funktionsänderung mitpflegen, siehe CLAUDE.md).lib/seo-meta.ts), BreadcrumbList (nur Markup, keine sichtbare Leiste), Sitemap + robots.txt (Details: CLAUDE.md → SEO)./datenschutz enthält einen Abschnitt zu den öffentlichen
Vereins- und Mannschaftsseiten sowie zu Zugriffsdaten (Server-Protokolle,
IP-Adresse) auch ohne Konto (der frühere Entwurf-Warnhinweis auf /datenschutz und /admin/avv
wurde auf Wunsch entfernt, die Texte sind aber nicht juristisch geprüft).
Offen: juristische Prüfung der Texte; Impressum nennt
DeWe Consulting UG als Betreiber — zu klären, ob für den öffentlichen
Bereich (und die dort angezeigten Verbandsdaten) etwas ergänzt werden
muss. Zu den Abrufen: Laut Kommentar im Code haben HHV (nuLiga) und DHB
(handball.net) dem automatischen Abruf zugestimmt — die Zustimmung ggf.
schriftlich ablegen; für weitere Verbände jeweils neu klären.Grober Ablauf
clubTeams-Seite mit Vereins-ID
und handball.net), Datenmodell + Sync ergänzen.Idee: nuLiga hat einen Live-Ticker (nuScore Live), der mit Spielbeginn läuft. Auf der öffentlichen Vereinsseite soll sichtbar werden, dass ein Spiel gerade läuft, und der Ticker erreichbar sein.
Stufe 1 — Link zum Ticker (einfach, ohne Abruf):
components/liga/liga-ui.tsx
(SpielKarte); "läuft gerade" ergibt sich aus Beginn bis ca. 2 Stunden
danach ohne Ergebnis.Stufe 2 — Live-Stand direkt auf der Seite (aufwendiger, erst nach Klärung):
handball-net/modell.ts
Ergebnisse nur für beendete Spiele (is_finished). Ob sie auch einen
Live-Stand hat, an einer echten Antwort prüfen.Reihenfolge: Stufe 1 ist umgesetzt (siehe unten, Stand 17.10.2026). Stufe 2 erst nach Zustimmung des HHV und nach Sichtung der Daten.
Umgesetzt und live:
spielstatistik-Seite), handball.net
(Spielseite /match/<Spiel-ID>?season_id=<Saison> mit dem Tab "LIVETICKER"; vor/während des Spiels "Live-Ticker", nach dem Spiel
"Spielseite bei handball.net"). Es gibt in der App WEITERHIN KEINE eigene Tickeransicht und keine gespeicherten Ereignisse, für keine Quelle./api/cron/liga-live (alle 5 Min, Freitag bis Sonntag, nur nuLiga): holt den Spielbericht-Link (= Ticker) rund um den Anwurf
statt erst im Stundentakt. Halbzeitstand unter dem Ergebnis, wo die Quelle ihn liefert (nuLiga, NDR; handball.net nicht belegt)./system/sync, Schalter für die automatische Ansprache unter /system/ansprache,
Feedback als erledigt markieren/direkt beantworten, /system/handball-net-diagnose (rohe Spielantwort + ein /api/-Pfad aus dem Browser).Offen (in dieser Reihenfolge):
/system/handball-net-diagnose mit Team 86819 und Spiel 392192 (und 392196) ausführen und die Ausgabe weitergeben;
(b) im Browser auf der Spielseite den Tab LIVETICKER öffnen und im Netzwerk-Tab die /api/…-Requests sichern (Pfad, Parameter, Antwort gekürzt,
Polling/WebSocket/SSE); (c) den PDF-Spielbericht (handball360.isquad.de/dhb_acta_completa_pdf.php?id_partido=<ID>) prüfen: gibt es ihn für jedes
Spiel, ist die Adresse dauerhaft, was enthält er. Verifiziert sind bisher nur die Spielseiten 392196 (live, 42 Ereignisse bei Halbzeit) und
392192 (beendet, 89 Ereignisse, Spielende 60:00), nicht die zugrunde liegende API.is_finished); Live-Cron auch für handball.net-Spiele, sobald der Endpunkt belegt ist; Zeitplan des Live-Cron (Fr–So, UTC) prüfen, falls Spiele
unter der Woche oder nach 21 Uhr UTC dazukommen; NDR-Spielseite (spielstatistik) auf Inhalt/Ereignisse prüfen.Aus der Durchsicht auf Bedienbarkeit/Komplexität. Umgesetzt: Einstellungen (Erste Schritte oben, Rest unter "Weitere Einstellungen", Hallenplan-Bereich nur bei aktivem Import), Mail-Schalter im Profil (ein gemeinsamer "Hinweise zu offenen Diensten"), Ordnerwart: Person ohne Login zuordnen. Offen (in dieser Reihenfolge):
ordnerBedarfUeberschreiben).ordnerInaktiveRolleAktivierenUndZuordnen, auch in der Admin-Übersicht)./eintragen/[token]). Die öffentlichen Eintragungsaktionen (Zeitnehmer/Ordner) sind seit 11.10.2026 EINE Logik in lib/selbsteintragung.ts (mit Tests); die beiden actions.ts sind dünne Hüllen mit den Unterschieden (Rollen, Token, Besetzungs-/Doppelrollen-Prüfung). Offen bleibt nur das Zusammenlegen der Wart-Aktionen (profil/zeitnehmerwart|ordnerwart/actions.ts).rundenspiel-sync, rundenspiel-import, ics-sync, hallenplan-*): erst,
wenn Freundschaftsspiele und Turniere anders gepflegt werden. Bis dahin ist der Bereich für Vereine mit abgeschaltetem Import
ausgeblendet und bleibt als Systemadmin-Werkzeug (/system/abgleich).Sammelstelle für alles, was in den letzten Arbeitsschritten besprochen wurde, noch offen ist oder bewusst nicht umgesetzt wurde. Beim Weiterarbeiten hier abhaken bzw. entfernen.
Umgesetzt und live (main)
/admin/termine mobil: Liste nach Tag gruppiert (Desktop-Tabelle
unverändert).Gemergt und am Handy geprüft (bestätigt am 01.10.2026)
/zeitnehmer-eintragen/… und /ordner-eintragen/…
klebt am unteren Rand; Umschalter "Nach Datum" (Standard) / "Offene
zuerst".src/components/bottom-nav.tsx) mobil für Admin
(Übersicht, Kalender, Termine, Training, Mehr), /profil (rollenabhängig;
ohne Rollen Profil + Hilfe) und /system.Gemergt, noch nicht bestätigt
holeTabelle filtert zurueckgezogen, #181). Prüfen, ob die Zeile in der
Live-Tabelle verschwindet; falls nicht, einmal "Jetzt aktualisieren" in
/admin/einstellungen (setzt das Merkmal beim Sync).Besprochen und entschieden
/zeitnehmer-eintragen, /ordner-eintragen): Standard
der Sortierung ist "Offene zuerst" (Umschalter auf "Nach Datum" bleibt),
entschieden am 01.10.2026./admin/einstellungen, Default aus).Noch zu entscheiden / bewusst nicht gebaut
/hilfe ohne Bottom-Navigation; Schiedsrichter und Trainer werden bei Sync-Verlegungen per Mail informiert, umgesetzt.)spiel_ics, persönlicher Feed eines
Schiedsrichters) — nicht gebaut, weil niemand sonst darauf eingetragen
ist; bei Bedarf neu entscheiden./hilfe liegt außerhalb der Layouts und zeigt keine
Leiste (dort gibt es den Zurück-Button). Ggf. angleichen.main per Branch-Schutz nur noch über Pull Requests änderbar ist — dann
automatisch einen PR erzeugen lassen statt direkt zu pushen.drizzle-kit zieht transitiv eine veraltete esbuild-Version (moderate,
nur Dev-Dependency, betrifft nur den lokalen drizzle-kit-Dev-Server).exceljs zieht transitiv eine uuid-Version mit einer moderate-severity-
Lücke (Buffer-Bounds-Check bei explizit übergebenem Buffer — wird von uns
nicht in dieser Form aufgerufen).termin_zuordnung) bekommen weiterhin keine Erinnerung, da niemand
konkret ermittelbar ist.src/lib/nuliga-scraper.ts) — weitere Verbände
bräuchten eine zusätzliche Domain-Konstante bzw. ein Verein-Einstellungsfeld
dafür.Pro Person unter /profil/darstellung einschaltbar (users.design_neu): Dashboard-Startseite mit „Heute“-Karte, „Braucht dich“ und Kacheln, Seitenleiste auf dem Rechner, Bereichsseiten /admin/bereich/[gruppe]. Demo und neue Vereine (ab 19.10.2026) starten damit, Bestandsvereine behalten die bisherige Ansicht (lib/design-regel.ts). Details siehe CLAUDE.md.
zuschussarttermin_zuordnungics_schiedsrichter_idzuschussexportiert/profilholeEigeneZuschuessesrc/lib/zuschuss.tshandballNetTeamId/datenschutz ist ein Entwurf). Erst nach juristischer Prüfung und
nur mit Einwilligung bzw. opt-in.