diff --git a/backend/controllers/memberController.js b/backend/controllers/memberController.js index 5ca5ed74..24f66ac4 100755 --- a/backend/controllers/memberController.js +++ b/backend/controllers/memberController.js @@ -2,6 +2,8 @@ import MemberService from "../services/memberService.js"; import MemberTransferService from "../services/memberTransferService.js"; import clickTtPlayerRegistrationService from "../services/clickTtPlayerRegistrationService.js"; import { emitMemberChanged } from '../services/socketService.js'; +import Member from '../models/Member.js'; +import UserClub from '../models/UserClub.js'; import { devLog } from '../utils/logger.js'; const getClubMembers = async(req, res) => { @@ -351,6 +353,28 @@ const transferMembers = async (req, res) => { } }; +const linkMemberUser = async (req, res) => { + try { + const clubId = Number(req.params.clubId); + const memberId = Number(req.params.memberId); + const userId = Number(req.body?.userId); + if (!clubId || !memberId || !userId) return res.status(400).json({ error: 'Mitglied, Verein und Benutzer sind erforderlich.' }); + const [member, clubUser, existing] = await Promise.all([ + Member.findOne({ where: { id: memberId, clubId } }), + UserClub.findOne({ where: { clubId, userId, approved: true } }), + Member.findOne({ where: { userId } }), + ]); + if (!member || !clubUser) return res.status(404).json({ error: 'Mitglied oder freigegebener Vereinsbenutzer wurde nicht gefunden.' }); + if (existing && Number(existing.id) !== memberId) return res.status(409).json({ error: 'Dieses Benutzerkonto ist bereits einem Mitglied zugeordnet.' }); + await member.update({ userId }); + emitMemberChanged(clubId); + res.json({ memberId, userId }); + } catch (error) { + console.error('[linkMemberUser] - Error:', error); + res.status(500).json({ error: 'Mitglied konnte nicht verknüpft werden.' }); + } +}; + export { getClubMembers, getWaitingApprovals, @@ -372,5 +396,6 @@ export { requestClickTtPlayerRegistration, deleteMemberImage, setPrimaryMemberImage, - generateMemberGallery + generateMemberGallery, + linkMemberUser }; diff --git a/backend/migrations/20260722_add_member_user_link.sql b/backend/migrations/20260722_add_member_user_link.sql new file mode 100644 index 00000000..d814ab34 --- /dev/null +++ b/backend/migrations/20260722_add_member_user_link.sql @@ -0,0 +1,4 @@ +ALTER TABLE member + ADD COLUMN user_id INT NULL, + ADD UNIQUE KEY member_user_id_unique (user_id), + ADD CONSTRAINT member_user_id_fk FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE SET NULL; diff --git a/backend/models/Member.js b/backend/models/Member.js index 60af45fa..9d47bde4 100755 --- a/backend/models/Member.js +++ b/backend/models/Member.js @@ -123,6 +123,13 @@ const Member = sequelize.define('Member', { type: DataTypes.INTEGER, allowNull: false, }, + userId: { + type: DataTypes.INTEGER, + allowNull: true, + unique: true, + field: 'user_id', + comment: 'Linked login account for personal member features', + }, membershipStatus: { type: DataTypes.STRING, allowNull: false, diff --git a/backend/models/index.js b/backend/models/index.js index 476afaf7..e362fea3 100755 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -223,6 +223,9 @@ User.hasMany(UserClub, { foreignKey: 'userId' }); UserClub.belongsTo(Club, { foreignKey: 'clubId', as: 'club' }); Club.hasMany(UserClub, { foreignKey: 'clubId' }); +User.hasOne(Member, { foreignKey: 'userId', as: 'memberProfile' }); +Member.belongsTo(User, { foreignKey: 'userId', as: 'user' }); + Group.belongsTo(DiaryDate, { foreignKey: 'diaryDateId', as: 'diaryDateGroup' }); DiaryDate.hasMany(Group, { foreignKey: 'diaryDateId', as: 'groupsDiaryDate' }); diff --git a/backend/routes/memberRoutes.js b/backend/routes/memberRoutes.js index 338992e1..efcdf903 100755 --- a/backend/routes/memberRoutes.js +++ b/backend/routes/memberRoutes.js @@ -19,7 +19,8 @@ import { requestClickTtPlayerRegistration, deleteMemberImage, setPrimaryMemberImage, - generateMemberGallery + generateMemberGallery, + linkMemberUser } from '../controllers/memberController.js'; import express from 'express'; import { authenticate } from '../middleware/authMiddleware.js'; @@ -39,6 +40,7 @@ router.post('/image/:clubId/:memberId/:imageId/primary', authenticate, authorize router.get('/get/:id/:showAll', authenticate, authorize('members', 'read'), getClubMembers); router.get('/gallery/:clubId', authenticate, authorize('members', 'read'), generateMemberGallery); router.post('/set/:id', authenticate, authorize('members', 'write'), setClubMembers); +router.put('/link-user/:clubId/:memberId', authenticate, authorize('members', 'write'), linkMemberUser); router.get('/sepa/:clubId/:memberId', authenticate, authorize('members', 'read'), getMemberSepaMandate); router.put('/sepa/:clubId/:memberId', authenticate, authorize('members', 'write'), saveMemberSepaMandate); router.get('/play-interest/:clubId', authenticate, authorize('members', 'read'), getMemberPlayInterests); diff --git a/docs/collective-orders-plan.md b/docs/collective-orders-plan.md new file mode 100644 index 00000000..788befc6 --- /dev/null +++ b/docs/collective-orders-plan.md @@ -0,0 +1,146 @@ +# Sammelbestellungen: gemeinsames Konzept für Club, Trainer und Trainingstagebuch + +Stand: 2026-07-22 + +## Ziel + +Eine Sammelbestellung bündelt Wünsche von Mitgliedern, Trainingsteilnehmern und +Vereinsverantwortlichen. Club- und Trainerrollen steuern Bestellung, Preise, +Rückfragen, Zahlung und Ausgabe; alle berechtigten Nutzer können Wünsche +eintragen und ihren eigenen Stand nachvollziehen. + +## Rollen und Rechte + +| Aktion | Club | Trainer | Mitglied / Trainingstagebuch-Nutzer | +| --- | --- | --- | --- | +| Sammelbestellung anlegen | Ja | Ja | Nein | +| Eigene Artikelwünsche eintragen | Ja | Ja | Ja | +| Eigene Wünsche ändern oder stornieren, solange offen | Ja | Ja | Ja | +| Preise, Bestellung, Ausgabe und Zahlungsstatus bearbeiten | Ja | Ja | Nein | +| Rückfrage zu einem Artikel stellen | Ja | Ja | Nein | +| Auf Rückfrage antworten | Ja | Ja | Ja, nur zum eigenen Artikel | +| Preis bestätigen | Ja | Ja | Ja, nur zum eigenen Artikel | +| Gesamtrechnung und Bestellstatus bearbeiten | Ja | Ja | Nein | + +Der Ersteller einer Sammelbestellung erhält zusätzlich dieselben Steuerrechte +wie Club/Trainer für genau diese Bestellung, auch wenn seine allgemeine Rolle +später geändert wird. + +## Datenmodell + +### Sammelbestellung + +- ID, `club_id`, Titel, Beschreibung, Lieferant, Bestellfrist +- Ersteller (`created_by_user_id`), optional verantwortlicher Trainer +- Bestellstatus: `open`, `ordered`, `arrived` +- Zahlungsstatus: `unpaid`, `partially_paid`, `paid` +- Gesamtrechnungsbetrag in Cent, Währung, Rechnungsreferenz +- Zeitstempel für Bestellung, Eingang und Abschluss + +### Artikelwunsch + +- ID, Sammelbestellung, anfragender Nutzer und optional zugeordnetes Mitglied +- Artikelname, Variante/Größe, Menge, Freitextwunsch +- Einzelpreis in Cent, Währung +- Artikelstatus: `entered`, `question`, `ordered`, `cancelled`, `arrived`, `issued` +- Zahlungsstatus: `unpaid`, `paid` +- Preisbestätigung durch den Anfragenden (`price_confirmed_at`) +- Rückfrage: Text, gestellt von, gestellt am, Antwort, beantwortet am +- Ausgabe: ausgegeben von, ausgegeben am + +## Verbindliche Statuslogik + +### Einzelartikel + +1. `entered`: Wunsch wurde eingetragen. +2. `question`: Verantwortliche stellen eine Rückfrage; der Nutzer kann antworten. +3. `ordered`: Artikel ist beim Lieferanten bestellt. +4. `cancelled`: Artikel wird nicht weitergeführt. +5. `arrived`: Artikel ist eingetroffen. +6. `issued`: Artikel wurde an den Nutzer ausgegeben. + +Der Preis darf von Club/Trainer jederzeit bis zur Ausgabe gepflegt werden. Ist +ein Einzelpreis vorhanden oder geändert, kann der Anfragende ihn bestätigen. +Die Preisbestätigung ist für die Bestellung optional konfigurierbar, sollte aber +vor `ordered` sichtbar als offen markiert sein. + +Ein Artikel gilt nur dann als **abgeschlossen**, wenn sein Artikelstatus +`issued` und sein Zahlungsstatus `paid` ist. Stornierte Artikel gelten als +abgeschlossen, jedoch separat als storniert. + +### Gesamte Sammelbestellung + +- `open`: Wünsche werden gesammelt und geklärt. +- `ordered`: Bestellung wurde ausgelöst. +- `arrived`: Lieferung ist eingetroffen. + +Der Zahlungsstatus bleibt unabhängig: `unpaid`, `partially_paid`, `paid`. +Der Gesamtbetrag der Rechnung wird manuell gepflegt und steht neben der Summe +aller Einzelpreise, damit Abweichungen sichtbar sind. Eine Bestellung ist erst +abgeschlossen, wenn sie `arrived` ist und alle nicht stornierten Artikel +ausgegeben und bezahlt sind. + +## Ansichten + +### Für alle Nutzer + +- Liste offener und eigener Sammelbestellungen. +- Wunschformular mit Artikel, Variante, Menge und Hinweis. +- Eigene Artikel mit Preis, Preisbestätigung, Rückfrage und Status-Timeline. +- Keine Sicht auf Wünsche, Preise oder Zahlungen anderer Nutzer. + +### Club- und Traineransicht + +- Neue Sammelbestellung anlegen und Verantwortliche festlegen. +- Artikeltabelle mit Filtern: Status, Rückfrage offen, Preis unbestätigt, + unbezahlt, eingetroffen, auszugeben. +- Preis, Artikelstatus, Rückfrage und Zahlungsstatus inline pflegen. +- Gesamtstatus, Gesamtrechnung und Gesamtzahlungsstatus pflegen. +- Übersichten für fehlende Preisbestätigungen, offene Rückfragen und noch + auszugebende Artikel. + +### Trainingstagebuch + +- Kontextlink aus einer Trainingseinheit auf aktive Sammelbestellungen. +- Teilnehmer können dort ihren Wunsch eintragen oder den eigenen Status prüfen. +- Keine Finanz- oder Verwaltungsaktionen für normale Teilnehmer. + +## Umsetzungstodos + +### Phase 1: Grundlage und Rechte + +- [ ] Datenmodell, Migrationen und Indizes für Bestellung und Artikelwunsch anlegen. +- [ ] Berechtigung `collective_orders` mit read/write und Ersteller-Ausnahme definieren. +- [ ] Sichere Zuordnung User ↔ Mitglied für eigene Wünsche verbindlich klären. +- [ ] REST-Endpunkte für Liste, Detail, Bestellung und eigene Artikelwünsche erstellen. +- [ ] Statusübergänge serverseitig validieren. + +### Phase 2: Club- und Trainer-Arbeitsbereich + +- [ ] Bestellliste und Detailansicht implementieren. +- [ ] Bestellung anlegen, Gesamtbetrag und Gesamtstatus pflegen. +- [ ] Artikelstatus, Einzelpreis und Zahlungsstatus pflegen. +- [ ] Rückfrage-/Antwort-Workflow und Preisbestätigung umsetzen. +- [ ] Abschlusslogik und Arbeitsfilter umsetzen. + +### Phase 3: Mitglieder- und Trainingstagebuch-Sicht + +- [ ] Eigene Wünsche eintragen, ändern und stornieren. +- [ ] Eigene Rückfragen beantworten und Preise bestätigen. +- [ ] Persönliche Statusanzeige und verständliche leere Zustände bauen. +- [ ] Kontextlink aus Trainingstagebuch ergänzen. + +### Phase 4: Abgleich und Qualität + +- [ ] Abweichung zwischen Gesamtrechnung und Summe der Artikelpreise sichtbar machen. +- [ ] Export für Lieferant, Ausgabe und offene Zahlungen ergänzen. +- [ ] Rechte-, Status- und Mobil-Smoketests mit Club, Trainer und Mitglied durchführen. +- [ ] Historie und Archivverknüpfung für abgeschlossene Bestellungen ergänzen. + +## Abnahmekriterien + +- [ ] Mitglieder können ausschließlich eigene Wünsche und Rückfragen sehen. +- [ ] Club/Trainer können jeden Artikel vollständig steuern. +- [ ] Kein Artikel wird als abgeschlossen markiert, bevor er ausgegeben und bezahlt ist. +- [ ] Gesamtbetrag und Artikelpreissumme sind transparent vergleichbar. +- [ ] Offene Rückfragen, unbestätigte Preise, fehlende Zahlungen und Ausgaben sind mit einem Filter erreichbar. diff --git a/docs/manual_sql_migrations.md b/docs/manual_sql_migrations.md index db766b67..600f03aa 100755 --- a/docs/manual_sql_migrations.md +++ b/docs/manual_sql_migrations.md @@ -18,6 +18,21 @@ Ergaenzend: ## 2026-07-22 +### `member.user_id` + +Sichere optionale Zuordnung eines Login-Kontos zu einem Vereinsmitglied für +persönliche Mitgliederfunktionen: + +```sql +ALTER TABLE member + ADD COLUMN user_id INT NULL, + ADD UNIQUE KEY member_user_id_unique (user_id), + ADD CONSTRAINT member_user_id_fk FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE SET NULL; +``` + +Bestehende Mitglieder bleiben zunächst unverknüpft. Die Zuordnung erfolgt +bewusst über die Verwaltungs-API und nicht über einen automatischen E-Mail-Abgleich. + ### `club_communication_threads.reply_to` Optionale Antwortadresse je Kommunikationsvorgang: diff --git a/docs/simple-user-plan.md b/docs/simple-user-plan.md new file mode 100644 index 00000000..cd53795c --- /dev/null +++ b/docs/simple-user-plan.md @@ -0,0 +1,83 @@ +# Einfache Nutzer: Produkt- und Umsetzungsplan + +Stand: 2026-07-22 + +## Zielgruppe und Zielbild + +Einfache Nutzer sind Vereinsmitglieder oder Helfer ohne Verwaltungsauftrag. Sie +sollen persönliche Informationen finden und Rückmeldungen geben können, ohne +Finanzen, Rollenverwaltung, Archiv oder interne Vorgänge zu sehen. + +## Rechteprinzip + +- Kein Zugriff auf Finanzen, Rollen, Benutzerverwaltung, Archiv oder Historie. +- Nur eigene bzw. explizit freigegebene Vereinsdaten sehen. +- Eigene Angaben und Rückmeldungen bearbeiten; vereinsweite Änderungen bleiben + bei Verwaltungsrollen. + +## Umsetzung in sinnvoller Reihenfolge + +### Phase 1: Zugang und persönlicher Einstieg + +- [ ] Rollenprofil `member` und Navigation gegen unzulässige Ziele auditieren. +- [ ] Persönliches Dashboard für Termine, Training, Rückmeldungen und Vereinsnachrichten umsetzen. +- [ ] Leere Zustände für fehlende Termine, Nachrichten und Rückmeldungen formulieren. +- [ ] Persönliche Startseite als Standardziel nach Login festlegen. + +Abnahme: + +- [ ] Ein Nutzer ohne Verwaltungsrechte sieht nur seinen persönlichen Bereich. +- [ ] Kein sichtbarer Link führt zu einer anschließenden Berechtigungsfehlermeldung. + +### Phase 2: Eigene Daten und Rückmeldungen + +- [ ] Ansicht „Mein Profil“ für freigegebene Stammdaten bereitstellen. +- [ ] Änderungsanfrage für Kontaktdaten statt direkter Änderung geschützter Felder umsetzen. +- [ ] Zu- und Absagen für relevante Trainings oder Veranstaltungen ermöglichen. +- [ ] Mitgliedschafts- und SEPA-Hinweise verständlich, aber ohne sensible Zahlungsdaten anzeigen. +- [ ] Rückmeldungen für berechtigte Verantwortliche sichtbar machen. + +Abnahme: + +- [ ] Kontaktdaten können sicher geändert bzw. zur Prüfung eingereicht werden. +- [ ] Zu- und Absagen sind an der jeweiligen Veranstaltung nachvollziehbar. + +### Phase 3: Kommunikation und Informationen + +- [ ] Persönlichen Nachrichteneingang bereitstellen. +- [ ] Vereinsankündigungen nach Datum sortiert anzeigen. +- [ ] Gelesen-/ungelesen-Status und einen klaren Rückfrageweg ergänzen. +- [ ] Datenschutz- und Sichtbarkeitsregeln für Nachrichten und Kontaktinformationen prüfen. + +Abnahme: + +- [ ] Neue Vereinsinformationen sind ohne Verwaltungsmenüs auffindbar. +- [ ] Persönliche Nachrichten sind ausschließlich für den Empfänger sichtbar. + +### Phase 4: Mobil, Qualität und Einführung + +- [ ] Mobilansicht und langsame Verbindungen prüfen. +- [ ] Berechtigungstest mit Mitglied, Trainer und Vorstand durchführen. +- [ ] Onboarding für ersten Login, Profilprüfung und Rückmeldungen erstellen. +- [ ] Pilot mit wenigen echten Mitgliedern durchführen und Feedback auswerten. + +Abnahme: + +- [ ] Die Testrollen sehen jeweils nur die vorgesehenen Daten und Aktionen. +- [ ] Der persönliche Hauptablauf funktioniert auf Mobilgeräten ohne Hilfestellung. + +## Nicht Teil dieses Plans + +- Vereinsverwaltung, Buchhaltung, Rollenpflege und Archivarbeit. +- Erweiterungen des Player-Produkts `mein-tt.de`; diese bleiben in der eigenen + [mein-tt.de-Roadmap](./mein-tt-product-plan.md). + +## Erste umsetzbare Tickets + +1. [ ] Bestehende `member`-Berechtigungen und Navigation auditieren. +2. [ ] Datenvertrag und Wireframe für das persönliche Dashboard festlegen. +3. [ ] Dashboard mit Terminen und Nachrichten implementieren. +4. [ ] Profil- und Kontaktdaten-Änderungsanfrage implementieren. +5. [ ] Trainings- und Veranstaltungsrückmeldungen ergänzen. +6. [ ] Nachrichteneingang und Benachrichtigungsstatus ergänzen. +7. [ ] Rollen- und Mobile-Smoke-Test mit Pilotgruppe durchführen. diff --git a/docs/tt-club.de.conf b/docs/tt-club.de.conf deleted file mode 100644 index 7c092af3..00000000 --- a/docs/tt-club.de.conf +++ /dev/null @@ -1,9 +0,0 @@ -# HTTP: www.tt-club.de und tt-club.de -> HTTPS - - ServerName www.tt-club.de - Redirect permanent / https://tt-club.de/ - - - ServerName tt-club.de - Redirect permanent / https://tt-club.de/ - diff --git a/docs/tt-verein.de-bootstrap.conf b/docs/tt-verein.de-bootstrap.conf new file mode 100644 index 00000000..ede96ecf --- /dev/null +++ b/docs/tt-verein.de-bootstrap.conf @@ -0,0 +1,16 @@ +# Temporärer HTTP-VHost für die erstmalige Let's-Encrypt-Ausstellung. +# Diese Datei vor dem Zertifikat als tt-verein.de.conf aktivieren. + + ServerName tt-verein.de + ServerAlias www.tt-verein.de + + DocumentRoot /var/www/tt-tagebuch.de + + Options Indexes FollowSymLinks + AllowOverride All + Require all granted + + + ErrorLog ${APACHE_LOG_DIR}/tt-verein.de_error.log + CustomLog ${APACHE_LOG_DIR}/tt-verein.de_access.log combined + diff --git a/docs/tt-club.de-le-ssl.conf b/docs/tt-verein.de-le-ssl.conf similarity index 55% rename from docs/tt-club.de-le-ssl.conf rename to docs/tt-verein.de-le-ssl.conf index 4e14f7c8..ba62018f 100644 --- a/docs/tt-club.de-le-ssl.conf +++ b/docs/tt-verein.de-le-ssl.conf @@ -1,18 +1,18 @@ +# tt-verein.de nutzt dieselbe ausgerollte Anwendung wie tt-tagebuch.de. - ServerName tt-club.de - ServerAlias www.tt-club.de - # tt-club.de nutzt dieselbe ausgerollte Anwendung wie tt-tagebuch.de. + ServerName tt-verein.de + ServerAlias www.tt-verein.de DocumentRoot /var/www/tt-tagebuch.de Options Indexes FollowSymLinks AllowOverride All Require all granted - ErrorLog ${APACHE_LOG_DIR}/tt-club.de_error.log - CustomLog ${APACHE_LOG_DIR}/tt-club.de_access.log combined + ErrorLog ${APACHE_LOG_DIR}/tt-verein.de_error.log + CustomLog ${APACHE_LOG_DIR}/tt-verein.de_access.log combined SSLEngine on - SSLCertificateFile /etc/letsencrypt/live/tt-club.de/fullchain.pem - SSLCertificateKeyFile /etc/letsencrypt/live/tt-club.de/privkey.pem + SSLCertificateFile /etc/letsencrypt/live/tt-verein.de/fullchain.pem + SSLCertificateKeyFile /etc/letsencrypt/live/tt-verein.de/privkey.pem Include /etc/letsencrypt/options-ssl-apache.conf ProxyRequests Off ProxyPass /api http://localhost:3050/api diff --git a/docs/tt-verein.de.conf b/docs/tt-verein.de.conf new file mode 100644 index 00000000..56af6974 --- /dev/null +++ b/docs/tt-verein.de.conf @@ -0,0 +1,9 @@ +# HTTP: www.tt-verein.de und tt-verein.de -> HTTPS + + ServerName www.tt-verein.de + Redirect permanent / https://tt-verein.de/ + + + ServerName tt-verein.de + Redirect permanent / https://tt-verein.de/ +