diff --git a/CLAUDE.md b/CLAUDE.md
index abca6e6..15a5857 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,7 +4,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
-stammtisch-hersbruck.de is a SvelteKit 5 application using Svelte 5's new runes syntax ($state, $props, etc.) with PocketBase as the backend. The app manages events, stages, riders, times and teams for a motorsport/cycling event organization.
+**Kurbeler** (bis 2026-09-08 „stammtisch-hersbruck.de"; Domain und
+Repository-Verzeichnis heißen weiterhin so) is a SvelteKit 5 application using Svelte 5's new runes syntax ($state, $props, etc.) with PocketBase as the backend. The app manages events, stages, riders, times and teams for a motorsport/cycling event organization.
The repository is a monorepo: the SvelteKit app lives in `frontend/`, the PocketBase instance in `backend/`.
@@ -62,18 +63,30 @@ docker compose down
- **Type-safe client**: The PocketBase client is typed using auto-generated types in `frontend/src/lib/types.d.ts`
- **Collections**: users, teams, team_invites, events, stages, riders, times,
trails, trail_versions, trail_flags, trail_markers, trail_comments,
- event_participants, event_series
+ event_participants, event_series, notification_prefs, notifications
- **Regeln**: Jede Zugriffsregel, die `@request.auth.id` erwähnt, beginnt mit
`@request.auth.id != "" && (…)`. Ohne diesen Vorspann ist eine *leere*
Relation gleich dem leeren `@request.auth.id` einer anonymen Anfrage — ein
Team ohne Admins stünde damit offen im Netz. Siehe
`backend/pb_migrations/1754501700_rules_require_login.js`.
- **Hooks**: `backend/pb_hooks/` enthält serverseitiges JS für das, was keine
- Collection-Regel abbilden kann — derzeit die öffentlichen Routen der
- Einladungslinks. Jeder Route-Handler läuft in einer eigenen JS-Laufzeit;
- gemeinsamer Code gehört in ein Modul und wird per
+ Collection-Regel abbilden kann — die öffentlichen Routen der Einladungslinks,
+ den Uhrenabgleich, `GET /api/directory/users` (Benutzerverzeichnis des
+ Superadmins), `POST /api/team-invites/send` (Einladungsmail) und die
+ Benachrichtigungen. Jeder Route- und Event-Handler läuft in einer eigenen
+ JS-Laufzeit; **was neben `routerAdd`/`onRecord…` im Dateiscope steht, sieht
+ der Handler nicht.** Gemeinsamer Code gehört in ein Modul und wird per
``require(`${__hooks}/…`)`` im Handler geholt. Details in
`backend/README.md`.
+- **Mails**: SMTP im Admin-UI; dazu `APP_URL` (Adresse des **Frontends**, für
+ die Links) und optional `APP_NAME`. Vorlagen liegen in
+ `backend/pb_hooks/views/` und werden mit `$template.loadFiles(…).render(…)`
+ gefüllt.
+- **Benachrichtigungen**: Kein Auslöser verschickt eine Mail — alle legen
+ einen Eintrag in `notifications`, ein Cron-Job macht daraus **eine
+ Zusammenfassung pro Konto und Tag** (6 Uhr). Voreinstellungen stehen doppelt,
+ in `notifications_lib.js` und `notificationPrefs.svelte.ts`; beide Listen
+ müssen zusammenpassen.
- **Authentication**: Handled through `src/lib/stores/pocketbase.svelte.ts` with the `AuthStore` class
- **File handling**: Use `getFileURL(record, file, options)` helper for PocketBase file URLs
@@ -155,10 +168,20 @@ tippy.js, which the action initializes, updates and destroys.
- Der Menüpunkt **Team** (`/dashboard/team`) führt Team und Kader an einer
Stelle: Kennzahlen, Teamdaten, Einladungslinks und die Fahrerliste samt der
Verwaltungsknöpfe der Teamleitung. Unter **Einstellungen** steht nur noch,
- was einen selbst betrifft (Profil, Flag-Typen).
+ was einen selbst betrifft (Profil, Benachrichtigungen, Flag-Typen) — dazu für
+ Superadmins der Reiter **Benutzer**, das appweite Kontoverzeichnis.
+- **Konten löscht die Teamleitung nicht.** Im Kader gibt es genau einen
+ Eingriff an einer fremden Person: „Zugang zum Team entziehen"
+ (`teams.removeMember`). Fahrer, Teilnahmen und Zeiten bleiben dabei stehen —
+ sie sind die Geschichte des Teams, nicht die des Logins. Ein Konto löscht
+ allein sein Inhaber (Einstellungen → Profil) oder der Superadmin
+ (Einstellungen → Benutzer); `users.deleteRule` erzwingt das. Ein
+ „Fahrer löschen" gibt es bewusst nicht mehr.
- **Ein Fahrer, ein Name, eine Richtung**: Ein Konto entsteht immer am Fahrer
- (`riders.createLogin`), es gibt kein Verknüpfen bestehender Konten und kein
- Lösen. Angezeigt wird ausschließlich der Fahrername — der Kontoname wäre
+ — die Teamleitung lädt per Mail ein (`POST /api/team-invites/send`), das
+ Passwort setzt der Eingeladene selbst. Ein Passwort im Kader zu vergeben gibt
+ es nicht mehr. Verknüpft wird der Fahrer beim Einlösen der Einladung; es gibt
+ kein Verknüpfen bestehender Konten und kein Lösen. Angezeigt wird ausschließlich der Fahrername — der Kontoname wäre
eine zweite Wahrheit, die niemand geradeziehen könnte, weil
`users.updateRule` nur den Kontoinhaber selbst ändern lässt.
- Reusable components in `src/lib/components/`
diff --git a/NAMING.md b/NAMING.md
deleted file mode 100644
index faf414f..0000000
--- a/NAMING.md
+++ /dev/null
@@ -1,31 +0,0 @@
-Die "Wanderer"-Schiene
-
- Shredder: Ziemlich actiongeladen, 100% MTB-Jargon und hat genau wie "Wanderer" dieses aktive, ausführende Element.
-
- Kurbler: Die direktere, etwas rotzigere deutsche Alternative. Hat einen leichten Retro-Charme.
-
- Dropper: Angelehnt an die Dropper-Post (absenkbare Sattelstütze) oder das "Reindroppen" in den Trail.
-
-Fokus auf Trail & Community
-
- TrailTribe: Betont den Vereins- und Gruppenaspekt hervorragend.
-
- DirtBase: Perfekt für die Traildatenbank – kurz, prägnant und technisch sauber.
-
- FlowHub: Verbindet das ultimative MTB-Gefühl ("Flow") mit dem modernen Plattform-Gedanken für Eventveranstalter.
-
-Fokus auf Zeitnahme & Action
-
- SendSync: "Send it!" gepaart mit der schnellen Datensynchronisation einer Webapp.
-
- PacePeak: Klingt sportlich, kompetitiv und extrem passend für Rennveranstalter und Bestzeitenjäger.
-
- TrailTicker: Etwas verspielter, bringt aber das Thema Zeitmessung und Live-Updates sofort auf den Punkt.
-
-Kurz & Knackig (Startup-Vibe)
-
- Berm: Englisch für die Anliegerkurve. Kurz, international und unter Mountainbikern ein fester Begriff.
-
- Roost: Der aufgeworfene Dreck in der Kurve. Hat Energie und ist sehr spezifisch für den Offroad-Sport.
-
- Stollen: Ein sehr deutsches, geerdetes Wort (Stollenreifen), das MTB-Flair mit technischer Präzision verbindet.
diff --git a/README.md b/README.md
index 15baa44..1c0b6b2 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,24 @@
-# stammtisch-hersbruck.de
+# stammtisch-hersbruck.de — abgelöst
+
+> **Dieses Repository wird nicht weiterentwickelt.**
+>
+> Die App heißt seit dem 08.09.2026 **Kurbeler** und wird unter
+> `~/Dokumente/Projekte/kurbeler` fortgeführt. Dort liegt die vollständige
+> Historie dieses Repos — es ist kein Neuanfang, sondern dieselbe Geschichte
+> unter neuem Namen.
+>
+> Der Stammtisch Hersbruck bekommt **kein** eigenes Deployment mehr, sondern
+> ist ein Team in Kurbeler. Die PocketBase-Instanz hinter
+> `api.stammtisch-hersbruck.de` bleibt dieselbe und wird nur umbenannt — es
+> zieht kein einziger Datensatz um.
+
Zeitmessung und Verwaltung für Läufe des Stammtisch Hersbruck.
+Die Anwendung heißt seit dem 08.09.2026 **Kurbeler**. Domain, Repository und
+Verzeichnisnamen bleiben `stammtisch-hersbruck.de` — umbenannt wurde der Name,
+unter dem die App auftritt, nicht ihre Adresse.
+
Das Repository enthält beide Teile der Anwendung:
| Verzeichnis | Inhalt |
diff --git a/backend/.env.example b/backend/.env.example
index a1eb8ab..2339aab 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -26,3 +26,18 @@ SUPERUSER_PASSWORD=bitte-aendern-min-8-zeichen
# Optional: überschreibt den in docker-compose.yaml gepinnten Default.
# Nur setzen, wenn bewusst eine andere PocketBase-Version gebaut werden soll.
#PB_VERSION=0.39.11
+
+# Adresse des Frontends, ohne Schrägstrich am Ende. Aus ihr baut PocketBase
+# die Links in Einladungs- und Benachrichtigungsmails.
+#
+# Wichtig: Das ist NICHT die Adresse von PocketBase. Ein Einladungslink auf
+# die API führte ins Leere — er muss auf die Seite zeigen, die das Konto
+# anlegt. Ohne diese Variable verweigern die Mail-Routen den Versand, statt
+# tote Links zu verschicken.
+#
+# Lokal: http://stammtisch-hersbruck.de.localhost:31337
+APP_URL=https://stammtisch-hersbruck.de
+
+# Optional: Name der Anwendung in Betreff und Mailtext. Ohne die Variable
+# steht dort "Kurbeler". Nur setzen, wenn eine Instanz sich abheben soll.
+#APP_NAME=Kurbeler (Test)
diff --git a/backend/README.md b/backend/README.md
index e1a1519..e361cf2 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -1,4 +1,4 @@
-# stammtisch-hersbruck.de — Backend (PocketBase)
+# Kurbeler — Backend (PocketBase)
PocketBase-Instanz für Events, Läufe, Fahrer, Zeiten und Teams.
@@ -105,12 +105,121 @@ Serverseitiges JavaScript, das PocketBase beim Start lädt (`--hooksDir`). Wie
`pb_migrations/` kommt das Verzeichnis per `COPY` ins Image — eine Änderung
wirkt also erst nach `docker compose up -d --build`.
-Bisher liegt dort ein Anliegen: die öffentlichen Routen der Einladungslinks.
-
| Datei | Zweck |
|---|---|
+| `clock.pb.js` | Uhrenabgleich für die Zeitnahme |
| `team_invites.pb.js` | `GET /api/team-invites/{token}`, `POST …/accept`, `POST …/join` |
-| `team_invites_lib.js` | Gemeinsame Helfer der drei Routen |
+| `team_invites_lib.js` | Gemeinsame Helfer der drei Einladungs-Routen |
+| `directory.pb.js` | `GET /api/directory/users` — Benutzerverzeichnis des Superadmins |
+| `directory_lib.js` | Helfer dazu |
+| `mail_lib.js` | Absender, Vorlagen, Versand — alles, was Mails verschickt, geht hier durch |
+| `notifications.pb.js` | Auslöser der Benachrichtigungen und der tägliche Versand |
+| `notifications_lib.js` | Voreinstellungen, Empfängerkreis, Warteschlange |
+| `views/invite.html` | Vorlage der Einladungsmail |
+| `views/digest.html` | Vorlage der Tageszusammenfassung |
+
+Vorlagen werden mit `$template.loadFiles(...).render(data)` gefüllt — Go's
+`html/template`. Die Platzhalter stehen deshalb in `{{ }}` und werden dabei
+escaped; Trailnamen und Kommentartexte kommen aus der Datenbank und dürfen
+kein Markup einschleusen.
+
+## Mails
+
+SMTP wird im Admin-UI eingerichtet (Settings → Mail settings). Zwei
+Umgebungsvariablen kommen dazu, beide in `.env.example` beschrieben:
+
+| Variable | Wofür |
+|---|---|
+| `APP_URL` | Adresse des **Frontends**. Aus ihr entstehen die Links in Mails. |
+| `APP_NAME` | Optional. Name in Betreff und Text; ohne sie steht dort „Kurbeler". |
+
+`APP_URL` ist ausdrücklich nicht `settings().meta.appURL` — die zeigt auf
+PocketBase selbst, und ein Einladungslink dorthin führte auf die API statt auf
+die Seite, die das Konto anlegt. Sie ist auch nicht der `Origin` des
+Aufrufers: Der steht unter dessen Kontrolle, und ein Link in einer Mail, den
+ein Fremder auf seinen eigenen Server zeigen lassen kann, ist eine
+Phishing-Vorlage mit unserem Absender darauf. Fehlt die Variable, verweigern
+die Mail-Routen den Dienst.
+
+### Einladung statt Passwort
+
+`POST /api/team-invites/send` ist der Ersatz für „Konto anlegen" im Kader.
+Dort tippte die Teamleitung Adresse **und Passwort** ein und musste dieses
+Passwort anschließend irgendwie zum Fahrer bringen — über einen Kanal, den
+niemand geplant hat. Sie kannte es außerdem.
+
+Jetzt legt die Route eine persönliche Einladung an (`team_invites` mit
+`email` und `rider`) und verschickt sie. Das Passwort entsteht erst beim
+Empfänger auf `/invite/{token}`. Die Adresse ist dabei gebunden: Das Feld auf
+der Seite ist gesperrt, und weil ein gesperrtes Feld nur eine Bitte ist, prüft
+`/accept` sie noch einmal.
+
+Die allgemeinen **Einladungslinks bleiben unverändert** — sie gelten jedem,
+der sie hat, und lassen `email` und `rider` leer.
+
+### Benachrichtigungen: eine Mail am Tag
+
+Kein Auslöser verschickt eine Mail. Alle legen einen Eintrag in
+`notifications`, und `cronAdd('notifications_digest', '0 6 * * *', …)` macht
+daraus einmal täglich je Konto eine Zusammenfassung.
+
+Das ist der ganze Punkt: Ein Trailkommentar, dem drei Antworten folgen, löste
+sonst vier Mails aus. Ein Verein, der sich die nicht leisten kann, schaltet
+nach drei Tagen alles ab.
+
+Nebenwirkung, die zählt: Ein Fehler im Mailversand kann nie das Speichern
+eines Events verhindern — der Auslöser schreibt nur in eine Tabelle.
+Scheitert der Versand an ein Konto, bleiben dessen Einträge unversendet stehen
+und sind am nächsten Morgen wieder dabei.
+
+Was ohne Einstellungen gilt, steht als `DEFAULTS` in `notifications_lib.js`
+und noch einmal in `frontend/src/lib/stores/notificationPrefs.svelte.ts`.
+**Beide Listen müssen zusammenpassen** — stünde dort etwas anderes, zeigte die
+Oberfläche einen Schalter an, der nichts mit dem zu tun hat, was verschickt
+wird. Die Linie: An heißt „betrifft mich", aus heißt „ist viel".
+
+`notifications` ist über die API nicht beschreibbar (create/update/delete auf
+`null`, in PocketBase „nur Superuser"). Gefüllt wird sie ausschließlich aus
+den Hooks, die an den Regeln vorbeilaufen — sonst schriebe sich jeder selbst
+Meldungen, oder anderen.
+
+### Warum das Verzeichnis eine Route braucht
+
+Konten entstehen mit `emailVisibility: false` (`teams.svelte.ts`,
+`createMember`). PocketBase gibt die E-Mail-Adresse damit nur ihrem Inhaber
+heraus — in jedem `users`-Datensatz, den jemand anderes liest, ist das Feld
+leer. Ein Verzeichnis ohne Adressen wäre aber eine Liste von Namen, in der
+niemand ein Konto wiedererkennt.
+
+Das Flag umzulegen wäre eine Zeile und zugleich ein Loch: `users.listRule`
+gilt appweit (`@request.auth.id != ""`), jede angemeldete Person läse danach
+jede Adresse — quer über alle Teams. Der Hook liest sie stattdessen
+serverseitig und gibt sie nur dem heraus, der sie sehen darf.
+
+`superadmin` wird dabei frisch aus der Datenbank gelesen und nicht aus dem
+mitgeschickten Auth-Record: Der Token hält den Stand von der Anmeldung fest,
+eine entzogene Rolle wirkte sonst bis zu seinem Ablauf nicht.
+
+## Wer darf ein Konto löschen?
+
+Zwei, und sonst niemand — `users.deleteRule` (siehe
+`1757340000_users_superadmin_delete.js`):
+
+| Wer | Wo |
+|---|---|
+| Der Inhaber selbst | Einstellungen → Profil |
+| Superadmin | Einstellungen → Benutzer |
+
+Die Teamleitung ausdrücklich **nicht**. Sie verwaltet Fahrer und
+Mitgliedschaften; im Kader gibt es dafür genau einen Eingriff an einer fremden
+Person, „Zugang zum Team entziehen" (`teams.removeMember`). Der nimmt das Konto
+aus `teams.users`/`teams.admins` — Fahrer, Teilnahmen und Zeiten bleiben
+stehen. Sie sind die Geschichte des Teams und nicht die des Logins.
+
+Ein Konto, das ein Team besitzt, lässt sich nicht löschen: `teams.owner` ist
+eine Pflichtrelation ohne cascadeDelete, PocketBase müsste sie beim Löschen
+leeren und bräche dabei den Datensatz. Beide Oberflächen prüfen das vorher und
+sagen, welches Team im Weg steht — sonst stünde dort ein Datenbankfehler.
Warum überhaupt ein Hook? Wer über einen Link beitritt, ist noch nicht
angemeldet und muss drei Dinge auf einmal tun, die keine Collection-Regel
diff --git a/backend/docker-compose.yaml b/backend/docker-compose.yaml
index f00598a..ddd8529 100644
--- a/backend/docker-compose.yaml
+++ b/backend/docker-compose.yaml
@@ -29,6 +29,15 @@ services:
# Superuser wird nur beim allerersten Start angelegt (siehe entrypoint.sh)
SUPERUSER_EMAIL: ${SUPERUSER_EMAIL:-}
SUPERUSER_PASSWORD: ${SUPERUSER_PASSWORD:-}
+ # Adresse des Frontends. Aus ihr entstehen die Links in Mails —
+ # Einladung und Tageszusammenfassung. NICHT die Adresse von PocketBase:
+ # Ein Link dorthin führte auf die API statt auf die Seite. Fehlt die
+ # Variable, verweigern die Mail-Routen den Dienst, statt tote Links zu
+ # verschicken.
+ APP_URL: ${APP_URL:-}
+ # Name der Anwendung in Betreff und Mailtext. Nur setzen, wenn eine
+ # Instanz sich abheben soll ("Kurbeler (Test)").
+ APP_NAME: ${APP_NAME:-}
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8090/api/health"]
interval: 30s
diff --git a/backend/pb_hooks/directory.pb.js b/backend/pb_hooks/directory.pb.js
new file mode 100644
index 0000000..fc99094
--- /dev/null
+++ b/backend/pb_hooks/directory.pb.js
@@ -0,0 +1,74 @@
+///
+
+// Das Benutzerverzeichnis des Superadmins.
+//
+// GET /api/directory/users — alle Konten der App, nur Superadmin
+//
+// Warum eine eigene Route und keine Collection-Regel? Wegen der E-Mail.
+// Konten entstehen mit `emailVisibility: false`, PocketBase gibt die Adresse
+// damit nur ihrem Inhaber heraus — und ein Verzeichnis ohne Adressen wäre
+// eine Liste von Namen, in der niemand ein Konto wiedererkennt. Das Flag
+// umzulegen wäre eine Zeile und zugleich ein Loch: `users.listRule` gilt
+// appweit (`@request.auth.id != ""`), jede angemeldete Person läse danach
+// jede Adresse, quer über alle Teams.
+//
+// Der Hook liest sie stattdessen serverseitig und gibt sie nur dem einen
+// heraus, der sie sehen darf.
+//
+// Jeder Handler holt sich seine Helfer selbst per require(): PocketBase führt
+// Route-Handler in einer eigenen JS-Laufzeit aus, der Dateikopf hier ist dort
+// nicht sichtbar.
+
+// --- Alle Konten der App ----------------------------------------------------
+//
+// Das Verzeichnis des Superadmins. Es führt Konten, nicht Fahrer: Ein Fahrer
+// gehört einem Team und wird dort verwaltet, ein Konto gehört niemandem außer
+// sich selbst — und genau deshalb kann es auch nur hier fallen.
+//
+// `owns` sagt, welche Teams das Konto besitzt. Die Oberfläche braucht das,
+// bevor sie den Löschknopf anbietet: `teams.owner` ist eine Pflichtrelation
+// ohne cascadeDelete, ein Löschen liefe sonst in einen Datenbankfehler statt
+// in einen Satz, den jemand lesen kann.
+
+routerAdd('GET', '/api/directory/users', (e) => {
+ const lib = require(`${__hooks}/directory_lib.js`)
+
+ const user = e.auth
+ if (!user || !lib.isSuperadmin(e.app, user)) {
+ throw new ForbiddenError('Nur für Superadmins.')
+ }
+
+ const teams = lib.allTeams(e.app)
+ const records = e.app.findAllRecords(lib.USERS)
+ const out = []
+
+ for (let i = 0; i < records.length; i++) {
+ const u = records[i]
+ const memberOf = []
+ const owns = []
+
+ for (let t = 0; t < teams.length; t++) {
+ const team = teams[t]
+ if (lib.ownerId(team) === u.id) {
+ owns.push(team.getString('name'))
+ }
+ if (lib.isMember(team, u.id)) {
+ memberOf.push(team.getString('name'))
+ }
+ }
+
+ out.push({
+ id: u.id,
+ name: u.getString('name'),
+ email: u.getString('email'),
+ verified: u.getBool('verified'),
+ superadmin: u.getBool('superadmin'),
+ created: u.getString('created'),
+ avatar: u.getString('avatar'),
+ teams: memberOf,
+ owns: owns,
+ })
+ }
+
+ return e.json(200, { users: out })
+}, $apis.requireAuth())
diff --git a/backend/pb_hooks/directory_lib.js b/backend/pb_hooks/directory_lib.js
new file mode 100644
index 0000000..43716e3
--- /dev/null
+++ b/backend/pb_hooks/directory_lib.js
@@ -0,0 +1,78 @@
+///
+
+// Gemeinsamer Code der Verzeichnis-Route.
+//
+// Eigene Datei aus demselben Grund wie bei den Einladungen: PocketBase führt
+// jeden Route-Handler in einer eigenen JS-Laufzeit aus. Was neben routerAdd
+// steht, sieht der Handler nicht — er holt sich seine Helfer per require().
+
+const USERS = 'users'
+const TEAMS = 'teams'
+
+/**
+ * Die IDs einer Relation als gewöhnliches Array.
+ *
+ * `getStringSlice` und nicht `get`: Es liefert bei Einzel- wie
+ * Mehrfachrelation dieselbe Form und kommt auch mit dem leeren Feld zurecht.
+ * `get` gäbe je nach Feld einen String oder eine Go-Slice zurück — zwei
+ * Formen, die der Aufrufer auseinanderhalten müsste.
+ */
+function idsOf(record, field) {
+ const raw = record.getStringSlice(field) || []
+ const out = []
+
+ for (let i = 0; i < raw.length; i++) {
+ if (raw[i]) {
+ out.push(raw[i])
+ }
+ }
+
+ return out
+}
+
+/** Wem gehört das Team? Leerer String, wenn der Owner fehlt. */
+function ownerId(team) {
+ const ids = idsOf(team, 'owner')
+ return ids.length > 0 ? ids[0] : ''
+}
+
+/** Ist die Person Mitglied (oder Owner) dieses Teams? */
+function isMember(team, userId) {
+ if (ownerId(team) === userId) {
+ return true
+ }
+
+ return idsOf(team, 'users').indexOf(userId) !== -1
+}
+
+/**
+ * Superadmin ist eine appweite Rolle auf dem eigenen users-Datensatz, kein
+ * PocketBase-Superuser. Gelesen wird sie frisch aus der Datenbank und nicht
+ * aus dem mitgeschickten Auth-Record: Der Token hält den Stand von der
+ * Anmeldung fest, eine entzogene Rolle wirkte sonst bis zum Ablauf nicht.
+ */
+function isSuperadmin(app, user) {
+ if (!user) {
+ return false
+ }
+
+ try {
+ return app.findRecordById(USERS, user.id).getBool('superadmin')
+ } catch (err) {
+ return false
+ }
+}
+
+/** Alle Teams — für die Zuordnung im Superadmin-Verzeichnis. */
+function allTeams(app) {
+ return app.findAllRecords(TEAMS)
+}
+
+module.exports = {
+ USERS: USERS,
+ idsOf: idsOf,
+ ownerId: ownerId,
+ isMember: isMember,
+ isSuperadmin: isSuperadmin,
+ allTeams: allTeams,
+}
diff --git a/backend/pb_hooks/mail_lib.js b/backend/pb_hooks/mail_lib.js
new file mode 100644
index 0000000..9c38f9e
--- /dev/null
+++ b/backend/pb_hooks/mail_lib.js
@@ -0,0 +1,112 @@
+///
+
+// Alles, was in dieser Anwendung eine Mail verschickt, geht hier durch.
+//
+// Eigene Datei, weil PocketBase jeden Route- und Event-Handler in einer
+// eigenen JS-Laufzeit ausführt: Was neben routerAdd steht, sieht der Handler
+// nicht. Er holt sich seine Helfer per require().
+//
+// Drei Dinge stehen hier, die sonst in jeder Mail neu erfunden würden:
+// woher der Name der App kommt, woher die Adresse des Frontends kommt, und
+// wie aus einer Vorlage samt Daten eine verschickte Mail wird.
+
+const DEFAULT_APP_NAME = 'Kurbeler'
+
+/**
+ * Name der Anwendung, wie er in Mails auftaucht.
+ *
+ * Über `APP_NAME` überschreibbar — eine Testinstanz soll in der Betreffzeile
+ * erkennbar sein, ohne dass jemand den Code anfasst.
+ */
+function appName() {
+ return $os.getenv('APP_NAME') || DEFAULT_APP_NAME
+}
+
+/**
+ * Adresse des Frontends, ohne Schrägstrich am Ende.
+ *
+ * Das ist NICHT `settings().meta.appURL`: Die zeigt auf PocketBase selbst,
+ * und ein Einladungslink dorthin führte auf die API statt auf die Seite, die
+ * das Konto anlegt.
+ *
+ * Und es ist ausdrücklich nicht der `Origin`-Header des Aufrufers. Der steht
+ * unter der Kontrolle dessen, der die Anfrage stellt — ein Link in einer
+ * Mail, den ein Fremder auf seinen eigenen Server zeigen lassen kann, ist
+ * eine Phishing-Vorlage mit unserem Absender darauf.
+ *
+ * Fehlt die Variable, gibt es keine Mail. Lieber ein Fehler, den die
+ * Teamleitung sieht, als ein Link ins Leere, den sie nie zu Gesicht bekommt.
+ */
+function appURL() {
+ const raw = ($os.getenv('APP_URL') || '').trim()
+ if (!raw) {
+ return ''
+ }
+
+ return raw.replace(/\/+$/, '')
+}
+
+/** Eine Vorlage aus pb_hooks/views/ mit Daten füllen. */
+function render(view, data) {
+ return $template.loadFiles(`${__hooks}/views/${view}`).render(data)
+}
+
+/**
+ * Absender aus den PocketBase-Einstellungen. Dort wird auch SMTP eingerichtet;
+ * beides gehört ins Admin-UI und nicht in den Code.
+ */
+function sender(app) {
+ const meta = app.settings().meta
+
+ return {
+ address: meta.senderAddress,
+ name: meta.senderName || appName(),
+ }
+}
+
+/**
+ * Mail verschicken. Wirft weiter, wenn der Versand scheitert — ob das den
+ * Aufrufer scheitern lässt, entscheidet er selbst: Eine Einladung ohne Mail
+ * ist wertlos und muss scheitern, eine Benachrichtigung darf im Stillen
+ * verloren gehen, statt das Speichern eines Events zu verhindern.
+ */
+function send(app, to, subject, html) {
+ const message = new MailerMessage({
+ from: sender(app),
+ to: [{ address: to.address, name: to.name || '' }],
+ subject: subject,
+ html: html,
+ })
+
+ app.newMailClient().send(message)
+}
+
+/** Vorlage rendern und verschicken — der übliche Weg. */
+function sendView(app, to, subject, view, data) {
+ send(app, to, subject, render(view, data))
+}
+
+/**
+ * Ein Datum, wie es in einer Mail stehen soll: „30.09.2026".
+ *
+ * PocketBase liefert „2026-09-30 10:00:00.000Z"; das direkt hineinzuschreiben
+ * sähe aus wie ein Datenbankfeld und nicht wie ein Satz.
+ */
+function formatDay(value) {
+ const day = (value || '').slice(0, 10)
+ if (day.length !== 10) {
+ return ''
+ }
+
+ const parts = day.split('-')
+ return `${parts[2]}.${parts[1]}.${parts[0]}`
+}
+
+module.exports = {
+ appName: appName,
+ appURL: appURL,
+ render: render,
+ send: send,
+ sendView: sendView,
+ formatDay: formatDay,
+}
diff --git a/backend/pb_hooks/notifications.pb.js b/backend/pb_hooks/notifications.pb.js
new file mode 100644
index 0000000..d130ec8
--- /dev/null
+++ b/backend/pb_hooks/notifications.pb.js
@@ -0,0 +1,326 @@
+///
+
+// Die Auslöser und der tägliche Versand.
+//
+// Alle Auslöser tun dasselbe: Sie legen einen Eintrag in `notifications` und
+// sind fertig. Keiner verschickt eine Mail. Das ist der Punkt — sonst löste
+// ein Kommentar, dem drei Antworten folgen, vier Mails aus, und ein Verein,
+// der sich die nicht leisten kann, schaltet nach drei Tagen alles ab.
+//
+// Einmal am Tag nimmt der Cron-Job unten alles Unversendete, macht je Konto
+// eine Mail daraus und hakt es ab.
+//
+// Alle Hooks hängen an `...AfterCreateSuccess` bzw. `...AfterUpdateSuccess`:
+// Erst wenn der Datensatz wirklich steht, gibt es etwas zu melden. Und keiner
+// darf werfen — ein Event anzulegen darf nicht daran scheitern, dass ein
+// Hinweis nicht geschrieben werden konnte. Die Bibliothek fängt deshalb
+// selbst ab; hier steht zusätzlich um jeden Handler ein try.
+
+// --- Neues Event im Team ----------------------------------------------------
+
+onRecordAfterCreateSuccess((e) => {
+ try {
+ const lib = require(`${__hooks}/notifications_lib.js`)
+
+ const name = e.record.getString('name') || e.record.getString('description') || 'Event'
+ const starts = (e.record.getString('starts') || '').slice(0, 10)
+
+ lib.queueForTeam(e.app, e.record.getString('team'), {
+ type: 'event_created',
+ actor: e.record.getString('created_by'),
+ title: name,
+ body: starts ? `Termin: ${starts.split('-').reverse().join('.')}` : 'Noch ohne Termin',
+ link: `/dashboard/events/${e.record.id}`,
+ })
+ } catch (err) {
+ console.log('[notifications] event_created:', err)
+ }
+
+ e.next()
+}, 'events')
+
+// --- Termin oder Ort eines Events verschoben --------------------------------
+//
+// Nur diese beiden Felder. Ein umbenanntes Event oder ein geänderter
+// Beschreibungstext ist keine Nachricht wert; ein verschobener Termin schon —
+// danach richtet sich, wer am Samstag wo steht.
+
+onRecordAfterUpdateSuccess((e) => {
+ try {
+ const lib = require(`${__hooks}/notifications_lib.js`)
+
+ const before = e.record.original()
+ const movedDate = before.getString('starts') !== e.record.getString('starts')
+ const movedPlace = before.getString('location') !== e.record.getString('location')
+
+ if (!movedDate && !movedPlace) {
+ e.next()
+ return
+ }
+
+ const name = e.record.getString('name') || e.record.getString('description') || 'Event'
+ const starts = (e.record.getString('starts') || '').slice(0, 10)
+ const parts = []
+ if (movedDate) {
+ parts.push(starts ? `neuer Termin ${starts.split('-').reverse().join('.')}` : 'Termin entfernt')
+ }
+ if (movedPlace) {
+ parts.push(`Ort: ${e.record.getString('location') || 'offen'}`)
+ }
+
+ lib.queueForTeam(e.app, e.record.getString('team'), {
+ type: 'event_changed',
+ title: name,
+ body: parts.join(', '),
+ link: `/dashboard/events/${e.record.id}`,
+ })
+ } catch (err) {
+ console.log('[notifications] event_changed:', err)
+ }
+
+ e.next()
+}, 'events')
+
+// --- Zu einem Event eingetragen ---------------------------------------------
+//
+// Das Gegenstück zu den Teammeldungen: Diese geht an genau einen, und zwar
+// den, um den es geht. Ein Fahrer ohne Konto bekommt nichts — es gibt keine
+// Adresse.
+
+onRecordAfterCreateSuccess((e) => {
+ try {
+ const lib = require(`${__hooks}/notifications_lib.js`)
+
+ const userId = lib.userOfRider(e.app, e.record.getString('rider'))
+ if (!userId) {
+ e.next()
+ return
+ }
+
+ const event = e.app.findRecordById('events', e.record.getString('event'))
+ const name = event.getString('name') || event.getString('description') || 'Event'
+ const starts = (event.getString('starts') || '').slice(0, 10)
+
+ lib.queueFor(e.app, userId, {
+ type: 'event_participation',
+ actor: e.record.getString('created_by'),
+ team: event.getString('team'),
+ title: `Du stehst bei „${name}" auf der Liste`,
+ body: starts ? `Termin: ${starts.split('-').reverse().join('.')}` : '',
+ link: `/dashboard/events/${event.id}`,
+ })
+ } catch (err) {
+ console.log('[notifications] event_participation:', err)
+ }
+
+ e.next()
+}, 'event_participants')
+
+// --- Neuer Kommentar an einem Trail -----------------------------------------
+
+onRecordAfterCreateSuccess((e) => {
+ try {
+ const lib = require(`${__hooks}/notifications_lib.js`)
+
+ const trail = e.app.findRecordById('trails', e.record.getString('trail'))
+ const author = lib.nameOf(e.app, e.record.getString('created_by'))
+ const text = e.record.getString('text') || ''
+
+ lib.queueForTeam(e.app, e.record.getString('team'), {
+ type: 'trail_comment',
+ actor: e.record.getString('created_by'),
+ title: `${author} zu „${trail.getString('name') || 'Trail'}"`,
+ // Gekürzt: Die Zusammenfassung ist eine Übersicht und nicht der
+ // Ort, an dem man Kommentare liest.
+ body: text.length > 140 ? text.slice(0, 140) + ' …' : text,
+ link: `/dashboard/trails/${trail.id}`,
+ })
+ } catch (err) {
+ console.log('[notifications] trail_comment:', err)
+ }
+
+ e.next()
+}, 'trail_comments')
+
+// --- Neue Meldung an der Strecke --------------------------------------------
+
+onRecordAfterCreateSuccess((e) => {
+ try {
+ const lib = require(`${__hooks}/notifications_lib.js`)
+
+ // Erledigte Meldungen entstehen nicht als Meldung — sie werden es.
+ if (e.record.getBool('resolved')) {
+ e.next()
+ return
+ }
+
+ const trail = e.app.findRecordById('trails', e.record.getString('trail'))
+ const author = lib.nameOf(e.app, e.record.getString('created_by'))
+
+ lib.queueForTeam(e.app, e.record.getString('team'), {
+ type: 'trail_marker',
+ actor: e.record.getString('created_by'),
+ title: `${e.record.getString('label') || 'Meldung'} an „${trail.getString('name') || 'Trail'}"`,
+ body: `${author}: ${e.record.getString('note') || 'ohne Notiz'}`,
+ link: `/dashboard/trails/${trail.id}`,
+ })
+ } catch (err) {
+ console.log('[notifications] trail_marker:', err)
+ }
+
+ e.next()
+}, 'trail_markers')
+
+// --- Trail gesperrt oder wieder frei ----------------------------------------
+//
+// Die eine Trail-Meldung, die standardmäßig an ist: Ob eine Strecke gesperrt
+// ist, entscheidet, ob jemand am Sonntag hinfährt.
+
+onRecordAfterUpdateSuccess((e) => {
+ try {
+ const lib = require(`${__hooks}/notifications_lib.js`)
+
+ const before = e.record.original()
+ const status = e.record.getString('status')
+ if (before.getString('status') === status) {
+ e.next()
+ return
+ }
+
+ lib.queueForTeam(e.app, e.record.getString('team'), {
+ type: 'trail_status',
+ actor: e.record.getString('status_by'),
+ title: `„${e.record.getString('name') || 'Trail'}" ist jetzt: ${status}`,
+ body: `Geändert von ${lib.nameOf(e.app, e.record.getString('status_by'))}`,
+ link: `/dashboard/trails/${e.record.id}`,
+ })
+ } catch (err) {
+ console.log('[notifications] trail_status:', err)
+ }
+
+ e.next()
+}, 'trails')
+
+// --- Neuer Fahrer im Kader --------------------------------------------------
+
+onRecordAfterCreateSuccess((e) => {
+ try {
+ const lib = require(`${__hooks}/notifications_lib.js`)
+
+ lib.queueForTeam(e.app, e.record.getString('team'), {
+ type: 'team_member',
+ title: `${e.record.getString('name') || 'Jemand'} ist neu im Kader`,
+ body: '',
+ link: '/dashboard/team',
+ })
+ } catch (err) {
+ console.log('[notifications] team_member:', err)
+ }
+
+ e.next()
+}, 'riders')
+
+// --- Der tägliche Versand ---------------------------------------------------
+//
+// Einmal am Morgen: alles Unversendete einsammeln, je Konto eine Mail, fertig.
+//
+// Warum 6 Uhr? Weil die Mail vor dem Frühstück da sein soll und nicht
+// mittendrin. Die Zeitzone ist die des Containers (TZ), nicht die des
+// Empfängers — für einen Verein aus einer Gegend ist das dasselbe.
+//
+// Der Job ist absichtlich stumpf: Er fragt nach allem ohne `sent`, gruppiert
+// nach Konto und hakt nur ab, was auch wirklich rausging. Scheitert der
+// Versand an ein Konto, bleiben dessen Einträge stehen und sind morgen wieder
+// dabei — eine verspätete Zusammenfassung ist besser als eine verlorene.
+
+cronAdd('notifications_digest', '0 6 * * *', () => {
+ const lib = require(`${__hooks}/notifications_lib.js`)
+ const mail = require(`${__hooks}/mail_lib.js`)
+
+ const base = mail.appURL()
+ if (!base) {
+ console.log('[notifications] APP_URL fehlt — keine Zusammenfassung verschickt.')
+ return
+ }
+
+ let pending
+ try {
+ // `sent = ""` trifft in PocketBase auch NULL — leer und nicht
+ // gesetzt sind im Filter dasselbe.
+ pending = $app.findRecordsByFilter(lib.QUEUE, 'sent = ""', '+created', 2000, 0)
+ } catch (err) {
+ console.log('[notifications] Warteschlange nicht lesbar:', err)
+ return
+ }
+
+ if (!pending || pending.length === 0) {
+ return
+ }
+
+ // Nach Konto bündeln. Ein Konto, eine Mail — darum geht die ganze Übung.
+ const byUser = {}
+ for (let i = 0; i < pending.length; i++) {
+ const uid = pending[i].getString('user')
+ if (!byUser[uid]) {
+ byUser[uid] = []
+ }
+ byUser[uid].push(pending[i])
+ }
+
+ for (const uid in byUser) {
+ const items = byUser[uid]
+
+ let user
+ try {
+ user = $app.findRecordById('users', uid)
+ } catch (err) {
+ // Konto gelöscht, Einträge noch da. Abhaken, sonst laufen sie
+ // jede Nacht aufs Neue in denselben Fehler.
+ lib.markSent($app, items)
+ continue
+ }
+
+ const address = user.getString('email')
+ if (!address) {
+ lib.markSent($app, items)
+ continue
+ }
+
+ // Nach Art gruppieren, damit die Mail Abschnitte hat und nicht eine
+ // Liste aus zwanzig gleich aussehenden Zeilen.
+ const groups = []
+ const seen = {}
+ for (let i = 0; i < items.length; i++) {
+ const type = items[i].getString('type')
+ if (!seen[type]) {
+ seen[type] = { Label: lib.LABELS[type] || type, Items: [] }
+ groups.push(seen[type])
+ }
+ seen[type].Items.push({
+ Title: items[i].getString('title'),
+ Body: items[i].getString('body'),
+ Link: items[i].getString('link') ? base + items[i].getString('link') : '',
+ })
+ }
+
+ try {
+ mail.sendView(
+ $app,
+ { address: address, name: user.getString('name') },
+ `${mail.appName()}: ${items.length} Neuigkeit${items.length === 1 ? '' : 'en'}`,
+ 'digest.html',
+ {
+ App: mail.appName(),
+ Name: user.getString('name') || 'Radlfahrer',
+ Count: items.length,
+ Groups: groups,
+ SettingsLink: `${base}/dashboard/settings/notifications`,
+ },
+ )
+ lib.markSent($app, items)
+ } catch (err) {
+ // Stehen lassen. Morgen früh noch einmal.
+ console.log('[notifications] Versand an', address, 'gescheitert:', err)
+ }
+ }
+})
diff --git a/backend/pb_hooks/notifications_lib.js b/backend/pb_hooks/notifications_lib.js
new file mode 100644
index 0000000..e7de34d
--- /dev/null
+++ b/backend/pb_hooks/notifications_lib.js
@@ -0,0 +1,221 @@
+///
+
+// Wer erfährt wovon — und was davon in die Warteschlange kommt.
+//
+// Eigene Datei, weil PocketBase jeden Hook in einer eigenen JS-Laufzeit
+// ausführt: Was neben onRecordAfterCreateSuccess steht, sieht der nächste
+// Handler nicht. Er holt sich seine Helfer per require().
+
+const PREFS = 'notification_prefs'
+const QUEUE = 'notifications'
+
+/**
+ * Was gilt, solange niemand etwas eingestellt hat.
+ *
+ * Die Linie ist bewusst gezogen: An heißt „betrifft mich", aus heißt „ist
+ * viel". Wer zu einem Event eingetragen wird, will das wissen — das passiert
+ * ihm. Jeder Kommentar an jedem Trail des Teams passiert dagegen ständig und
+ * meistens anderen; wer das will, schaltet es ein.
+ *
+ * Diese Voreinstellungen gelten auch dann, wenn ein Konto nie in die
+ * Einstellungen geschaut hat: Ein Datensatz in notification_prefs entsteht
+ * erst beim ersten Speichern.
+ */
+const DEFAULTS = {
+ enabled: true,
+
+ event_created: true, // neues Event im Team
+ event_participation: true, // du wurdest eingetragen, dein Status änderte sich
+ event_changed: true, // Termin oder Ort eines Events verschoben
+ trail_comment: false, // jeder Kommentar an jedem Trail
+ trail_marker: false, // jede neue Meldung an der Strecke
+ trail_status: true, // ein Trail wurde gesperrt oder wieder frei
+ team_member: false, // jemand ist ins Team gekommen
+}
+
+/** Überschriften der Arten, wie sie in der Zusammenfassung stehen. */
+const LABELS = {
+ event_created: 'Neue Events',
+ event_participation: 'Deine Teilnahmen',
+ event_changed: 'Geänderte Events',
+ trail_comment: 'Kommentare',
+ trail_marker: 'Meldungen an der Strecke',
+ trail_status: 'Trail-Status',
+ team_member: 'Neu im Team',
+}
+
+/**
+ * Einstellungen eines Kontos, ergänzt um alles, was nicht drinsteht.
+ *
+ * Ein fehlender Datensatz ist kein Sonderfall, sondern der Normalfall bei
+ * jedem, der die Einstellungen nie geöffnet hat — er bekommt DEFAULTS.
+ */
+function prefsOf(app, userId) {
+ let record = null
+ try {
+ record = app.findFirstRecordByFilter(PREFS, 'user = {:user}', { user: userId })
+ } catch (err) {
+ record = null
+ }
+
+ if (!record) {
+ return DEFAULTS
+ }
+
+ const out = { enabled: record.getBool('enabled') }
+ for (const key in DEFAULTS) {
+ if (key !== 'enabled') {
+ out[key] = record.getBool(key)
+ }
+ }
+
+ return out
+}
+
+/** Will dieses Konto diese Art von Meldung? */
+function wants(app, userId, type) {
+ const prefs = prefsOf(app, userId)
+ return !!prefs.enabled && !!prefs[type]
+}
+
+/**
+ * Die IDs einer Relation als gewöhnliches Array — wie im Verzeichnis-Modul.
+ * `get()` gäbe je nach Feld einen String oder eine Go-Slice zurück.
+ */
+function idsOf(record, field) {
+ const raw = record.getStringSlice(field) || []
+ const out = []
+
+ for (let i = 0; i < raw.length; i++) {
+ if (raw[i]) {
+ out.push(raw[i])
+ }
+ }
+
+ return out
+}
+
+/** Alle Konten eines Teams: Mitglieder und der Owner. */
+function teamMembers(app, teamId) {
+ let team
+ try {
+ team = app.findRecordById('teams', teamId)
+ } catch (err) {
+ return []
+ }
+
+ const ids = idsOf(team, 'users')
+ const owner = idsOf(team, 'owner')[0]
+ if (owner && ids.indexOf(owner) === -1) {
+ ids.push(owner)
+ }
+
+ return ids
+}
+
+/**
+ * Einen Eintrag in die Warteschlange legen — für ein Konto.
+ *
+ * Wirft nie. Ein Auslöser hängt immer an einem Speichervorgang, den jemand
+ * gerade ausgelöst hat: Ein neues Event darf nicht daran scheitern, dass ein
+ * Hinweis nicht geschrieben werden konnte.
+ */
+function queueFor(app, userId, entry) {
+ if (!userId || !entry || !entry.type) {
+ return
+ }
+
+ // Der Auslöser selbst bekommt keine Meldung über sein eigenes Tun. Wer
+ // ein Event anlegt, weiß, dass es eins gibt.
+ if (entry.actor && entry.actor === userId) {
+ return
+ }
+
+ try {
+ if (!wants(app, userId, entry.type)) {
+ return
+ }
+
+ const record = new Record(app.findCollectionByNameOrId(QUEUE))
+ record.set('user', userId)
+ record.set('type', entry.type)
+ record.set('title', entry.title || '')
+ record.set('body', entry.body || '')
+ record.set('link', entry.link || '')
+ if (entry.team) {
+ record.set('team', entry.team)
+ }
+ app.save(record)
+ } catch (err) {
+ console.log('[notifications] konnte nicht einreihen:', err)
+ }
+}
+
+/** Denselben Eintrag für ein ganzes Team einreihen. */
+function queueForTeam(app, teamId, entry) {
+ const members = teamMembers(app, teamId)
+ const withTeam = Object.assign({}, entry, { team: teamId })
+
+ for (let i = 0; i < members.length; i++) {
+ queueFor(app, members[i], withTeam)
+ }
+}
+
+/**
+ * Das Konto hinter einem Fahrer — oder leer, wenn er keins hat.
+ * Ohne Konto gibt es keine Adresse und damit nichts zu benachrichtigen.
+ */
+function userOfRider(app, riderId) {
+ try {
+ return app.findRecordById('riders', riderId).getString('user')
+ } catch (err) {
+ return ''
+ }
+}
+
+/** Anzeigename eines Kontos für Sätze wie „X hat kommentiert". */
+function nameOf(app, userId) {
+ if (!userId) {
+ return 'Jemand'
+ }
+
+ try {
+ const u = app.findRecordById('users', userId)
+ return u.getString('name') || u.getString('email') || 'Jemand'
+ } catch (err) {
+ return 'Jemand'
+ }
+}
+
+/**
+ * Einträge als versendet abhaken. Einzeln, damit ein hängender Datensatz
+ * nicht die ganze Gruppe mitreißt und morgen alles doppelt rausgeht.
+ */
+function markSent(app, items) {
+ const now = new Date().toISOString().replace('T', ' ')
+
+ for (let i = 0; i < items.length; i++) {
+ try {
+ items[i].set('sent', now)
+ app.save(items[i])
+ } catch (err) {
+ console.log('[notifications] konnte nicht abhaken:', err)
+ }
+ }
+}
+
+module.exports = {
+ PREFS: PREFS,
+ QUEUE: QUEUE,
+ DEFAULTS: DEFAULTS,
+ LABELS: LABELS,
+ prefsOf: prefsOf,
+ wants: wants,
+ idsOf: idsOf,
+ teamMembers: teamMembers,
+ queueFor: queueFor,
+ queueForTeam: queueForTeam,
+ userOfRider: userOfRider,
+ nameOf: nameOf,
+ markSent: markSent,
+}
diff --git a/backend/pb_hooks/team_invites.pb.js b/backend/pb_hooks/team_invites.pb.js
index 6d30f32..fa6b9df 100644
--- a/backend/pb_hooks/team_invites.pb.js
+++ b/backend/pb_hooks/team_invites.pb.js
@@ -38,7 +38,10 @@ routerAdd('GET', '/api/team-invites/{token}', (e) => {
throw new NotFoundError('Diese Einladung gibt es nicht mehr.')
}
- return e.json(200, { team: lib.teamPayload(e.app, invite) })
+ return e.json(200, {
+ team: lib.teamPayload(e.app, invite),
+ recipient: lib.recipientPayload(e.app, invite),
+ })
})
// --- Konto anlegen und beitreten -------------------------------------------
@@ -80,6 +83,17 @@ routerAdd('POST', '/api/team-invites/{token}/accept', (e) => {
throw new BadRequestError('Diese Einladung gilt nicht mehr.')
}
+ // Eine persönliche Einladung ging an genau eine Adresse. Sie hier
+ // erneut zu prüfen ist der Punkt, an dem es zählt: Das Feld auf der
+ // Seite ist gesperrt, aber ein gesperrtes Feld ist eine Bitte und
+ // keine Bedingung — die Anfrage kann jeder von Hand stellen.
+ const invitedEmail = invite.getString('email')
+ if (invitedEmail && invitedEmail.toLowerCase() !== email.toLowerCase()) {
+ throw new BadRequestError(
+ 'Diese Einladung gilt für eine andere E-Mail-Adresse.',
+ )
+ }
+
// Ohne die Vorabprüfung meldete PocketBase nur „Value must be unique".
// Der Hinweis auf den zweiten Weg ist hier mehr wert als die
// Fehlermeldung.
@@ -143,3 +157,172 @@ routerAdd('POST', '/api/team-invites/{token}/join', (e) => {
return e.json(200, { ok: true, team: result.teamId })
}, $apis.requireAuth())
+
+// --- Persönliche Einladung: Konto per Mail statt Passwort per Zuruf --------
+//
+// Der Ersatz für „Konto anlegen" im Kader. Dort tippte die Teamleitung eine
+// Adresse und ein Passwort ein und musste dieses Passwort anschließend
+// irgendwie zum Fahrer bringen — über einen Kanal, den niemand geplant hat.
+// Sie kannte es außerdem, und der Fahrer hatte eins, das er sich nicht
+// ausgesucht hat.
+//
+// Jetzt geht eine Mail raus, und das Passwort entsteht erst beim Empfänger.
+//
+// Warum eine Route und kein Collection-Create mit anschließendem Versand?
+// Weil beides zusammengehört: Eine Einladung, die niemanden erreicht, ist
+// keine — sie stünde nur als Datensatz herum und die Teamleitung wartete auf
+// jemanden, der nie eine Mail bekommen hat. Hier scheitert beides zusammen
+// oder gar nichts.
+//
+// POST /api/team-invites/send { rider, email, days? }
+
+routerAdd('POST', '/api/team-invites/send', (e) => {
+ const lib = require(`${__hooks}/team_invites_lib.js`)
+ const mail = require(`${__hooks}/mail_lib.js`)
+
+ const user = e.auth
+ if (!user) {
+ throw new UnauthorizedError()
+ }
+
+ const base = mail.appURL()
+ if (!base) {
+ // Ohne Adresse des Frontends gibt es keinen Link, und ohne Link ist
+ // die Mail wertlos. Das hier ist ein Konfigurationsfehler und gehört
+ // sichtbar gemeldet, nicht stillschweigend umschifft.
+ throw new InternalServerError(
+ 'APP_URL ist nicht gesetzt — ohne sie lässt sich kein Einladungslink bilden.',
+ )
+ }
+
+ const data = new DynamicModel({ rider: '', email: '', days: 14 })
+ e.bindBody(data)
+
+ const email = (data.email || '').trim()
+ if (email.indexOf('@') < 1) {
+ throw new BadRequestError('Bitte eine gültige E-Mail-Adresse angeben.')
+ }
+
+ let rider
+ try {
+ rider = e.app.findRecordById('riders', (data.rider || '').trim())
+ } catch (err) {
+ throw new NotFoundError('Diesen Fahrer gibt es nicht.')
+ }
+
+ if (rider.getString('user')) {
+ throw new BadRequestError('Dieser Fahrer hat schon ein Konto.')
+ }
+
+ const team = e.app.findRecordById('teams', rider.getString('team'))
+
+ // Einladen darf die Teamleitung — dieselbe Bedingung wie in der
+ // createRule von team_invites, hier von Hand geprüft, weil die Route an
+ // den Collection-Regeln vorbeiläuft.
+ let allowed = false
+ try {
+ e.app.findFirstRecordByFilter(
+ 'teams',
+ 'id = {:team} && (owner.id ?= {:user} || admins.id ?= {:user})',
+ { team: team.id, user: user.id },
+ )
+ allowed = true
+ } catch (err) {
+ allowed = false
+ }
+
+ if (!allowed) {
+ throw new ForbiddenError('Einladen darf nur die Teamleitung.')
+ }
+
+ // Ohne die Vorabprüfung liefe der Eingeladene erst beim Anlegen des
+ // Kontos in „Value must be unique" — nach der Mail, nach dem Klick, am
+ // Ende eines Weges, den er umsonst gegangen wäre.
+ let existing = null
+ try {
+ existing = e.app.findAuthRecordByEmail('users', email)
+ } catch (err) {
+ existing = null
+ }
+
+ if (existing) {
+ throw new BadRequestError(
+ 'Zu dieser Adresse gibt es schon ein Konto. Es kann über einen ' +
+ 'Einladungslink ins Team kommen.',
+ )
+ }
+
+ const days = parseInt(data.days, 10)
+ let expires = ''
+ if (days > 0) {
+ const until = new Date(Date.now() + days * 24 * 60 * 60 * 1000)
+ expires = until.toISOString().replace('T', ' ').replace('Z', 'Z')
+ }
+
+ let invite
+ e.app.runInTransaction((txApp) => {
+ // Eine zweite Einladung an denselben Fahrer ersetzt die erste. Sonst
+ // stapelten sich bei jedem „nochmal senden" Datensätze, von denen
+ // jeder für sich gültig bliebe — und ein zurückgezogener wäre keiner.
+ try {
+ const old = txApp.findFirstRecordByFilter(
+ 'team_invites',
+ 'rider = {:rider}',
+ { rider: rider.id },
+ )
+ txApp.delete(old)
+ } catch (err) {
+ // Keine vorhandene — der Normalfall.
+ }
+
+ invite = new Record(txApp.findCollectionByNameOrId('team_invites'))
+ invite.set('team', team.id)
+ invite.set('rider', rider.id)
+ invite.set('email', email)
+ invite.set('created_by', user.id)
+ invite.set('max_uses', 1)
+ invite.set('uses', 0)
+ if (expires) {
+ invite.set('expires', expires)
+ }
+ txApp.save(invite)
+ })
+
+ const name = rider.getString('name') || 'Radlfahrer'
+ const teamName = team.getString('name') || mail.appName()
+
+ // Der Versand steht bewusst NACH der Transaktion: Ein SMTP-Aufruf gehört
+ // nicht in eine offene Datenbanktransaktion, er dauert und kann hängen.
+ // Scheitert er, verschwindet die Einladung wieder — eine, von der niemand
+ // weiß, wäre schlimmer als keine.
+ try {
+ mail.sendView(
+ e.app,
+ { address: email, name: name },
+ `${teamName} lädt dich zu ${mail.appName()} ein`,
+ 'invite.html',
+ {
+ App: mail.appName(),
+ Team: teamName,
+ Name: name,
+ Link: `${base}/invite/${invite.getString('token')}`,
+ Expires: mail.formatDay(expires),
+ },
+ )
+ } catch (err) {
+ try {
+ e.app.delete(invite)
+ } catch (cleanupErr) {
+ // Aufräumen ist der Versuch wert, aber nicht der Fehler, den der
+ // Aufrufer sehen soll.
+ }
+ throw new InternalServerError(
+ 'Die Einladung konnte nicht verschickt werden. Stimmen die SMTP-Einstellungen?',
+ )
+ }
+
+ invite.set('sent', new Date().toISOString().replace('T', ' '))
+ e.app.save(invite)
+
+ return e.json(200, { ok: true, email: email, expires: expires })
+}, $apis.requireAuth())
diff --git a/backend/pb_hooks/team_invites_lib.js b/backend/pb_hooks/team_invites_lib.js
index ee9e104..d5a2145 100644
--- a/backend/pb_hooks/team_invites_lib.js
+++ b/backend/pb_hooks/team_invites_lib.js
@@ -56,6 +56,35 @@ function teamPayload(app, invite) {
}
}
+/**
+ * Wem gilt die Einladung? Beim Einladungslink: niemandem Bestimmten.
+ *
+ * Eine persönliche Einladung nennt Adresse und Namen. Die Seite füllt beides
+ * vor und lässt die Adresse nicht ändern — sonst legte jemand mit fremdem
+ * Link ein Konto auf eine Adresse an, an die nie eine Mail ging.
+ *
+ * Das ist keine Preisgabe: Wer den Token hat, hat die Mail bekommen, in der
+ * die Adresse ohnehin steht.
+ */
+function recipientPayload(app, invite) {
+ const email = invite.getString('email')
+ if (!email) {
+ return { email: '', name: '', personal: false }
+ }
+
+ let name = ''
+ const riderId = invite.getString('rider')
+ if (riderId) {
+ try {
+ name = app.findRecordById('riders', riderId).getString('name')
+ } catch (err) {
+ name = ''
+ }
+ }
+
+ return { email: email, name: name, personal: true }
+}
+
/**
* Trägt einen bestehenden Nutzer ins Team ein und legt ihm einen Fahrer an,
* falls er dort noch keinen hat. Zählt die Einladung hoch.
@@ -100,6 +129,29 @@ function joinTeam(txApp, invite, user, name) {
rider = null
}
+ // Eine persönliche Einladung nennt den Fahrer, um den es geht: Sie ging
+ // an jemanden, der längst im Kader steht und nur noch kein Konto hat.
+ // Ohne diesen Zweig entstünde daneben ein zweiter Fahrer gleichen Namens
+ // — dieselbe Person doppelt, einmal mit Zeiten und einmal mit Login.
+ if (!rider) {
+ const invitedRiderId = invite.getString('rider')
+ if (invitedRiderId) {
+ try {
+ const invited = txApp.findRecordById('riders', invitedRiderId)
+ // Nur, wenn er noch frei ist. Hat er inzwischen ein Konto,
+ // gehört er jemand anderem; dann ist ein eigener Fahrer für
+ // den Eingeladenen richtig.
+ if (invited.getString('team') === team.id && !invited.getString('user')) {
+ invited.set('user', user.id)
+ txApp.save(invited)
+ rider = invited
+ }
+ } catch (err) {
+ rider = null
+ }
+ }
+ }
+
if (!rider) {
rider = new Record(txApp.findCollectionByNameOrId('riders'))
rider.set('team', team.id)
@@ -119,4 +171,4 @@ function joinTeam(txApp, invite, user, name) {
return { teamId: team.id, riderId: rider.id, joined: !isMember }
}
-module.exports = { findValidInvite, teamPayload, joinTeam }
+module.exports = { findValidInvite, teamPayload, recipientPayload, joinTeam }
diff --git a/backend/pb_hooks/views/digest.html b/backend/pb_hooks/views/digest.html
new file mode 100644
index 0000000..170fd5e
--- /dev/null
+++ b/backend/pb_hooks/views/digest.html
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+ {{.App}} — Zusammenfassung
+
+
+
+ {{.Count}} Neuigkeiten aus deinen Teams.
+
+
+
+
+
+
+
+
+
+ {{.App}}
+
+
+ Was seit gestern passiert ist
+
+
+ Hallo {{.Name}} — {{.Count}} Neuigkeit{{if ne .Count 1}}en{{end}}.
+
diff --git a/frontend/src/lib/components/ui/table/table-header.svelte b/frontend/src/lib/components/ui/table/table-header.svelte
index 72c02fc..d45b147 100644
--- a/frontend/src/lib/components/ui/table/table-header.svelte
+++ b/frontend/src/lib/components/ui/table/table-header.svelte
@@ -13,7 +13,7 @@
{@render children?.()}
diff --git a/frontend/src/lib/stores/notificationPrefs.svelte.ts b/frontend/src/lib/stores/notificationPrefs.svelte.ts
new file mode 100644
index 0000000..5064e18
--- /dev/null
+++ b/frontend/src/lib/stores/notificationPrefs.svelte.ts
@@ -0,0 +1,182 @@
+import { api, auth } from './pocketbase.svelte'
+import type { NotificationPrefsResponse } from '$lib/types'
+
+/**
+ * Was jemand per Mail erfahren will.
+ *
+ * Kein Context-Store wie die Collection-Stores: Diese Einstellungen gehören
+ * dem Konto und nicht dem aktiven Team, es gibt sie genau einmal je
+ * Anmeldung. Ein Modul-Singleton ist dafür die ehrlichere Form.
+ *
+ * Ein Datensatz entsteht erst beim ersten Speichern. Bis dahin gilt
+ * `DEFAULTS` — dieselbe Linie wie serverseitig in
+ * backend/pb_hooks/notifications_lib.js: An heißt „betrifft mich", aus heißt
+ * „ist viel". Beide Listen müssen zusammenpassen; stünde hier etwas anderes,
+ * zeigte die Oberfläche einen Schalter an, der nichts mit dem zu tun hat, was
+ * tatsächlich verschickt wird.
+ */
+
+export type NotificationType =
+ | 'event_created'
+ | 'event_participation'
+ | 'event_changed'
+ | 'trail_comment'
+ | 'trail_marker'
+ | 'trail_status'
+ | 'team_member'
+
+export type NotificationPrefs = Record & { enabled: boolean }
+
+export const DEFAULTS: NotificationPrefs = {
+ enabled: true,
+
+ event_created: true,
+ event_participation: true,
+ event_changed: true,
+ trail_comment: false,
+ trail_marker: false,
+ trail_status: true,
+ team_member: false,
+}
+
+/** Reihenfolge, Beschriftung und Begründung — was die Oberfläche anzeigt. */
+export const NOTIFICATION_GROUPS: {
+ title: string
+ items: { key: NotificationType; label: string; hint: string }[]
+}[] = [
+ {
+ title: 'Events',
+ items: [
+ {
+ key: 'event_created',
+ label: 'Neues Event im Team',
+ hint: 'Sobald jemand einen Termin anlegt.',
+ },
+ {
+ key: 'event_participation',
+ label: 'Du stehst auf der Teilnehmerliste',
+ hint: 'Wenn dich jemand zu einem Event einträgt.',
+ },
+ {
+ key: 'event_changed',
+ label: 'Termin oder Ort verschoben',
+ hint: 'Nur bei Datum und Ort — nicht bei jeder Textänderung.',
+ },
+ ],
+ },
+ {
+ title: 'Trails',
+ items: [
+ {
+ key: 'trail_status',
+ label: 'Ein Trail wird gesperrt oder wieder frei',
+ hint: 'Danach richtet sich, ob die Ausfahrt am Sonntag stattfindet.',
+ },
+ {
+ key: 'trail_marker',
+ label: 'Neue Meldung an der Strecke',
+ hint: 'Umgestürzter Baum, kaputte Anlieger, jede neue Markierung.',
+ },
+ {
+ key: 'trail_comment',
+ label: 'Neuer Kommentar',
+ hint: 'Jeder Kommentar an jedem Trail des Teams. Kann viel werden.',
+ },
+ ],
+ },
+ {
+ title: 'Team',
+ items: [
+ {
+ key: 'team_member',
+ label: 'Jemand ist neu im Kader',
+ hint: 'Bei jedem neu angelegten Fahrer.',
+ },
+ ],
+ },
+]
+
+const COLLECTION = 'notification_prefs'
+
+export class NotificationPrefsStore {
+ /** Der Datensatz, sofern es schon einen gibt. */
+ record = $state(null)
+ values = $state({ ...DEFAULTS })
+ loading = $state(false)
+ saving = $state(false)
+ error = $state(null)
+
+ async load() {
+ const uid = auth.user?.id
+ if (!uid) return
+
+ this.loading = true
+ this.error = null
+ try {
+ const list = await api.collection(COLLECTION).getFullList({
+ filter: `user = "${uid}"`,
+ requestKey: null,
+ })
+
+ const found = (list[0] as NotificationPrefsResponse) ?? null
+ this.record = found
+
+ // Ohne Datensatz bleiben die Voreinstellungen stehen. Sie sind
+ // keine Behelfsanzeige, sondern genau das, was der Server tut.
+ if (found) {
+ const next = { ...DEFAULTS }
+ for (const key of Object.keys(DEFAULTS) as (keyof NotificationPrefs)[]) {
+ next[key] = !!found[key as keyof NotificationPrefsResponse]
+ }
+ this.values = next
+ }
+ } catch (e: any) {
+ this.error = e.message ?? 'Die Einstellungen konnten nicht geladen werden.'
+ } finally {
+ this.loading = false
+ }
+ }
+
+ /**
+ * Einen Schalter umlegen und sofort speichern.
+ *
+ * Kein Speichern-Knopf: Es sind Schalter, sie gehören nur dem Kontoinhaber
+ * und jeder für sich ergibt sofort Sinn — genau wie Name und Bild im
+ * Profil. Ein Knopf, der fast immer nichts zu tun hat, ist eine Frage an
+ * jemanden, der längst geantwortet hat.
+ */
+ async set(key: keyof NotificationPrefs, value: boolean) {
+ const uid = auth.user?.id
+ if (!uid || this.saving) return
+
+ const previous = this.values[key]
+ this.values = { ...this.values, [key]: value }
+
+ this.saving = true
+ this.error = null
+ try {
+ if (this.record) {
+ this.record = (await api
+ .collection(COLLECTION)
+ .update(this.record.id, { [key]: value })) as NotificationPrefsResponse
+ } else {
+ // Der erste Griff an die Schalter legt den Datensatz an — mit
+ // allen Werten, nicht nur dem geänderten. Sonst stünden die
+ // übrigen auf `false` statt auf ihrer Voreinstellung, und
+ // wer einen Schalter ausmacht, hätte nebenbei alle anderen
+ // mit ausgemacht.
+ this.record = (await api.collection(COLLECTION).create({
+ ...this.values,
+ user: uid,
+ })) as NotificationPrefsResponse
+ }
+ } catch (e: any) {
+ this.values = { ...this.values, [key]: previous }
+ this.error = e.message ?? 'Das konnte nicht gespeichert werden.'
+ } finally {
+ this.saving = false
+ }
+ }
+}
+
+export const notificationPrefs = new NotificationPrefsStore()
diff --git a/frontend/src/lib/stores/riders.svelte.ts b/frontend/src/lib/stores/riders.svelte.ts
index 714b22b..d5776bb 100644
--- a/frontend/src/lib/stores/riders.svelte.ts
+++ b/frontend/src/lib/stores/riders.svelte.ts
@@ -1,6 +1,5 @@
import { getContext, setContext } from 'svelte'
import { api } from './pocketbase.svelte'
-import { app } from './app.svelte'
import { getTeamContext } from './teams.svelte'
import type { RidersResponse, UsersResponse } from '$lib/types'
@@ -92,62 +91,22 @@ export class RiderStore {
return await api.collection('riders').update(id, data)
}
- /**
- * Fahrer löschen — und mit ihm die Mitgliedschaft seines Kontos.
- *
- * Ohne den zweiten Schritt riss jedes Löschen ein Loch: Der Fahrer war
- * fort, sein Konto blieb in `teams.users` und stand danach als Zeile ohne
- * Fahrer im Kader. Aus so einer Zeile führte kein Weg zurück — ein Konto
- * nachträglich an einen Fahrer zu hängen gibt es bewusst nicht, weil
- * dieselbe Person sonst zweimal in der Liste stehen könnte. Der Eintrag
- * war damit unreparierbar.
- *
- * Das Konto selbst bleibt bestehen: Löschen darf es allein sein Inhaber
- * (`users.deleteRule = id = @request.auth.id`), und über einen neuen
- * Einladungslink kommt es zurück ins Team.
- *
- * Der Owner ist ausgenommen. Ihn aus `teams.users` zu nehmen, nähme ihm
- * die Sicht auf sein eigenes Team — die listRule der meisten Collections
- * hängt an `team.users`, nicht an `team.owner`.
- */
- remove(record: RidersResponse) {
- const team = this.teams.records.find((t) => t.id === record.team)
- const userId = record.user || null
- const dropsMembership = !!userId && !!team && userId !== team.owner
-
- const text = dropsMembership
- ? `Möchten Sie „${riderName(record)}" wirklich löschen? Alle Teilnahmen und Zeiten dieses Fahrers werden ebenfalls gelöscht. ` +
- `Das zugehörige Konto verliert seine Mitgliedschaft im Team — gelöscht wird es nicht, es kann über einen Einladungslink zurückkommen.`
- : `Möchten Sie „${riderName(record)}" wirklich löschen? Alle Teilnahmen und Zeiten dieses Fahrers werden ebenfalls gelöscht.`
-
- return new Promise((resolve, reject) => {
- app.confirm.request({
- title: 'Fahrer löschen?',
- text,
- yes: async () => {
- try {
- // Erst die Mitgliedschaft, dann der Fahrer. Andersherum
- // stünde bei einem Fehler im zweiten Schritt genau das
- // verwaiste Konto da, das hier verhindert werden soll.
- if (dropsMembership) {
- await this.teams.removeMember(team!.id, userId!)
- }
-
- await api.collection('riders').delete(record.id)
- app.confirm.close()
- resolve()
- } catch (e) {
- app.confirm.close()
- reject(e)
- }
- },
- no: () => {
- app.confirm.close()
- resolve()
- },
- })
- })
- }
+ // Ein „Fahrer löschen" gibt es nicht mehr.
+ //
+ // Im Kader stand bis hierher ein Papierkorb neben „Aus dem Team
+ // entfernen". Was er wirklich tat, war aus zwei Icons nicht abzulesen:
+ // Er löschte den Fahrer samt Teilnahmen, nahm nebenbei das Konto aus dem
+ // Team — und ließ die Zeiten stehen, obwohl der Rückfragetext das
+ // Gegenteil versprach. `times.rider` hat cascadeDelete: false; die
+ // Zeit-Datensätze überlebten mit leerem Fahrer und standen danach
+ // namenlos in der Stage-Rangliste, die nach Stage filtert und nicht nach
+ // Fahrer.
+ //
+ // Geblieben ist der eine Eingriff, der niemandem etwas wegnimmt:
+ // `teams.removeMember` entzieht den Zugang zum Team. Fahrer, Teilnahmen
+ // und Zeiten bleiben — sie sind die Geschichte des Teams und nicht die
+ // des Logins. Konten löscht allein ihr Inhaber oder der Superadmin im
+ // Benutzerverzeichnis unter Einstellungen.
getById(id: string) {
return this.records.find((r) => r.id === id)
@@ -158,33 +117,19 @@ export class RiderStore {
return this.scoped.filter((r) => r.user === userId)
}
- /**
- * Konto an den Fahrer hängen. Nur intern und nur in diese Richtung: Ein
- * Konto entsteht am Fahrer, ein Lösen der Verbindung gibt es nicht — sonst
- * stünde dieselbe Person zweimal in der Liste, einmal mit und einmal ohne
- * Login.
- */
- private async linkUser(riderId: string, userId: string) {
- return await this.edit(riderId, { user: userId } as Partial)
- }
-
- /**
- * Legt dem Fahrer ein Login an und verknüpft es. Das Konto entsteht im
- * Team-Store — es ist zuerst ein Teammitglied und erst dann dieser Fahrer.
- */
- async createLogin(
- rider: RidersResponse,
- data: { email: string; password: string; name?: string },
- ): Promise {
- const user = await this.teams.createMember({
- ...data,
- name: data.name?.trim() || riderName(rider),
- })
-
- await this.linkUser(rider.id, user.id)
-
- return user
- }
+ // Ein Konto legt der Kader nicht mehr an.
+ //
+ // Hier standen `linkUser` und `createLogin`: Die Teamleitung dachte sich
+ // eine Adresse und ein Passwort aus, das Konto entstand sofort — und sie
+ // kannte danach das Passwort eines anderen Menschen und musste es
+ // irgendwie zu ihm bringen.
+ //
+ // Stattdessen geht eine Einladung raus (`teamInvites.send`). Verknüpft
+ // wird der Fahrer erst, wenn der Eingeladene den Link öffnet und sein
+ // Passwort selbst setzt; das erledigt `joinTeam` in
+ // backend/pb_hooks/team_invites_lib.js über das Feld `rider` der
+ // Einladung. Die Richtung bleibt dieselbe wie zuvor — ein Konto entsteht
+ // am Fahrer, gelöst wird die Verbindung nie.
get filtered() {
const q = this.searchQuery.toLowerCase().trim()
diff --git a/frontend/src/lib/stores/teamInvites.svelte.ts b/frontend/src/lib/stores/teamInvites.svelte.ts
index 7009060..ae605db 100644
--- a/frontend/src/lib/stores/teamInvites.svelte.ts
+++ b/frontend/src/lib/stores/teamInvites.svelte.ts
@@ -113,6 +113,29 @@ export class TeamInviteStore {
return invite as TeamInvitesResponse
}
+ /**
+ * Persönliche Einladung an einen Fahrer verschicken.
+ *
+ * Nicht über die Collection, sondern über die Route: Anlegen und
+ * Verschicken gehören zusammen. Eine Einladung, die niemanden erreicht,
+ * ist keine — sie stünde nur als Datensatz herum, während die
+ * Teamleitung auf jemanden wartet, der nie eine Mail bekommen hat. Der
+ * Hook macht beides oder nichts.
+ *
+ * Das ist der Ersatz für „Konto anlegen mit Passwort": Das Passwort
+ * entsteht erst beim Empfänger, niemand sonst kennt es je.
+ */
+ async send({ rider, email, days = 14 }: { rider: string; email: string; days?: number }) {
+ const res = await api.send('/api/team-invites/send', {
+ method: 'POST',
+ body: { rider, email, days },
+ })
+
+ // Die Einladung selbst kommt über das Realtime-Abo herein; hier steht
+ // nur, was der Aufrufer für seine Rückmeldung braucht.
+ return res as { ok: boolean; email: string; expires: string }
+ }
+
/**
* Link zurückziehen. Ohne Rückfrage: Ein Einladungslink ist nichts, was
* verloren gehen kann — wer einen braucht, erzeugt den nächsten.
@@ -161,11 +184,31 @@ async function send(path: string, body?: unknown) {
return data
}
+/**
+ * Wem gilt die Einladung? Ein Einladungslink gilt jedem, der ihn hat —
+ * `personal` ist dann false und die Felder sind leer. Eine persönliche
+ * Einladung nennt Adresse und Namen aus dem Kader; die Seite füllt beides vor.
+ */
+export type InviteRecipient = {
+ email: string
+ name: string
+ personal: boolean
+}
+
export const publicInvite = {
/** Zu welchem Team gehört der Link? Wirft, wenn er nicht (mehr) gilt. */
- async load(token: string): Promise {
+ async load(token: string): Promise<{ team: InviteTeam; recipient: InviteRecipient }> {
const data = await send(`/api/team-invites/${encodeURIComponent(token)}`)
- return data.team as InviteTeam
+ return {
+ team: data.team as InviteTeam,
+ // Ältere Instanzen kennen das Feld noch nicht — dann ist es ein
+ // gewöhnlicher Einladungslink.
+ recipient: (data.recipient ?? {
+ email: '',
+ name: '',
+ personal: false,
+ }) as InviteRecipient,
+ }
},
/** Konto anlegen und beitreten. */
diff --git a/frontend/src/lib/stores/teams.svelte.ts b/frontend/src/lib/stores/teams.svelte.ts
index 4807784..7289a74 100644
--- a/frontend/src/lib/stores/teams.svelte.ts
+++ b/frontend/src/lib/stores/teams.svelte.ts
@@ -219,32 +219,11 @@ export class TeamStore {
return u.name || u.email || id.slice(0, 6) + '…'
}
- /**
- * Legt ein Login an und nimmt es ins Team auf. Zwei Schritte, weil die
- * REST-API keine Transaktion kennt: Scheitert die Aufnahme, existiert das
- * Konto bereits — der Aufrufer meldet den Fehler, statt still
- * weiterzumachen.
- *
- * Erlaubt ist das seit 1754500500_riders_user_and_admin_rules.js jedem
- * eingeloggten Nutzer (users.createRule); eine öffentliche Registrierung
- * gibt es weiterhin nicht.
- */
- async createMember(data: { email: string; password: string; name?: string }, teamId = this.activeId) {
- if (!teamId) throw new Error('Kein aktives Team')
-
- const user = (await api.collection('users').create({
- email: data.email.trim(),
- emailVisibility: false,
- password: data.password,
- passwordConfirm: data.password,
- name: data.name?.trim() || data.email.trim(),
- })) as UsersResponse
-
- this.users = { ...this.users, [user.id]: user }
- await this.addMember(teamId, user.id)
-
- return user
- }
+ // `createMember` ist entfallen. Es legte ein Konto mitsamt Passwort an —
+ // gebraucht wurde es allein von `riders.createLogin`, und das gibt es
+ // nicht mehr. Konten entstehen jetzt ausschließlich dort, wo auch das
+ // Passwort entsteht: beim Empfänger einer Einladung, in den Routen aus
+ // backend/pb_hooks/team_invites.pb.js.
destroy() {
if (this.unsubscribe) {
diff --git a/frontend/src/lib/types.d.ts b/frontend/src/lib/types.d.ts
index 3dfdf10..9c24dee 100644
--- a/frontend/src/lib/types.d.ts
+++ b/frontend/src/lib/types.d.ts
@@ -9,6 +9,8 @@ export enum Collections {
EventParticipants = "event_participants",
EventSeries = "event_series",
Events = "events",
+ NotificationPrefs = "notification_prefs",
+ Notifications = "notifications",
Riders = "riders",
Stages = "stages",
TeamInvites = "team_invites",
@@ -114,6 +116,34 @@ export type EventsRecord = {
updated?: IsoDateString
}
+export type NotificationPrefsRecord = {
+ created?: IsoDateString
+ enabled?: boolean
+ event_changed?: boolean
+ event_created?: boolean
+ event_participation?: boolean
+ id: string
+ team_member?: boolean
+ trail_comment?: boolean
+ trail_marker?: boolean
+ trail_status?: boolean
+ updated?: IsoDateString
+ user: RecordIdString
+}
+
+export type NotificationsRecord = {
+ body?: string
+ created?: IsoDateString
+ id: string
+ link?: string
+ sent?: IsoDateString
+ team?: RecordIdString
+ title: string
+ type: string
+ updated?: IsoDateString
+ user: RecordIdString
+}
+
export type RidersRecord = {
avatar?: string
created?: IsoDateString
@@ -136,9 +166,12 @@ export type StagesRecord = {
export type TeamInvitesRecord = {
created?: IsoDateString
created_by?: RecordIdString
+ email?: string
expires?: IsoDateString
id: string
max_uses?: number
+ rider?: RecordIdString
+ sent?: IsoDateString
team: RecordIdString
token: string
updated?: IsoDateString
@@ -277,6 +310,8 @@ export type UsersRecord = {
export type EventParticipantsResponse = Required & BaseSystemFields
export type EventSeriesResponse = Required & BaseSystemFields
export type EventsResponse = Required & BaseSystemFields
+export type NotificationPrefsResponse = Required & BaseSystemFields
+export type NotificationsResponse = Required & BaseSystemFields
export type RidersResponse = Required & BaseSystemFields
export type StagesResponse = Required & BaseSystemFields
export type TeamInvitesResponse = Required & BaseSystemFields
@@ -295,6 +330,8 @@ export type CollectionRecords = {
event_participants: EventParticipantsRecord
event_series: EventSeriesRecord
events: EventsRecord
+ notification_prefs: NotificationPrefsRecord
+ notifications: NotificationsRecord
riders: RidersRecord
stages: StagesRecord
team_invites: TeamInvitesRecord
@@ -312,6 +349,8 @@ export type CollectionResponses = {
event_participants: EventParticipantsResponse
event_series: EventSeriesResponse
events: EventsResponse
+ notification_prefs: NotificationPrefsResponse
+ notifications: NotificationsResponse
riders: RidersResponse
stages: StagesResponse
team_invites: TeamInvitesResponse
@@ -332,6 +371,8 @@ export type TypedPocketBase = PocketBase & {
collection(idOrName: 'event_participants'): RecordService
collection(idOrName: 'event_series'): RecordService
collection(idOrName: 'events'): RecordService
+ collection(idOrName: 'notification_prefs'): RecordService
+ collection(idOrName: 'notifications'): RecordService
collection(idOrName: 'riders'): RecordService
collection(idOrName: 'stages'): RecordService
collection(idOrName: 'team_invites'): RecordService
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte
index 98a1e6b..e862bde 100644
--- a/frontend/src/routes/+layout.svelte
+++ b/frontend/src/routes/+layout.svelte
@@ -1,6 +1,5 @@
-
+
+
diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte
index e7d652e..a14a1b7 100644
--- a/frontend/src/routes/dashboard/+layout.svelte
+++ b/frontend/src/routes/dashboard/+layout.svelte
@@ -199,7 +199,7 @@
onmouseenter={() => (dashHover = true)}
onmouseleave={() => (dashHover = false)}
>
-
+
+ {#snippet stat(label: string, value: number, Icon: typeof Calendar, hint: string)}
+
+
+ {label}
+
+
+
+
+
+ {#if role === 'owner'}
+ Owner
+ {:else if role === 'admin'}
+ Admin
+ {:else if role === 'mitglied'}
+ Mitglied
+ {/if}
+
+ {#if rider.user}
+
+
+ kann sich anmelden
+
+ {:else}
+ kein Konto
+ {/if}
+
+{/if}
diff --git a/frontend/src/routes/dashboard/settings/+layout.svelte b/frontend/src/routes/dashboard/settings/+layout.svelte
index 8c39ecb..034d2bc 100644
--- a/frontend/src/routes/dashboard/settings/+layout.svelte
+++ b/frontend/src/routes/dashboard/settings/+layout.svelte
@@ -7,14 +7,23 @@
* keine Einstellung, sondern der Gegenstand der Anwendung.
*/
import { page } from '$app/state'
- import { Flag, User } from 'lucide-svelte'
+ import { auth } from '$lib/stores/pocketbase.svelte'
+ import { Bell, Flag, User, Users } from 'lucide-svelte'
let { children } = $props()
- const tabs = [
+ // Das Benutzerverzeichnis ist die eine Ausnahme von „hier steht nur, was
+ // einen selbst betrifft": Es führt alle Konten der App und erscheint nur
+ // dem Superadmin. Ausgeblendet heißt nicht abgesichert — die Seite selbst
+ // weist ab, und der Hook dahinter tut es auch.
+ const tabs = $derived([
{ href: '/dashboard/settings', label: 'Profil', icon: User },
+ { href: '/dashboard/settings/notifications', label: 'Benachrichtigungen', icon: Bell },
{ href: '/dashboard/settings/flags', label: 'Flag-Typen', icon: Flag },
- ]
+ ...(auth.user?.superadmin
+ ? [{ href: '/dashboard/settings/users', label: 'Benutzer', icon: Users }]
+ : []),
+ ])
// Das Profil liegt auf der Wurzel; ohne den Sonderfall wäre sein Reiter
// auf jedem Unterpfad mit aktiv.
@@ -25,7 +34,7 @@
}
-Einstellungen
+Einstellungen · Kurbeler
+ Es geht eine Mail am Tag raus, morgens
+ gegen sechs, mit allem, was seit der letzten passiert ist. Kein Ereignis
+ löst für sich eine Mail aus. Ist nichts passiert, kommt auch nichts.
+
+
+
+
+
+ {#if prefs.error}
+
{prefs.error}
+ {/if}
+
+
+
+
+
+ {#each NOTIFICATION_GROUPS as group (group.title)}
+
+
+ {group.title}
+
+
+ {#each group.items as item, i (item.key)}
+ {#if i > 0}
+
+ {/if}
+
+ {/each}
+
+
+ {/each}
+
+ {:else}
+
+
+
+ Name
+ E-Mail
+ Teams
+ Angelegt
+ Aktion
+
+
+
+ {#each filtered as u (u.id)}
+ {@const blocked = blockedReason(u)}
+
+
+
+
+
+ {u.name || '— ohne Name —'}
+ {#if u.superadmin}
+
+
+ Superadmin
+
+ {/if}
+ {#if !u.verified}
+ unbestätigt
+ {/if}
+
+
+
+
+ {u.email}
+
+
+
+ {#if u.teams.length === 0}
+ in keinem Team
+ {:else}
+ {u.teams.join(', ')}
+ {/if}
+
+
+
+ {formatDay(u.created) || '—'}
+
+
+
+ {#if blocked}
+
+
+
+
+ {:else}
+
+ {/if}
+
+
+ {/each}
+
+
+ {/if}
+
+
+{/if}
diff --git a/frontend/src/routes/dashboard/team/+page.svelte b/frontend/src/routes/dashboard/team/+page.svelte
index f93b643..80da127 100644
--- a/frontend/src/routes/dashboard/team/+page.svelte
+++ b/frontend/src/routes/dashboard/team/+page.svelte
@@ -20,13 +20,13 @@
* auch die Teamleitung nicht heran. Beim Anlegen setzt riders.createLogin
* den Kontonamen einmal auf den Fahrernamen; ab da zählt der Fahrer.
*/
+ import { goto } from '$app/navigation'
import { api } from '$lib/stores/pocketbase.svelte'
+ import { app, tooltip } from '$lib/stores/app.svelte'
import { getTeamContext } from '$lib/stores/teams.svelte'
import { getTeamInviteContext } from '$lib/stores/teamInvites.svelte'
import { riderName, getRiderContext } from '$lib/stores/riders.svelte'
import { getEventContext } from '$lib/stores/events.svelte'
- import { getTimeContext } from '$lib/stores/times.svelte'
- import { getEventParticipantContext } from '$lib/stores/eventParticipants.svelte'
import { getTrailContext } from '$lib/stores/trails.svelte'
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
import Avatar from '$lib/components/Avatar.svelte'
@@ -42,7 +42,7 @@
import * as Table from '@/components/ui/table'
import {
Bike, Calendar, Check, Copy, Crown, Image, KeyRound, Link2, LogOut, Pencil,
- Plus, Route, Search, Shield, Trash2, UserCheck, UserMinus, Users,
+ Plus, Route, Search, Shield, Trash2, UserMinus, Users,
} from 'lucide-svelte'
import type { RidersResponse, TeamInvitesResponse, TeamsResponse } from '$lib/types'
@@ -50,8 +50,6 @@
const invites = getTeamInviteContext()
const riders = getRiderContext()
const events = getEventContext()
- const times = getTimeContext()
- const participants = getEventParticipantContext()
const trails = getTrailContext()
const markers = getTrailMarkerContext()
@@ -133,33 +131,36 @@
})
/**
- * Zur Fahrerseite geht es über ein Event, denn Zeiten hängen an der Stage und
- * der am Event. Bei mehreren Teilnahmen führt der Link zur jüngsten.
+ * Aus dem Kader geht es aufs Fahrerprofil, nicht in ein Event.
+ *
+ * Früher führte der Link über die jüngste Teilnahme auf die
+ * Event-Fahrerseite — wer im Team auf einen Namen klickte, landete in
+ * einem einzelnen Rennen, das er gar nicht gesucht hatte. Das Profil
+ * fasst alle Teilnahmen zusammen; von dort geht es ins einzelne Event.
*/
function riderPath(riderId: string) {
- const latest = participants.byRider(riderId).at(-1)
- return latest ? `/dashboard/events/${latest.event}/riders/${riderId}` : null
+ return `/dashboard/riders/${riderId}`
}
- // ---- Person anlegen oder bearbeiten ----------------------------------
+ // ---- Fahrer anlegen oder bearbeiten ----------------------------------
//
- // Der Dialog bearbeitet die Person, nicht nur den Fahrer: Name und Rolle
- // im Team stehen beieinander. Die Rolle lag früher als „Zu Admin" in der
+ // Der Dialog fasst zusammen, was zu einem Fahrer gehoert: sein Name und
+ // seine Rolle im Team stehen beieinander. Die Rolle lag früher als „Zu Admin" in der
// Zeile — ein Knopf, der ohne Rückfrage Rechte vergab, direkt neben den
// Icons fürs Entfernen und Löschen.
//
// `editing` ist der Fahrer und darf leer bleiben: Alte Datensätze kennen
// Konten im Team, an denen kein Fahrer hängt. Ob der Dialog anlegt oder
// bearbeitet, sagt deshalb `isEdit`.
- let personDialog = $state(false)
+ let riderDialog = $state(false)
let isEdit = $state(false)
let editing = $state(null)
let editingUser = $state(null)
- let form = $state({ name: '', withAccount: false, email: '', password: '', admin: false })
+ let form = $state({ name: '', withAccount: false, email: '', admin: false })
let busy = $state(false)
let error = $state(null)
- /** Rolle der gerade bearbeiteten Person, mitlaufend mit dem Team. */
+ /** Rolle des gerade bearbeiteten Fahrers, mitlaufend mit dem Team. */
const editingRole = $derived(roleOf(editingUser ?? undefined))
/** Die Rolle vergibt allein der Owner, und nie die eigene. */
@@ -169,9 +170,9 @@
isEdit = false
editing = null
editingUser = null
- form = { name: '', withAccount: false, email: '', password: '', admin: false }
+ form = { name: '', withAccount: false, email: '', admin: false }
error = null
- personDialog = true
+ riderDialog = true
}
function openEdit(row: Row) {
@@ -182,27 +183,20 @@
name: row.rider?.name ?? '',
withAccount: false,
email: '',
- password: '',
admin: row.role === 'admin',
}
error = null
- personDialog = true
+ riderDialog = true
}
- async function savePerson() {
+ async function saveRider() {
if ((editing || !isEdit) && !form.name.trim()) {
error = 'Bitte einen Namen angeben.'
return
}
- if (form.withAccount) {
- if (!form.email.trim()) {
- error = 'Für ein Konto braucht es eine E-Mail-Adresse.'
- return
- }
- if (form.password.length < 8) {
- error = 'Das Passwort braucht mindestens 8 Zeichen.'
- return
- }
+ if (form.withAccount && !form.email.trim()) {
+ error = 'Für die Einladung braucht es eine E-Mail-Adresse.'
+ return
}
busy = true
@@ -221,74 +215,111 @@
}
} else {
const rider = await riders.create(data)
+
+ // Erst der Fahrer, dann die Einladung: Sie hängt an ihm, und
+ // scheitert der Versand, steht der Fahrer trotzdem im Kader.
+ // Die Teamleitung kann ihn dann von der Zeile aus erneut
+ // einladen, statt alles noch einmal einzutippen.
if (form.withAccount) {
- await riders.createLogin(rider as RidersResponse, {
- email: form.email,
- password: form.password,
+ await invites.send({
+ rider: rider.id,
+ email: form.email.trim(),
})
}
}
- personDialog = false
+ riderDialog = false
} catch (e: any) {
- error = e.message ?? 'Die Person konnte nicht gespeichert werden.'
+ error = e.message ?? 'Der Fahrer konnte nicht gespeichert werden.'
} finally {
busy = false
}
}
- // ---- Konto zu einem vorhandenen Fahrer -------------------------------
+ // ---- Einladung an einen vorhandenen Fahrer ---------------------------
//
// Nur diese eine Richtung: aus einem Fahrer ohne Konto einen mit Konto
// machen. Ein bestehendes Konto an einen Fahrer zu hängen oder die
// Verbindung wieder zu lösen gibt es bewusst nicht — wer sich anmelden
- // können soll, bekommt hier ein Konto oder kommt über den Einladungslink.
+ // können soll, wird hier eingeladen oder kommt über den Einladungslink.
+ //
+ // Hier stand früher ein Passwortfeld. Die Teamleitung dachte sich eins
+ // aus, gab es irgendwie weiter — und kannte danach das Passwort eines
+ // anderen. Jetzt geht eine Mail raus und der Fahrer setzt es selbst;
+ // niemand sonst kennt es je.
let accountDialog = $state(false)
let accountRider = $state(null)
- let accountForm = $state({ email: '', password: '' })
+ let accountForm = $state({ email: '', days: 14 })
let accountBusy = $state(false)
let accountError = $state(null)
+ let accountSent = $state(null)
function openAccount(rider: RidersResponse) {
accountRider = rider
- accountForm = { email: '', password: '' }
+ accountForm = { email: '', days: 14 }
accountError = null
+ accountSent = null
accountDialog = true
}
- async function createAccount() {
+ async function sendInvite() {
if (!accountRider) return
if (!accountForm.email.trim()) {
- accountError = 'Ohne E-Mail-Adresse gibt es keinen Anmeldenamen.'
- return
- }
- if (accountForm.password.length < 8) {
- accountError = 'Das Passwort braucht mindestens 8 Zeichen.'
+ accountError = 'Ohne E-Mail-Adresse geht keine Einladung raus.'
return
}
accountBusy = true
accountError = null
try {
- await riders.createLogin(accountRider, { ...accountForm })
- accountDialog = false
+ await invites.send({
+ rider: accountRider.id,
+ email: accountForm.email.trim(),
+ days: accountForm.days,
+ })
+ accountSent = accountForm.email.trim()
} catch (e: any) {
- accountError = e.message ?? 'Das Konto konnte nicht angelegt werden.'
+ accountError = e.message ?? 'Die Einladung konnte nicht verschickt werden.'
} finally {
accountBusy = false
}
}
- async function removeFromTeam(userId: string) {
- if (!team) return
- if (userId === team.owner) return alert('Der Owner kann nicht entfernt werden.')
- if (!confirm('Konto aus dem Team entfernen? Der Fahrer bleibt im Kader.')) return
+ /**
+ * Zugang entziehen — der einzige Eingriff, den die Teamleitung an einer
+ * fremden Person hat.
+ *
+ * Sie nimmt das Konto aus `teams.users` und `teams.admins`. Damit sieht
+ * die Person dieses Team nicht mehr; das Konto selbst bleibt unberührt,
+ * und PocketBase erzwingt das auch: `users.deleteRule` lässt ein Konto
+ * nur von seinem Inhaber löschen oder vom Superadmin.
+ *
+ * Der Fahrer bleibt im Kader, mit Teilnahmen und Zeiten. Das ist der
+ * Kern: Wer ausgetreten ist, verschwindet nicht rückwirkend aus den
+ * Ergebnissen vergangener Rennen — die Geschichte gehört dem Team, nicht
+ * dem Login. Ein „Fahrer löschen" gibt es hier deshalb nicht mehr; es
+ * hätte Zeiten mitgerissen und stand irreführend neben diesem Knopf.
+ */
+ function removeFromTeam(userId: string, name: string) {
+ if (!team || userId === team.owner) return
- try {
- await teams.removeMember(team.id, userId)
- } catch (e: any) {
- alert(e.message ?? 'Das Mitglied konnte nicht entfernt werden.')
- }
+ app.confirm.request({
+ title: 'Zugang entziehen?',
+ text:
+ `„${name}" sieht dieses Team danach nicht mehr. Das Konto bleibt bestehen — ` +
+ `es kann über einen Einladungslink zurückkommen. Fahrer, Teilnahmen und Zeiten ` +
+ `bleiben im Kader stehen.`,
+ yes: async () => {
+ try {
+ await teams.removeMember(team.id, userId)
+ app.confirm.close()
+ } catch (e: any) {
+ app.confirm.close()
+ alert(e.message ?? 'Der Zugang konnte nicht entzogen werden.')
+ }
+ },
+ no: () => app.confirm.close(),
+ })
}
// ---- Einladungslinks -------------------------------------------------
@@ -419,7 +450,7 @@
}
-Team
+Team · Kurbeler
@@ -517,7 +548,7 @@
{#if canManage}
{/if}
@@ -530,7 +561,7 @@
Noch niemand im Team
{#if canManage}
- Leg die erste Person an oder verschick einen Einladungslink.
+ Leg den ersten Fahrer an oder verschick einen Einladungslink.
{:else}
Die Teamleitung legt den Kader an.
{/if}
@@ -543,10 +574,7 @@
-
-
+
+
- {isEdit ? 'Person bearbeiten' : 'Person anlegen'}
+ {isEdit ? 'Fahrer bearbeiten' : 'Fahrer anlegen'}
{isEdit
? 'Der Name gilt teamweit; Startnummern hängen am jeweiligen Event.'
- : 'Kommt als Fahrer in den Kader. Ein Konto ist optional.'}
+ : 'Kommt als Fahrer in den Kader. Eine Einladung ist optional.'}
@@ -725,26 +748,29 @@
{/if}
{#if !isEdit}
+
{#if form.withAccount}
-
-
-
-
-
+
- Das Konto kommt zugleich ins Team. Gib das Passwort weiter; es lässt
- sich unter „Einstellungen" ändern.
+ Der Fahrer bekommt einen Link und setzt sein Passwort selbst. Ohne
+ Häkchen steht er nur im Kader — einladen lässt er sich jederzeit
+ später aus seiner Zeile.
{/if}
@@ -756,8 +782,8 @@
-
-
@@ -769,35 +795,64 @@
- Konto für {accountRider ? riderName(accountRider) : 'Fahrer'}
+ {accountRider ? riderName(accountRider) : 'Fahrer'} einladen
- Mit einem Konto kann sich die Person selbst anmelden. Gib das Passwort
- weiter; ändern lässt es sich unter „Einstellungen".
+ Es geht eine Mail mit einem Link raus. Das Passwort setzt der Fahrer
+ dabei selbst — du bekommst es nicht zu sehen und musst es nicht
+ weitergeben.
-
-
-
-
-
-
-
-
+ {#if accountSent}
+
+
+
+ Einladung an {accountSent} ist unterwegs.
+
+
+ Kommt nichts an, lohnt der Blick in den Spam-Ordner. Danach lässt sich
+ hier eine neue Einladung schicken — die alte verfällt damit.
+
+
+
+ (accountDialog = false)}>Abbrechen
+
+ {accountBusy ? 'Wird verschickt …' : 'Einladung senden'}
+
+
+ {/if}
@@ -928,7 +983,7 @@
Logo für „{logoTeam?.name}"
Erscheint in der Kopfzeile und blass im Seitenhintergrund. Ohne eigenes
- Logo steht dort das Wappen des Stammtischs.
+ Logo steht dort die Kurbel.
diff --git a/frontend/src/routes/dashboard/trails/+page.svelte b/frontend/src/routes/dashboard/trails/+page.svelte
index 2e4146b..6d38027 100644
--- a/frontend/src/routes/dashboard/trails/+page.svelte
+++ b/frontend/src/routes/dashboard/trails/+page.svelte
@@ -121,7 +121,13 @@
{@const freshComments = comments.hasRecent(trail.id)}
-
+
+