feat: implement user linking for members and update database schema for user associations
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 53s

This commit is contained in:
Torsten Schulz (local)
2026-07-22 23:54:45 +02:00
parent 46079fc16e
commit 3ac4039097
12 changed files with 319 additions and 18 deletions

View File

@@ -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
};

View File

@@ -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;

View File

@@ -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,

View File

@@ -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' });

View File

@@ -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);

View File

@@ -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.

View File

@@ -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:

83
docs/simple-user-plan.md Normal file
View File

@@ -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.

View File

@@ -1,9 +0,0 @@
# HTTP: www.tt-club.de und tt-club.de -> HTTPS
<VirtualHost *:80>
ServerName www.tt-club.de
Redirect permanent / https://tt-club.de/
</VirtualHost>
<VirtualHost *:80>
ServerName tt-club.de
Redirect permanent / https://tt-club.de/
</VirtualHost>

View File

@@ -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.
<VirtualHost *:80>
ServerName tt-verein.de
ServerAlias www.tt-verein.de
DocumentRoot /var/www/tt-tagebuch.de
<Directory /var/www/tt-tagebuch.de>
Options Indexes FollowSymLinks
AllowOverride All
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/tt-verein.de_error.log
CustomLog ${APACHE_LOG_DIR}/tt-verein.de_access.log combined
</VirtualHost>

View File

@@ -1,18 +1,18 @@
# tt-verein.de nutzt dieselbe ausgerollte Anwendung wie tt-tagebuch.de.
<VirtualHost *:443>
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
<Directory /var/www/tt-tagebuch.de>
Options Indexes FollowSymLinks
AllowOverride All
Require all granted
</Directory>
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

9
docs/tt-verein.de.conf Normal file
View File

@@ -0,0 +1,9 @@
# HTTP: www.tt-verein.de und tt-verein.de -> HTTPS
<VirtualHost *:80>
ServerName www.tt-verein.de
Redirect permanent / https://tt-verein.de/
</VirtualHost>
<VirtualHost *:80>
ServerName tt-verein.de
Redirect permanent / https://tt-verein.de/
</VirtualHost>