35 Commits

Author SHA1 Message Date
Torsten Schulz (local)
616bade9a5 feat: enhance API client and add club overview functionality
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 52s
2026-07-30 11:01:19 +02:00
Torsten Schulz (local)
ef8d99eaf4 feat: implement authentication flow with login, password reset, and club selection
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 53s
- Added dependencies for dio, flutter_secure_storage, and shared_preferences.
- Created ApiClient for handling API requests with token management.
- Implemented AuthRepository for authentication operations.
- Developed TokenStorage for secure token storage.
- Added AppSession to manage user session state and club selection.
- Created password reset pages for requesting and saving new passwords.
- Implemented ClubSelectionPage for selecting an active club.
- Updated widget tests to reflect new login flow and UI changes.
- Documented progress in FLUTTER_DESKTOP_APP_PLAN.md.
2026-07-30 10:54:02 +02:00
Torsten Schulz (local)
240cbf7d14 feat: Implement product switcher for AppShell and update navigation context
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 53s
2026-07-30 10:37:39 +02:00
Torsten Schulz (local)
b404c289bb feat: Implement Windows runner for Flutter desktop app
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 57s
- Added runner.exe.manifest for DPI awareness and compatibility settings.
- Created utils.cpp and utils.h for console management and command line argument handling.
- Developed win32_window.cpp and win32_window.h for high DPI-aware window management.
- Established basic window creation, message handling, and theme updating.
- Added documentation for Flutter desktop app implementation plan and MVP specifications.
2026-07-30 10:01:54 +02:00
Torsten Schulz (local)
ee526dc3f2 feat: Update member table schema to link users and enhance calendar events structure
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 52s
2026-07-24 14:47:21 +02:00
Torsten Schulz (local)
01ca924e3e feat: Add Berlin date-time formatting for match results processing
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 54s
2026-07-24 13:31:49 +02:00
Torsten Schulz (local)
e2e8ba0d54 feat: Implement member inbox with read status and question functionality
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 53s
2026-07-24 09:17:57 +02:00
Torsten Schulz (local)
63a5db1196 feat: Implement member self-service features for profile change requests and event responses
- Added MemberProfileChangeRequest and MemberEventResponse models with associations.
- Created API endpoints for member dashboard, profile change requests, event responses, and training attendance updates.
- Developed MemberHomeView component to display member-specific information and actions.
- Updated routing to include new member dashboard and profile change request functionalities.
- Added SQL migration scripts for new database tables related to member self-service.
- Enhanced login functionality to redirect members based on their roles and club associations.
2026-07-24 07:53:05 +02:00
Torsten Schulz (local)
3ac4039097 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
2026-07-22 23:54:45 +02:00
Torsten Schulz (local)
46079fc16e feat: update navigation link for club payments in dashboard and enhance error handling in Home view
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 51s
2026-07-22 16:17:19 +02:00
Torsten Schulz (local)
e38f2c2e19 feat: add archived documents section and filtering options to club archive view
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 52s
2026-07-22 16:07:31 +02:00
Torsten Schulz (local)
d190c2cb70 feat: add optional reply address to communication threads and enhance email transport status handling
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 52s
2026-07-22 15:28:39 +02:00
Torsten Schulz (local)
fd0b3020bf feat: implement load request ID for improved data loading consistency across views
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 52s
2026-07-22 15:25:00 +02:00
Torsten Schulz (local)
8e4edfb719 style: improve button styling and layout for schedule actions and summary bar
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 1m4s
2026-07-22 15:09:23 +02:00
Torsten Schulz (local)
01f80ea094 style: enhance layout and styling of schedule summary items and summary bar
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 52s
2026-07-22 15:04:17 +02:00
Torsten Schulz (local)
8140283b7a feat: improve schedule view layout and controls for match overview
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 52s
2026-07-22 14:57:13 +02:00
Torsten Schulz (local)
48f0f9afb0 fix: update app version to 1.7.10 and SDK versions to 36
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 2m28s
2026-07-22 14:38:33 +02:00
Torsten Schulz (local)
ad778328e8 feat: add team name normalization for selected schedule in PDF generation
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 50s
2026-07-17 16:01:39 +02:00
Torsten Schulz (local)
91006bb0c5 fix: adjust table font size and improve cell content formatting in PDF generation
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 52s
2026-07-17 15:51:15 +02:00
Torsten Schulz (local)
110519c7b8 feat: add team name normalization for club matching in match service
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 51s
2026-07-17 15:35:57 +02:00
Torsten Schulz (local)
6afa5a5629 fix: improve existing match handling in CSV import to ensure correct league and location updates
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 50s
2026-07-17 15:19:23 +02:00
Torsten Schulz (local)
7bf7ae2bef fix: ensure leagueId is retained when editing a team to prevent accidental removal of MyTischtennis link
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 51s
2026-07-17 14:53:06 +02:00
Torsten Schulz (local)
51b620f496 feat: enhance table generation in PDF with optional cell pinning for teams
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 51s
2026-07-17 14:28:12 +02:00
Torsten Schulz (local)
f83e3986b7 feat: add age group code extraction and link youth club teams to imported leagues
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 50s
2026-07-17 14:10:52 +02:00
Torsten Schulz (local)
bb9a030e89 fix: update clubId handling in CSV upload to support query parameters and improve form data processing
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 53s
2026-07-17 14:02:54 +02:00
Torsten Schulz (local)
a767061bd3 fix: remove manual Content-Type setting for CSV import to ensure proper form data handling
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 51s
2026-07-17 13:58:10 +02:00
Torsten Schulz (local)
35031e3ae5 fix: improve error handling for CSV import in upload and ScheduleView
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 51s
2026-07-17 13:48:56 +02:00
Torsten Schulz (local)
370df07c2a feat: implement team name normalization and enhance CSV import match detection
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 51s
2026-07-17 12:25:58 +02:00
Torsten Schulz (local)
94385231d3 fix: update CSV import to correctly decode Windows-1252 encoding for venue names
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 51s
2026-07-17 12:00:17 +02:00
Torsten Schulz (local)
5357dac9e1 chore: update file permissions for multiple files and add migration for club fee rules
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 54s
- Changed file permissions from 644 to 755 for various files in the mobile-app and mobile-app_legacy_rn_expo directories, ensuring executable permissions where necessary.
- Added a new SQL migration script to the backend to introduce missing columns for club billing and invoice settings in the clubs table, maintaining synchronization with the current Sequelize model.
2026-07-17 11:51:24 +02:00
Torsten Schulz (local)
ac97332e6f feat: Enhance club payment claims and task automation
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 1m0s
- Added `paid_amount_cents` column to `club_payment_claims` for better tracking of payments.
- Implemented compatibility checks for the new column in `clubPaymentClaimService` and `clubTaskAutomationService`.
- Updated various views to handle read-only states when editing is not allowed.
- Refactored forms in `ClubAccountsView`, `ClubInvoicesView`, `ClubTasksView`, and `ClubCommunicationView` to use factory functions for cleaner code.
- Introduced a new migration script to add the `paid_amount_cents` column if it doesn't exist and initialize it for existing paid claims.
- Created a detailed plan for enhancing club features and ensuring stability in existing modules.
2026-07-13 17:01:13 +02:00
Torsten Schulz (local)
90e6c2f9f6 Updates, overview extended, club view implemented 2026-06-23 16:17:07 +02:00
Torsten Schulz (local)
dd91c07694 Mitgliedermeldung jetzt wieder möglich 2026-06-21 17:23:29 +02:00
Torsten Schulz (local)
302ed20a9b more club funtions, fix for team edit 2026-06-21 17:15:04 +02:00
Torsten Schulz (local)
542fae089c implementierung der ersten schritte eine komplett-suite 2026-06-19 15:47:32 +02:00
1061 changed files with 34001 additions and 952 deletions

0
.codex Normal file → Executable file
View File

0
.cursor/commands/club-settings.md Normal file → Executable file
View File

0
.cursor/commands/oldfkchecks.md Normal file → Executable file
View File

0
.gitea/workflows/deploy.yml Normal file → Executable file
View File

0
.gitignore vendored Normal file → Executable file
View File

0
Advanced Normal file → Executable file
View File

0
Autofill Normal file → Executable file
View File

0
CHECK_SERVER.md Normal file → Executable file
View File

0
CODEx_MIGRATION_RULES.md Normal file → Executable file
View File

0
DEPLOYMENT_SOCKET_IO.md Normal file → Executable file
View File

0
DSGVO_CHECKLIST.md Normal file → Executable file
View File

0
Languages Normal file → Executable file
View File

0
PERMISSIONS_GUIDE.md Normal file → Executable file
View File

0
PERMISSIONS_MIGRATION.md Normal file → Executable file
View File

0
SERVER_NODE_UPGRADE.md Normal file → Executable file
View File

0
SITEMAP_ANLEITUNG.md Normal file → Executable file
View File

0
System Normal file → Executable file
View File

0
TrainingsTagebuch.code-workspace Normal file → Executable file
View File

0
apache-http.conf.example Normal file → Executable file
View File

0
apache-https.conf.example Normal file → Executable file
View File

0
apache.conf.example Normal file → Executable file
View File

0
backend/CLICKTT_HTTV_README.md Normal file → Executable file
View File

0
backend/MYTISCHTENNIS_AUTO_FETCH_README.md Normal file → Executable file
View File

0
backend/MYTISCHTENNIS_URL_PARSER_README.md Normal file → Executable file
View File

0
backend/README_CLEANUP.md Normal file → Executable file
View File

0
backend/SOCKET_IO_SSL_SETUP.md Normal file → Executable file
View File

0
backend/checkRealIndexes.sql Normal file → Executable file
View File

0
backend/checkTableNames.sql Normal file → Executable file
View File

0
backend/cleanupKeys.sql Normal file → Executable file
View File

0
backend/cleanupKeysAggressive.sql Normal file → Executable file
View File

0
backend/cleanupKeysCorrected.sql Normal file → Executable file
View File

0
backend/cleanupKeysFinal.sql Normal file → Executable file
View File

0
backend/cleanupKeysIntelligent.sql Normal file → Executable file
View File

0
backend/cleanupKeysMinimal.sql Normal file → Executable file
View File

0
backend/cleanupKeysNode.cjs Normal file → Executable file
View File

0
backend/cleanupKeysNode.js Normal file → Executable file
View File

0
backend/cleanupKeysReal.sql Normal file → Executable file
View File

0
backend/cleanupKeysSimple.sql Normal file → Executable file
View File

0
backend/cleanupKeysSmart.sql Normal file → Executable file
View File

0
backend/clients/myTischtennisClient.js Normal file → Executable file
View File

0
backend/config.js Normal file → Executable file
View File

0
backend/constants/ERROR_CODES_USAGE.md Normal file → Executable file
View File

0
backend/constants/errorCodes.js Normal file → Executable file
View File

0
backend/controllers/accidentController.js Normal file → Executable file
View File

0
backend/controllers/activityController.js Normal file → Executable file
View File

0
backend/controllers/apiLogController.js Normal file → Executable file
View File

0
backend/controllers/authController.js Normal file → Executable file
View File

0
backend/controllers/billingController.js Normal file → Executable file
View File

0
backend/controllers/calendarController.js Normal file → Executable file
View File

15
backend/controllers/calendarEventController.js Normal file → Executable file
View File

@@ -19,7 +19,7 @@ export const createClubCalendarEvent = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const event = await calendarEventService.createClubEvent(userToken, clubId, req.body);
const event = await calendarEventService.createClubEvent(userToken, clubId, req.body, req.user?.id || null);
res.status(201).json(event);
} catch (error) {
console.error('[createClubCalendarEvent] - Error:', error);
@@ -28,6 +28,19 @@ export const createClubCalendarEvent = async (req, res) => {
}
};
export const updateClubCalendarEvent = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, eventId } = req.params;
const event = await calendarEventService.updateClubEvent(userToken, clubId, eventId, req.body, req.user?.id || null);
res.status(200).json(event);
} catch (error) {
console.error('[updateClubCalendarEvent] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Speichern des Kalender-Events');
res.status(error.statusCode || 500).json({ error: msg });
}
};
export const deleteClubCalendarEvent = async (req, res) => {
try {
const { authcode: userToken } = req.headers;

0
backend/controllers/clickTtAccountController.js Normal file → Executable file
View File

View File

@@ -0,0 +1,93 @@
import clubAccountService from '../services/clubAccountService.js';
class ClubAccountController {
async listClubAccounts(req, res) {
try {
const { clubId } = req.params;
const payload = await clubAccountService.listClubAccounts(Number(clubId));
res.json(payload);
} catch (error) {
console.error('[listClubAccounts] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Konten konnten nicht geladen werden.' });
}
}
async createClubAccount(req, res) {
try {
const { clubId } = req.params;
const account = await clubAccountService.createClubAccount(Number(clubId), req.body || {});
res.status(201).json({ account });
} catch (error) {
console.error('[createClubAccount] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Konto konnte nicht gespeichert werden.' });
}
}
async updateClubAccount(req, res) {
try {
const { clubId, accountId } = req.params;
const account = await clubAccountService.updateClubAccount(Number(clubId), Number(accountId), req.body || {});
res.json({ account });
} catch (error) {
console.error('[updateClubAccount] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Konto konnte nicht gespeichert werden.' });
}
}
async updateClubAccountStatus(req, res) {
try {
const { clubId, accountId } = req.params;
const account = await clubAccountService.updateClubAccountStatus(Number(clubId), Number(accountId), String(req.body?.status || ''));
res.json({ account });
} catch (error) {
console.error('[updateClubAccountStatus] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Kontostatus konnte nicht gespeichert werden.' });
}
}
async deleteClubAccount(req, res) {
try {
const { clubId, accountId } = req.params;
await clubAccountService.deleteClubAccount(Number(clubId), Number(accountId));
res.json({ success: true });
} catch (error) {
console.error('[deleteClubAccount] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Konto konnte nicht gelöscht werden.' });
}
}
async createTransaction(req, res) {
try {
const { clubId } = req.params;
const transaction = await clubAccountService.createTransaction(Number(clubId), req.user?.id || null, req.body || {});
res.status(201).json({ transaction });
} catch (error) {
console.error('[createClubAccountTransaction] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Kontenbewegung konnte nicht gespeichert werden.' });
}
}
async updateTransaction(req, res) {
try {
const { clubId, transactionId } = req.params;
const transaction = await clubAccountService.updateTransaction(Number(clubId), Number(transactionId), req.body || {});
res.json({ transaction });
} catch (error) {
console.error('[updateClubAccountTransaction] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Kontenbewegung konnte nicht gespeichert werden.' });
}
}
async deleteTransaction(req, res) {
try {
const { clubId, transactionId } = req.params;
await clubAccountService.deleteTransaction(Number(clubId), Number(transactionId));
res.json({ success: true });
} catch (error) {
console.error('[deleteClubAccountTransaction] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Kontenbewegung konnte nicht gelöscht werden.' });
}
}
}
export default new ClubAccountController();

View File

@@ -0,0 +1,19 @@
import clubArchiveService from '../services/clubArchiveService.js';
class ClubArchiveController {
async getClubArchive(req, res) {
try {
const { clubId } = req.params;
const archive = await clubArchiveService.getClubArchive(clubId);
res.json(archive);
} catch (error) {
console.error('[getClubArchive] - Error:', error);
if (error?.status) {
return res.status(error.status).json({ error: error.message });
}
res.status(500).json({ error: 'Fehler beim Laden des Vereinsarchivs' });
}
}
}
export default new ClubArchiveController();

View File

@@ -0,0 +1,132 @@
import clubCommunicationService from '../services/clubCommunicationService.js';
function handleError(res, scope, error) {
console.error(`[${scope}] - Error:`, error);
res.status(error?.status || 500).json({
error: error?.message || 'internalerror',
});
}
const clubCommunicationController = {
async list(req, res) {
try {
const { clubId } = req.params;
const payload = await clubCommunicationService.listClubCommunication(Number(clubId));
res.json(payload);
} catch (error) {
handleError(res, 'listClubCommunication', error);
}
},
async createThread(req, res) {
try {
const { clubId } = req.params;
const thread = await clubCommunicationService.createThread(Number(clubId), req.user?.id || null, req.body || {});
res.status(201).json({ thread });
} catch (error) {
handleError(res, 'createClubCommunicationThread', error);
}
},
async updateThread(req, res) {
try {
const { clubId, threadId } = req.params;
const thread = await clubCommunicationService.updateThread(Number(clubId), Number(threadId), req.body || {});
res.json({ thread });
} catch (error) {
handleError(res, 'updateClubCommunicationThread', error);
}
},
async deleteThread(req, res) {
try {
const { clubId, threadId } = req.params;
await clubCommunicationService.deleteThread(Number(clubId), Number(threadId));
res.status(204).send();
} catch (error) {
handleError(res, 'deleteClubCommunicationThread', error);
}
},
async addMessage(req, res) {
try {
const { clubId, threadId } = req.params;
const message = await clubCommunicationService.addMessage(Number(clubId), Number(threadId), req.user?.id || null, req.body || {});
res.status(201).json({ message });
} catch (error) {
handleError(res, 'addClubCommunicationMessage', error);
}
},
async sendThread(req, res) {
try {
const { clubId, threadId } = req.params;
const thread = await clubCommunicationService.sendThread(Number(clubId), Number(threadId), req.user?.id || null);
res.json({ thread });
} catch (error) {
handleError(res, 'sendClubCommunicationThread', error);
}
},
async createGroup(req, res) {
try {
const { clubId } = req.params;
const group = await clubCommunicationService.createGroup(Number(clubId), req.body || {});
res.status(201).json({ group });
} catch (error) {
handleError(res, 'createClubDistributionGroup', error);
}
},
async updateGroup(req, res) {
try {
const { clubId, groupId } = req.params;
const group = await clubCommunicationService.updateGroup(Number(clubId), Number(groupId), req.body || {});
res.json({ group });
} catch (error) {
handleError(res, 'updateClubDistributionGroup', error);
}
},
async deleteGroup(req, res) {
try {
const { clubId, groupId } = req.params;
await clubCommunicationService.deleteGroup(Number(clubId), Number(groupId));
res.status(204).send();
} catch (error) {
handleError(res, 'deleteClubDistributionGroup', error);
}
},
async createTemplate(req, res) {
try {
const { clubId } = req.params;
const template = await clubCommunicationService.createTemplate(Number(clubId), req.body || {});
res.status(201).json({ template });
} catch (error) {
handleError(res, 'createClubCommunicationTemplate', error);
}
},
async updateTemplate(req, res) {
try {
const { clubId, templateId } = req.params;
const template = await clubCommunicationService.updateTemplate(Number(clubId), Number(templateId), req.body || {});
res.json({ template });
} catch (error) {
handleError(res, 'updateClubCommunicationTemplate', error);
}
},
async deleteTemplate(req, res) {
try {
const { clubId, templateId } = req.params;
await clubCommunicationService.deleteTemplate(Number(clubId), Number(templateId));
res.status(204).send();
} catch (error) {
handleError(res, 'deleteClubCommunicationTemplate', error);
}
},
};
export default clubCommunicationController;

View File

@@ -0,0 +1,671 @@
import { Op } from 'sequelize';
import sequelize from '../database.js';
import {
CalendarEvent,
ClubCommunicationThread,
ClubInvoice,
ClubPaymentClaim,
ClubRequest,
ClubSepaMandate,
ClubTask,
Match,
Member,
TrainingGroup,
} from '../models/index.js';
import clubArchiveService from '../services/clubArchiveService.js';
import { hasClubPaymentClaimPaidAmountCentsColumn } from '../services/clubPaymentClaimCompatibility.js';
import { getSafeErrorMessage } from '../utils/errorUtils.js';
function formatRequestWorkflowStage(stage) {
return {
contact_replied: 'Kontakt beantwortet',
trial_training_scheduled: 'Probetraining terminiert',
trial_training_feedback_recorded: 'Probetraining nachbereitet',
membership_reviewed: 'Mitgliedsanfrage geprüft',
admission_prepared: 'Aufnahme vorbereitet',
member_record_created: 'Mitglied angelegt',
sepa_pending: 'SEPA ausstehend',
onboarding_completed: 'Onboarding abgeschlossen',
sponsoring_contacted: 'Sponsoring kontaktiert',
sponsoring_offer_prepared: 'Sponsoringangebot vorbereitet',
sponsoring_followed_up: 'Sponsoring nachgefasst',
}[stage] || stage;
}
function formatTaskType(taskType) {
return {
request_contact_reply: 'Kontaktanfrage beantworten',
request_schedule_trial_training: 'Probetraining organisieren',
request_trial_training_follow_up: 'Probetraining nachbereiten',
request_membership_review: 'Mitgliedsanfrage prüfen',
membership_prepare_admission: 'Aufnahme vorbereiten',
membership_create_member_record: 'Mitglied anlegen',
membership_collect_sepa_mandate: 'SEPA organisieren',
membership_assign_fee: 'Beitrag zuordnen',
request_sponsoring_reply: 'Sponsoring nachfassen',
sponsoring_prepare_offer: 'Sponsoringangebot vorbereiten',
sponsoring_follow_up: 'Sponsoring nachfassen',
document_review_required: 'Dokument prüfen',
sponsor_contract_renewal: 'Sponsoring verlängern',
member_missing_email: 'E-Mail ergänzen',
member_missing_birthdate: 'Geburtsdatum ergänzen',
member_missing_sepa_mandate: 'SEPA-Mandat einholen',
payment_claim_due_soon: 'Fällige Zahlung vorbereiten',
payment_claim_overdue: 'Überfällige Zahlung nachfassen',
payment_claim_reminder: 'Mahnstufe prüfen',
calendar_event_prepare: 'Termin vorbereiten',
calendar_event_deadline_check: 'Terminfrist prüfen',
}[taskType] || taskType || 'Freie Aufgabe';
}
function formatCommunicationStatus(status) {
return {
draft: 'Entwurf',
scheduled: 'Geplant',
sent: 'Versendet',
archived: 'Archiviert',
}[status] || status || 'Status';
}
function formatInvoiceStatus(status) {
return {
draft: 'Entwurf',
issued: 'Gestellt',
partially_paid: 'Teilbezahlt',
paid: 'Bezahlt',
cancelled: 'Storniert',
archived: 'Archiviert',
}[status] || status || 'Status';
}
function formatEventDateRange(event) {
if (!event?.startDate) {
return null;
}
const formatter = new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium' });
const start = formatter.format(new Date(event.startDate));
const end = event.endDate ? formatter.format(new Date(event.endDate)) : start;
return start === end ? start : `${start} bis ${end}`;
}
function formatDate(value, options = { dateStyle: 'medium' }) {
if (!value) {
return null;
}
return new Intl.DateTimeFormat('de-DE', options).format(new Date(value));
}
function formatTime(value) {
if (!value) {
return null;
}
return String(value).slice(0, 5);
}
function formatWeekday(weekday) {
return ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][Number(weekday)] || 'Unbekannt';
}
function formatConfiguredTrainingLabel(entry) {
const start = formatTime(entry.startTime);
const end = formatTime(entry.endTime);
const timeRange = start && end ? `${start} bis ${end} Uhr` : start ? `${start} Uhr` : null;
return [entry.groupName, formatWeekday(entry.weekday), timeRange].filter(Boolean).join(' · ');
}
function formatMatchLabel(match) {
const date = formatDate(match.date);
const time = formatTime(match.time);
const homeTeam = match.homeTeam?.name || 'Heimteam';
const guestTeam = match.guestTeam?.name || 'Gastteam';
const league = match.leagueDetails?.name || null;
return [
`${homeTeam} gegen ${guestTeam}`,
date,
time ? `${time} Uhr` : null,
league,
].filter(Boolean).join(' · ');
}
function createDashboardItem(label, to, extra = {}) {
return { label, to, ...extra };
}
function buildMemberRoute(memberId, scope = 'active', extraQuery = {}) {
return {
path: '/members',
query: {
scope,
memberId: String(memberId),
mode: 'edit',
...extraQuery,
},
};
}
function buildRequestRoute(requestId, status = '') {
const query = { requestId: String(requestId) };
if (status) {
query.status = status;
}
return {
path: '/club-requests',
query,
};
}
function buildTaskRoute(taskId, status = '') {
const query = { taskId: String(taskId) };
if (status) {
query.status = status;
}
return {
path: '/club-tasks',
query,
};
}
async function loadAvailableTables() {
const tables = await sequelize.getQueryInterface().showAllTables();
return new Set(
tables
.map((table) => (typeof table === 'string' ? table : Object.values(table || {})[0]))
.filter(Boolean)
.map((table) => String(table).toLowerCase())
);
}
async function loadOptionalTableData(availableTables, tableName, loader, fallbackValue = []) {
if (!availableTables.has(String(tableName).toLowerCase())) {
return fallbackValue;
}
return loader();
}
function countMissingMemberFields(members) {
const missing = {
email: 0,
birthDate: 0,
};
for (const member of members) {
if (!String(member.email || '').trim()) {
missing.email += 1;
}
if (!String(member.birthDate || '').trim()) {
missing.birthDate += 1;
}
}
return missing;
}
function toNextOccurrenceDate(weekday, startTime) {
const now = new Date();
const result = new Date(now);
const targetWeekday = Number(weekday);
const daysUntilWeekday = (targetWeekday - result.getDay() + 7) % 7;
result.setDate(result.getDate() + daysUntilWeekday);
const [hours = '0', minutes = '0'] = String(startTime || '00:00').split(':');
result.setHours(Number(hours), Number(minutes), 0, 0);
if (result < now) {
result.setDate(result.getDate() + 7);
}
return result;
}
function buildUpcomingTrainingSlots(groups, limit = 5) {
return groups
.flatMap((group) => (Array.isArray(group.trainingTimes) ? group.trainingTimes.map((time) => ({
id: time.id,
weekday: time.weekday,
startTime: time.startTime,
endTime: time.endTime,
sortOrder: time.sortOrder,
groupName: group.name,
nextOccurrence: toNextOccurrenceDate(time.weekday, time.startTime),
})) : []))
.sort((left, right) => {
const timeDiff = left.nextOccurrence.getTime() - right.nextOccurrence.getTime();
if (timeDiff !== 0) return timeDiff;
return String(left.groupName || '').localeCompare(String(right.groupName || ''));
})
.slice(0, limit);
}
export const getClubDashboard = async (req, res) => {
try {
const clubId = Number(req.params.clubId);
const currentUserId = Number(req.user?.id) || null;
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayIso = today.toISOString().slice(0, 10);
const availableTables = await loadAvailableTables();
const hasPaidAmountCentsColumn = await hasClubPaymentClaimPaidAmountCentsColumn();
const [
requests,
tasks,
members,
mandates,
paymentClaims,
upcomingEvents,
trainingGroups,
upcomingMatches,
communicationThreads,
invoices,
archive,
] = await Promise.all([
loadOptionalTableData(availableTables, 'club_requests', () => ClubRequest.findAll({
where: {
clubId,
status: { [Op.notIn]: ['archived'] },
},
order: [['receivedAt', 'DESC']],
})),
loadOptionalTableData(availableTables, 'club_tasks', () => ClubTask.findAll({
where: {
clubId,
status: { [Op.notIn]: ['archived'] },
},
order: [['dueAt', 'ASC'], ['updatedAt', 'DESC']],
})),
Member.findAll({
where: {
clubId,
active: true,
},
order: [['createdAt', 'DESC']],
}),
loadOptionalTableData(availableTables, 'club_sepa_mandates', () => ClubSepaMandate.findAll({
where: {
clubId,
status: 'active',
revokedAt: null,
memberId: { [Op.ne]: null },
},
attributes: ['memberId'],
})),
hasPaidAmountCentsColumn
? loadOptionalTableData(availableTables, 'club_payment_claims', () => ClubPaymentClaim.findAll({
where: {
clubId,
status: { [Op.in]: ['open', 'partially_paid'] },
archivedAt: null,
},
order: [['dueOn', 'ASC']],
}))
: Promise.resolve([]),
loadOptionalTableData(availableTables, 'calendar_events', () => CalendarEvent.findAll({
where: {
clubId,
endDate: { [Op.gte]: todayIso },
},
order: [['startDate', 'ASC']],
limit: 5,
})),
loadOptionalTableData(availableTables, 'training_group', () => TrainingGroup.findAll({
where: {
clubId,
},
include: [
{
association: 'trainingTimes',
required: false,
},
],
order: [['isPreset', 'DESC'], ['sortOrder', 'ASC'], ['name', 'ASC']],
})),
loadOptionalTableData(availableTables, 'match', () => Match.findAll({
where: {
clubId,
date: { [Op.gte]: today },
},
include: [
{ association: 'homeTeam', attributes: ['id', 'name'] },
{ association: 'guestTeam', attributes: ['id', 'name'] },
{ association: 'leagueDetails', attributes: ['id', 'name'] },
],
order: [['date', 'ASC'], ['time', 'ASC']],
limit: 5,
})),
loadOptionalTableData(availableTables, 'club_communication_threads', () => ClubCommunicationThread.findAll({
where: { clubId },
attributes: ['id', 'subject', 'status', 'threadType', 'scheduledAt', 'sentAt', 'updatedAt'],
order: [['updatedAt', 'DESC'], ['createdAt', 'DESC']],
limit: 8,
})),
loadOptionalTableData(availableTables, 'club_invoices', () => ClubInvoice.findAll({
where: {
clubId,
archivedAt: null,
},
attributes: ['id', 'invoiceNumber', 'invoiceDirection', 'invoiceType', 'status', 'dueOn', 'grossAmountCents', 'currencyCode', 'updatedAt'],
order: [['updatedAt', 'DESC'], ['dueOn', 'ASC']],
limit: 8,
})),
clubArchiveService.getClubArchive(clubId),
]);
const visibleDashboardTasks = tasks.filter((task) => !task.assignedUserId || Number(task.assignedUserId) === currentUserId);
const membersById = new Map(members.map((member) => [Number(member.id), member]));
const paymentClaimsById = new Map(paymentClaims.map((claim) => [Number(claim.id), claim]));
const missingFields = countMissingMemberFields(members);
const openTasks = visibleDashboardTasks.filter((task) => task.status === 'open');
const inProgressTasks = visibleDashboardTasks.filter((task) => task.status === 'in_progress');
const automatedTasks = visibleDashboardTasks.filter((task) => Boolean(task.automationKey));
const automatedOpenTasks = automatedTasks.filter((task) => ['open', 'in_progress', 'waiting'].includes(task.status));
const overdueTaskCount = visibleDashboardTasks.filter((task) => {
if (!task.dueAt || ['done', 'cancelled', 'archived'].includes(task.status)) {
return false;
}
return new Date(task.dueAt) < today;
}).length;
const memberIdsWithMandate = new Set(mandates.map((mandate) => Number(mandate.memberId)).filter(Boolean));
const missingMandateCount = members.filter((member) => !memberIdsWithMandate.has(Number(member.id))).length;
const openRequestCount = requests.filter((request) => request.status === 'open').length;
const inProgressRequestCount = requests.filter((request) => request.status === 'in_progress').length;
const trialTrainingCount = requests.filter((request) => request.requestType === 'trial_training' && request.status !== 'archived').length;
const workflowStageCounts = requests.reduce((accumulator, request) => {
if (!request.workflowStage) return accumulator;
accumulator[request.workflowStage] = (accumulator[request.workflowStage] || 0) + 1;
return accumulator;
}, {});
const onboardingCount =
(workflowStageCounts.membership_reviewed || 0) +
(workflowStageCounts.admission_prepared || 0) +
(workflowStageCounts.member_record_created || 0) +
(workflowStageCounts.sepa_pending || 0);
const duePaymentCount = paymentClaims.filter((claim) => claim.status === 'open').length;
const reminderCount = paymentClaims.filter((claim) => Number(claim.reminderLevel || 0) > 0).length;
const openInvoices = invoices.filter((invoice) => ['draft', 'issued', 'partially_paid'].includes(invoice.status));
const outgoingOpenInvoices = openInvoices.filter((invoice) => invoice.invoiceDirection === 'outgoing');
const incomingOpenInvoices = openInvoices.filter((invoice) => invoice.invoiceDirection === 'incoming');
const communicationDraftCount = communicationThreads.filter((thread) => thread.status === 'draft').length;
const communicationScheduledCount = communicationThreads.filter((thread) => thread.status === 'scheduled').length;
const communicationSentCount = communicationThreads.filter((thread) => thread.status === 'sent').length;
const recentMembers = members.slice(0, 4);
const upcomingTrainings = buildUpcomingTrainingSlots(trainingGroups);
const paidRatio = paymentClaims.length === 0
? null
: Math.max(
0,
Math.round(
((paymentClaims.length - duePaymentCount) / paymentClaims.length) * 100
)
);
function taskDetailTarget(task) {
if (task.relatedEntityType === 'member' && task.relatedEntityId) {
return buildMemberRoute(task.relatedEntityId, 'active');
}
if (task.relatedEntityType === 'club_request' && task.relatedEntityId) {
const request = requests.find((entry) => Number(entry.id) === Number(task.relatedEntityId));
return buildRequestRoute(task.relatedEntityId, request?.status || '');
}
if (task.relatedEntityType === 'club_payment_claim' && task.relatedEntityId) {
const claim = paymentClaimsById.get(Number(task.relatedEntityId));
if (claim?.memberId) {
return buildMemberRoute(claim.memberId, 'active');
}
}
return buildTaskRoute(task.id, task.status || '');
}
function taskDashboardItem(task, label) {
return createDashboardItem(label, taskDetailTarget(task), {
isAssignedToCurrentUser: Boolean(task.assignedUserId) && Number(task.assignedUserId) === currentUserId,
});
}
const sections = [
{
id: 'action-needed',
title: 'Handlungsbedarf',
cards: [
{
title: 'Neue Anfragen',
value: `${openRequestCount + inProgressRequestCount}`,
meta: trialTrainingCount > 0 ? `${trialTrainingCount} Probetrainings` : null,
to: '/club-requests',
accent: 'amber',
items: [
createDashboardItem(`${openRequestCount} offen`, '/club-requests'),
createDashboardItem(`${inProgressRequestCount} in Bearbeitung`, '/club-requests'),
],
},
{
title: 'Anfrage-Workflows',
value: `${onboardingCount}`,
meta: onboardingCount > 0 ? 'im Aufnahme- und Onboardingprozess' : 'Keine aktiven Onboarding-Fälle',
to: '/club-requests',
accent: 'green',
items: [
createDashboardItem(`${workflowStageCounts.trial_training_scheduled || 0} Probetrainings terminiert`, '/club-requests'),
createDashboardItem(`${workflowStageCounts.membership_reviewed || 0} Mitgliedsanfragen geprüft`, '/club-requests'),
createDashboardItem(`${workflowStageCounts.sepa_pending || 0} Fälle mit ausstehendem SEPA`, '/club-requests'),
],
},
{
title: 'Offene Zahlungen',
value: `${paymentClaims.length}`,
meta: [
duePaymentCount > 0 ? `${duePaymentCount} fällig offen` : null,
reminderCount > 0 ? `${reminderCount} mit Mahnstufe` : null,
].filter(Boolean).join(' · ') || 'Keine Mahnungen aktiv',
to: '/club-payments',
accent: 'red',
items: paymentClaims.slice(0, 3).map((claim) => {
const amount = `${(Number(claim.amountCents) / 100).toFixed(2)} ${claim.currencyCode || 'EUR'}`;
return createDashboardItem(
`${amount} · ${claim.dueOn ? `fällig ${claim.dueOn}` : 'ohne Fälligkeit'}`,
claim.memberId ? buildMemberRoute(claim.memberId, 'active') : '/club-payments'
);
}),
},
{
title: 'Fehlende Daten',
value: `${missingFields.email + missingFields.birthDate + missingMandateCount}`,
to: '/members',
accent: 'blue',
items: [
createDashboardItem(`${missingFields.email} Mitglieder ohne E-Mail`, { path: '/members', query: { scope: 'dataIncomplete' } }),
createDashboardItem(`${missingFields.birthDate} Mitglieder ohne Geburtsdatum`, { path: '/members', query: { scope: 'dataIncomplete' } }),
createDashboardItem(`${missingMandateCount} Mitglieder ohne SEPA-Mandat`, { path: '/members', query: { scope: 'dataIncomplete' } }),
],
},
{
title: 'Offene Aufgaben',
value: `${openTasks.length + inProgressTasks.length}`,
meta: overdueTaskCount > 0 ? `${overdueTaskCount} überfällig` : 'Keine überfälligen Aufgaben',
to: '/club-tasks',
accent: overdueTaskCount > 0 ? 'red' : 'green',
items: [
createDashboardItem(`${automatedOpenTasks.length} automatisch erzeugte Schritte`, '/club-tasks'),
...visibleDashboardTasks.slice(0, 3).map((task) => taskDashboardItem(task, task.title)),
],
},
],
},
{
id: 'operations-room',
title: 'Kommunikation, Finanzen und Archiv',
cards: [
{
title: 'Kommunikation',
value: `${communicationThreads.length}`,
meta: `${communicationDraftCount} Entwürfe, ${communicationScheduledCount} geplant, ${communicationSentCount} versendet`,
to: '/club-communication',
accent: 'blue',
items: communicationThreads.slice(0, 3).map((thread) => createDashboardItem(
`${thread.subject || 'Ohne Betreff'} · ${formatCommunicationStatus(thread.status)}`,
'/club-communication'
)),
},
{
title: 'Rechnungen',
value: `${openInvoices.length}`,
meta: `${outgoingOpenInvoices.length} ausgehend, ${incomingOpenInvoices.length} eingehend`,
to: '/club-invoices',
accent: 'amber',
items: openInvoices.slice(0, 3).map((invoice) => createDashboardItem(
`${invoice.invoiceNumber || `Rechnung #${invoice.id}`} · ${formatInvoiceStatus(invoice.status)}`,
'/club-invoices'
)),
},
{
title: 'Archiv',
value: `${archive.summary.archivedRequests + archive.summary.archivedTasks + archive.summary.archivedClaims}`,
meta: `${archive.summary.inactiveMembers} inaktive Mitglieder`,
to: '/club-archive',
accent: 'neutral',
items: [
createDashboardItem(`${archive.summary.archivedRequests} archivierte Anfragen`, '/club-archive'),
createDashboardItem(`${archive.summary.archivedTasks} archivierte Aufgaben`, '/club-archive'),
createDashboardItem(`${archive.summary.archivedClaims} archivierte Beitragsfälle`, '/club-archive'),
],
},
],
},
{
id: 'appointments',
title: 'Aktuelle Termine',
cards: [
{
title: 'Nächste Trainings',
value: `${upcomingTrainings.length}`,
to: {
path: '/club-settings',
query: { tab: 'training-times' },
},
items: upcomingTrainings.map((training) => createDashboardItem(
formatConfiguredTrainingLabel(training),
{
path: '/club-settings',
query: { tab: 'training-times' },
}
)),
},
{
title: 'Nächste Spiele',
value: `${upcomingMatches.length}`,
to: '/schedule',
items: upcomingMatches.map((match) => createDashboardItem(formatMatchLabel(match), '/schedule')),
},
{
title: 'Kalendertermine',
value: `${upcomingEvents.length}`,
to: '/calendar',
items: upcomingEvents.map((event) => createDashboardItem(`${event.title} · ${formatEventDateRange(event)}`, '/calendar')),
},
],
},
{
id: 'club-status',
title: 'Vereinsstatus',
cards: [
{
title: 'Mitglieder',
value: `${members.length} aktiv`,
meta: members.length > 0 ? `${members.filter((member) => {
const createdAt = new Date(member.createdAt);
return createdAt.getFullYear() === today.getFullYear();
}).length} dieses Jahr angelegt` : null,
to: '/members',
items: recentMembers.map((member) => {
const name = [member.firstName, member.lastName].filter(Boolean).join(' ').trim() || member.email || `Mitglied ${member.id}`;
return createDashboardItem(name, buildMemberRoute(member.id, 'active'));
}),
},
{
title: 'Anfragen',
value: `${requests.length}`,
meta: `${openRequestCount} offen, ${inProgressRequestCount} in Bearbeitung`,
to: '/club-requests',
},
{
title: 'Workflow-Fortschritt',
value: `${workflowStageCounts.onboarding_completed || 0}`,
meta: 'Onboardings abgeschlossen',
to: '/club-requests',
items: [
createDashboardItem(`${workflowStageCounts.admission_prepared || 0} Aufnahmen vorbereitet`, '/club-requests'),
createDashboardItem(`${workflowStageCounts.member_record_created || 0} Mitglieder angelegt`, '/club-requests'),
createDashboardItem(`${workflowStageCounts.sepa_pending || 0} warten auf SEPA`, '/club-requests'),
],
},
{
title: 'Finanzen',
value: paidRatio === null ? 'Keine Daten' : `${paidRatio} % erledigt`,
meta: paymentClaims.length > 0 ? `${paymentClaims.length} offene oder teilweise offene Forderungen` : 'Noch keine Beitragsforderungen erfasst',
to: '/club-payments',
},
],
},
{
id: 'recent-activity',
title: 'Letzte Aktivitäten',
cards: [
{
title: 'Zuletzt eingegangen',
to: '/club-requests',
items: requests.slice(0, 4).map((request) => {
const name = [request.firstName, request.lastName].filter(Boolean).join(' ').trim() || request.email || 'Unbekannt';
const workflow = request.workflowStage ? ` · ${formatRequestWorkflowStage(request.workflowStage)}` : '';
return createDashboardItem(
`${name} · ${request.subject || request.requestType}${workflow}`,
buildRequestRoute(request.id, request.status || '')
);
}),
},
{
title: 'Aktuelle Aufgaben',
to: '/club-tasks',
items: visibleDashboardTasks.slice(0, 4).map((task) => taskDashboardItem(task, `${formatTaskType(task.taskType)} · ${task.status}`)),
},
{
title: 'Automatik zuletzt aktiv',
to: '/club-tasks',
items: automatedTasks.slice(0, 4).map((task) => {
const sourceLabel = task.automationSource === 'club_requests'
? 'Anfrage'
: task.automationSource === 'club_payment_claims'
? 'Zahlung'
: task.automationSource === 'club_documents'
? 'Dokument'
: task.automationSource === 'club_invoice_parties'
? 'Sponsor'
: task.automationSource === 'club_invoices'
? 'Rechnung'
: task.automationSource === 'club_communication'
? 'Kommunikation'
: task.automationSource === 'calendar_events'
? 'Termin'
: 'Workflow';
return taskDashboardItem(task, `${formatTaskType(task.taskType)} · ${sourceLabel}`);
}),
},
],
},
];
res.status(200).json({ sections });
} catch (error) {
console.error('[getClubDashboard] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Dashboard konnte nicht geladen werden.'),
});
}
};

View File

@@ -0,0 +1,111 @@
import multer from 'multer';
import fs from 'fs';
import path from 'path';
import clubDocumentService from '../services/clubDocumentService.js';
const storage = multer.diskStorage({
destination: (req, file, cb) => {
fs.mkdirSync('uploads/temp', { recursive: true });
cb(null, 'uploads/temp/');
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
cb(null, `${file.fieldname}-${uniqueSuffix}${path.extname(file.originalname)}`);
},
});
const upload = multer({
storage,
limits: {
fileSize: 20 * 1024 * 1024,
},
fileFilter: (req, file, cb) => {
const allowedExtensions = ['.pdf', '.doc', '.docx', '.txt', '.csv', '.odt', '.xlsx'];
const allowedMimePatterns = ['pdf', 'msword', 'wordprocessingml.document', 'text/plain', 'csv', 'excel', 'opendocument.text'];
const extensionValid = allowedExtensions.includes(path.extname(file.originalname).toLowerCase());
const mimetypeValid = allowedMimePatterns.some((pattern) => file.mimetype && file.mimetype.toLowerCase().includes(pattern));
if (extensionValid && mimetypeValid) {
return cb(null, true);
}
cb(new Error('Nur PDF, DOC, DOCX, TXT, CSV, ODT und XLSX Dateien sind erlaubt!'));
},
});
export const uploadMiddleware = upload.single('document');
function cleanupTempFile(file) {
if (file?.path && fs.existsSync(file.path)) {
fs.unlinkSync(file.path);
}
}
export const listClubDocuments = async (req, res) => {
try {
const documents = await clubDocumentService.listClubDocuments(Number(req.params.clubId), req.query || {});
res.status(200).json({ documents });
} catch (error) {
res.status(error.status || 500).json({ error: error.message || 'internalerror' });
}
};
export const createClubDocument = async (req, res) => {
try {
const document = await clubDocumentService.createClubDocument(Number(req.params.clubId), req.user?.id || null, req.body || {}, req.file || null);
res.status(201).json({ document });
} catch (error) {
cleanupTempFile(req.file);
res.status(error.status || 500).json({ error: error.message || 'internalerror' });
}
};
export const updateClubDocument = async (req, res) => {
try {
const document = await clubDocumentService.updateClubDocument(Number(req.params.clubId), Number(req.params.documentId), req.user?.id || null, req.body || {}, req.file || null);
res.status(200).json({ document });
} catch (error) {
cleanupTempFile(req.file);
res.status(error.status || 500).json({ error: error.message || 'internalerror' });
}
};
export const archiveClubDocument = async (req, res) => {
try {
const document = await clubDocumentService.archiveClubDocument(Number(req.params.clubId), Number(req.params.documentId));
res.status(200).json({ document });
} catch (error) {
res.status(error.status || 500).json({ error: error.message || 'internalerror' });
}
};
export const deleteClubDocument = async (req, res) => {
try {
await clubDocumentService.deleteClubDocument(Number(req.params.clubId), Number(req.params.documentId));
res.status(204).end();
} catch (error) {
res.status(error.status || 500).json({ error: error.message || 'internalerror' });
}
};
export const downloadClubDocument = async (req, res) => {
try {
const versionNo = req.query.versionNo ? Number(req.query.versionNo) : null;
const { document, version } = await clubDocumentService.getDocumentDownload(Number(req.params.clubId), Number(req.params.documentId), versionNo);
if (!fs.existsSync(version.storagePath)) {
return res.status(404).json({ error: 'filenotfound' });
}
res.setHeader('Content-Disposition', `inline; filename="${document.title || version.fileName}"`);
res.setHeader('Content-Type', version.mimeType || 'application/octet-stream');
res.sendFile(version.storagePath);
} catch (error) {
res.status(error.status || 500).json({ error: error.message || 'internalerror' });
}
};
export default {
listClubDocuments,
createClubDocument,
updateClubDocument,
archiveClubDocument,
deleteClubDocument,
downloadClubDocument,
};

View File

@@ -0,0 +1,93 @@
import clubInvoiceService from '../services/clubInvoiceService.js';
class ClubInvoiceController {
async listClubInvoices(req, res) {
try {
const { clubId } = req.params;
const payload = await clubInvoiceService.listClubInvoices(Number(clubId));
res.json(payload);
} catch (error) {
console.error('[listClubInvoices] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Rechnungen konnten nicht geladen werden.' });
}
}
async createInvoiceParty(req, res) {
try {
const { clubId } = req.params;
const party = await clubInvoiceService.createInvoiceParty(Number(clubId), req.body || {});
res.status(201).json({ party });
} catch (error) {
console.error('[createInvoiceParty] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Rechnungspartei konnte nicht gespeichert werden.' });
}
}
async updateInvoiceParty(req, res) {
try {
const { clubId, partyId } = req.params;
const party = await clubInvoiceService.updateInvoiceParty(Number(clubId), Number(partyId), req.body || {});
res.json({ party });
} catch (error) {
console.error('[updateInvoiceParty] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Rechnungspartei konnte nicht gespeichert werden.' });
}
}
async deleteInvoiceParty(req, res) {
try {
const { clubId, partyId } = req.params;
await clubInvoiceService.deleteInvoiceParty(Number(clubId), Number(partyId));
res.json({ success: true });
} catch (error) {
console.error('[deleteInvoiceParty] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Rechnungspartei konnte nicht gelöscht werden.' });
}
}
async createInvoice(req, res) {
try {
const { clubId } = req.params;
const invoice = await clubInvoiceService.createInvoice(Number(clubId), req.user?.id || null, req.body || {});
res.status(201).json({ invoice });
} catch (error) {
console.error('[createInvoice] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Rechnung konnte nicht gespeichert werden.' });
}
}
async updateInvoice(req, res) {
try {
const { clubId, invoiceId } = req.params;
const invoice = await clubInvoiceService.updateInvoice(Number(clubId), Number(invoiceId), req.body || {});
res.json({ invoice });
} catch (error) {
console.error('[updateInvoice] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Rechnung konnte nicht gespeichert werden.' });
}
}
async updateInvoiceStatus(req, res) {
try {
const { clubId, invoiceId } = req.params;
const invoice = await clubInvoiceService.updateInvoiceStatus(Number(clubId), Number(invoiceId), String(req.body?.status || ''));
res.json({ invoice });
} catch (error) {
console.error('[updateInvoiceStatus] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Rechnungsstatus konnte nicht gespeichert werden.' });
}
}
async deleteInvoice(req, res) {
try {
const { clubId, invoiceId } = req.params;
await clubInvoiceService.deleteInvoice(Number(clubId), Number(invoiceId));
res.json({ success: true });
} catch (error) {
console.error('[deleteInvoice] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Rechnung konnte nicht gelöscht werden.' });
}
}
}
export default new ClubInvoiceController();

View File

@@ -0,0 +1,73 @@
import clubPaymentClaimService from '../services/clubPaymentClaimService.js';
class ClubPaymentClaimController {
async list(req, res) {
try {
const payload = await clubPaymentClaimService.listClaims(Number(req.params.clubId));
res.json(payload);
} catch (error) {
console.error('[listClubPaymentClaims] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Zahlungsforderungen konnten nicht geladen werden.' });
}
}
async create(req, res) {
try {
const claim = await clubPaymentClaimService.createClaim(Number(req.params.clubId), req.body || {});
res.status(201).json({ claim });
} catch (error) {
console.error('[createClubPaymentClaim] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Zahlungsforderung konnte nicht gespeichert werden.' });
}
}
async update(req, res) {
try {
const claim = await clubPaymentClaimService.updateClaim(Number(req.params.clubId), Number(req.params.claimId), req.body || {});
res.json({ claim });
} catch (error) {
console.error('[updateClubPaymentClaim] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Zahlungsforderung konnte nicht gespeichert werden.' });
}
}
async updateStatus(req, res) {
try {
const claim = await clubPaymentClaimService.updateClaimStatus(
Number(req.params.clubId),
Number(req.params.claimId),
String(req.body?.status || '')
);
res.json({ claim });
} catch (error) {
console.error('[updateClubPaymentClaimStatus] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Status konnte nicht gespeichert werden.' });
}
}
async registerPayment(req, res) {
try {
const claim = await clubPaymentClaimService.registerPayment(
Number(req.params.clubId),
Number(req.params.claimId),
req.body || {}
);
res.json({ claim });
} catch (error) {
console.error('[registerClubPaymentClaimPayment] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Teilzahlung konnte nicht gespeichert werden.' });
}
}
async delete(req, res) {
try {
await clubPaymentClaimService.deleteClaim(Number(req.params.clubId), Number(req.params.claimId));
res.json({ success: true });
} catch (error) {
console.error('[deleteClubPaymentClaim] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Zahlungsforderung konnte nicht gelöscht werden.' });
}
}
}
export default new ClubPaymentClaimController();

View File

@@ -0,0 +1,174 @@
import { ClubRequest, ClubRequestNote } from '../models/index.js';
import { getSafeErrorMessage } from '../utils/errorUtils.js';
const TERMINAL_REQUEST_STATUSES = new Set(['converted', 'rejected', 'archived']);
function isMissingRequestTableError(error) {
return error?.original?.code === 'ER_NO_SUCH_TABLE'
&& /club_requests|club_request_notes/.test(String(error?.original?.sqlMessage || ''));
}
function normalizeRequestPayload(payload = {}) {
return {
requestType: payload.requestType || 'contact',
subject: payload.subject?.trim() || null,
firstName: payload.firstName?.trim() || null,
lastName: payload.lastName?.trim() || null,
email: payload.email?.trim() || null,
phone: payload.phone?.trim() || null,
message: payload.message?.trim() || null,
};
}
async function loadRequestOrThrow(clubId, requestId) {
const request = await ClubRequest.findOne({
where: {
id: requestId,
clubId,
},
include: [
{
model: ClubRequestNote,
as: 'notes',
required: false,
},
],
order: [[{ model: ClubRequestNote, as: 'notes' }, 'createdAt', 'DESC']],
});
if (!request) {
const error = new Error('Anfrage wurde nicht gefunden.');
error.statusCode = 404;
throw error;
}
return request;
}
export const listClubRequests = async (req, res) => {
try {
const { clubId } = req.params;
let requests = [];
try {
requests = await ClubRequest.findAll({
where: { clubId },
include: [
{
model: ClubRequestNote,
as: 'notes',
required: false,
},
],
order: [
['receivedAt', 'DESC'],
[{ model: ClubRequestNote, as: 'notes' }, 'createdAt', 'DESC'],
],
});
} catch (error) {
if (!isMissingRequestTableError(error)) {
throw error;
}
}
res.status(200).json({ requests });
} catch (error) {
console.error('[listClubRequests] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Anfragen konnten nicht geladen werden.'),
});
}
};
export const createClubRequest = async (req, res) => {
try {
const { clubId } = req.params;
const payload = normalizeRequestPayload(req.body);
if (!payload.subject && !payload.message) {
return res.status(400).json({ error: 'Betreff oder Nachricht sind erforderlich.' });
}
const request = await ClubRequest.create({
clubId,
...payload,
receivedAt: new Date(),
});
const created = await loadRequestOrThrow(clubId, request.id);
res.status(201).json({ request: created });
} catch (error) {
console.error('[createClubRequest] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Anfrage konnte nicht gespeichert werden.'),
});
}
};
export const updateClubRequest = async (req, res) => {
try {
const { clubId, requestId } = req.params;
const request = await loadRequestOrThrow(clubId, requestId);
const payload = normalizeRequestPayload(req.body);
await request.update(payload);
const updated = await loadRequestOrThrow(clubId, requestId);
res.status(200).json({ request: updated });
} catch (error) {
console.error('[updateClubRequest] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Anfrage konnte nicht gespeichert werden.'),
});
}
};
export const updateClubRequestStatus = async (req, res) => {
try {
const { clubId, requestId } = req.params;
const { status } = req.body || {};
if (!status) {
return res.status(400).json({ error: 'Status fehlt.' });
}
const request = await loadRequestOrThrow(clubId, requestId);
await request.update({
status,
closedAt: TERMINAL_REQUEST_STATUSES.has(status) ? new Date() : null,
});
const updated = await loadRequestOrThrow(clubId, requestId);
res.status(200).json({ request: updated });
} catch (error) {
console.error('[updateClubRequestStatus] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Status konnte nicht gespeichert werden.'),
});
}
};
export const addClubRequestNote = async (req, res) => {
try {
const { clubId, requestId } = req.params;
const body = String(req.body?.body || '').trim();
if (!body) {
return res.status(400).json({ error: 'Notiztext fehlt.' });
}
await loadRequestOrThrow(clubId, requestId);
await ClubRequestNote.create({
clubRequestId: requestId,
createdByUserId: req.user?.id || null,
body,
});
const updated = await loadRequestOrThrow(clubId, requestId);
res.status(201).json({ request: updated });
} catch (error) {
console.error('[addClubRequestNote] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Notiz konnte nicht gespeichert werden.'),
});
}
};

View File

@@ -0,0 +1,19 @@
import clubStatisticsService from '../services/clubStatisticsService.js';
class ClubStatisticsController {
async getClubStatistics(req, res) {
try {
const { clubId } = req.params;
const statistics = await clubStatisticsService.getClubStatistics(clubId);
res.json(statistics);
} catch (error) {
console.error('[getClubStatistics] - Error:', error);
if (error?.status) {
return res.status(error.status).json({ error: error.message });
}
res.status(500).json({ error: 'Fehler beim Laden der Vereinsstatistiken' });
}
}
}
export default new ClubStatisticsController();

View File

@@ -0,0 +1,275 @@
import { ClubTask, User, UserClub } from '../models/index.js';
import { getSafeErrorMessage } from '../utils/errorUtils.js';
import clubTaskAutomationService from '../services/clubTaskAutomationService.js';
import clubWorkflowSourceService from '../services/clubWorkflowSourceService.js';
const TERMINAL_TASK_STATUSES = new Set(['done', 'cancelled', 'archived']);
function isMissingTaskTableError(error) {
return error?.original?.code === 'ER_NO_SUCH_TABLE'
&& /club_tasks/.test(String(error?.original?.sqlMessage || ''));
}
function isMissingTaskSuppressionTableError(error) {
return error?.original?.code === 'ER_NO_SUCH_TABLE'
&& /club_task_suppressions/.test(String(error?.original?.sqlMessage || ''));
}
function normalizeTaskPayload(payload = {}) {
return {
title: String(payload.title || '').trim(),
taskType: payload.taskType?.trim() || null,
description: payload.description?.trim() || null,
status: payload.status || 'open',
priority: payload.priority || 'normal',
dueAt: payload.dueAt || null,
remindAt: payload.remindAt || null,
assignedUserId: payload.assignedUserId ? Number(payload.assignedUserId) : null,
automationSource: payload.automationSource?.trim() || null,
automationKey: payload.automationKey?.trim() || null,
relatedEntityType: payload.relatedEntityType?.trim() || null,
relatedEntityId: payload.relatedEntityId ? Number(payload.relatedEntityId) : null,
sourceSnapshot: payload.sourceSnapshot || null,
};
}
async function loadAssignableUsers(clubId) {
const entries = await UserClub.findAll({
where: { clubId },
include: [{ model: User, as: 'user', attributes: ['id', 'email'] }],
order: [[{ model: User, as: 'user' }, 'email', 'ASC']],
});
return entries
.filter((entry) => entry.user)
.filter((entry) => entry.approved || entry.isOwner)
.map((entry) => ({
userId: entry.userId,
email: entry.user.email,
isOwner: Boolean(entry.isOwner),
approved: Boolean(entry.approved),
}));
}
async function validateAssignedUser(clubId, assignedUserId) {
if (!assignedUserId) {
return null;
}
const userClub = await UserClub.findOne({
where: {
clubId,
userId: assignedUserId,
},
});
if (!userClub || (!userClub.approved && !userClub.isOwner)) {
const error = new Error('Der zugewiesene Benutzer gehört nicht zu diesem Verein.');
error.statusCode = 400;
throw error;
}
return assignedUserId;
}
async function loadTaskOrThrow(clubId, taskId) {
const task = await ClubTask.findOne({
where: {
id: taskId,
clubId,
},
});
if (!task) {
const error = new Error('Aufgabe wurde nicht gefunden.');
error.statusCode = 404;
throw error;
}
return task;
}
export const listClubTasks = async (req, res) => {
try {
const { clubId } = req.params;
let tasks = [];
let automationOverview = { definitions: [], suggestions: [] };
try {
[tasks, automationOverview] = await Promise.all([
ClubTask.findAll({
where: { clubId },
include: [{ model: User, as: 'assignedUser', attributes: ['id', 'email'], required: false }],
order: [
['status', 'ASC'],
['dueAt', 'ASC'],
['updatedAt', 'DESC'],
],
}),
clubTaskAutomationService.buildAutomationOverview(clubId),
]);
} catch (error) {
if (!isMissingTaskTableError(error)) {
throw error;
}
}
const assignableUsers = await loadAssignableUsers(clubId);
res.status(200).json({
tasks,
taskDefinitions: automationOverview.definitions,
workflowSources: automationOverview.workflowSources || [],
taskSuggestions: automationOverview.suggestions,
assignableUsers,
});
} catch (error) {
console.error('[listClubTasks] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Aufgaben konnten nicht geladen werden.'),
});
}
};
export const createClubTask = async (req, res) => {
try {
const { clubId } = req.params;
const payload = normalizeTaskPayload(req.body);
if (!payload.title) {
return res.status(400).json({ error: 'Titel ist erforderlich.' });
}
payload.assignedUserId = await validateAssignedUser(clubId, payload.assignedUserId);
const task = await ClubTask.create({
clubId,
...payload,
createdByUserId: req.user?.id || null,
});
res.status(201).json({ task });
} catch (error) {
console.error('[createClubTask] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Aufgabe konnte nicht gespeichert werden.'),
});
}
};
export const updateClubTask = async (req, res) => {
try {
const { clubId, taskId } = req.params;
const task = await loadTaskOrThrow(clubId, taskId);
const payload = normalizeTaskPayload(req.body);
const wasDoneBefore = task.status === 'done';
if (!payload.title) {
return res.status(400).json({ error: 'Titel ist erforderlich.' });
}
payload.assignedUserId = await validateAssignedUser(clubId, payload.assignedUserId);
await task.update({
...payload,
completedAt: TERMINAL_TASK_STATUSES.has(payload.status) ? (task.completedAt || new Date()) : null,
archivedAt: payload.status === 'archived' ? (task.archivedAt || new Date()) : null,
});
const followUpTasks = !wasDoneBefore && payload.status === 'done'
? await clubTaskAutomationService.materializeWorkflowFollowUps(task, req.user?.id || null)
: [];
const sourceUpdate = !wasDoneBefore && payload.status === 'done'
? await clubWorkflowSourceService.syncSourceStateForCompletedTask(task)
: null;
res.status(200).json({ task, followUpTasks, sourceUpdate });
} catch (error) {
console.error('[updateClubTask] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Aufgabe konnte nicht gespeichert werden.'),
});
}
};
export const updateClubTaskStatus = async (req, res) => {
try {
const { clubId, taskId } = req.params;
const { status } = req.body || {};
if (!status) {
return res.status(400).json({ error: 'Status fehlt.' });
}
const task = await loadTaskOrThrow(clubId, taskId);
const wasDoneBefore = task.status === 'done';
await task.update({
status,
completedAt: TERMINAL_TASK_STATUSES.has(status) ? (task.completedAt || new Date()) : null,
archivedAt: status === 'archived' ? (task.archivedAt || new Date()) : null,
});
const followUpTasks = !wasDoneBefore && status === 'done'
? await clubTaskAutomationService.materializeWorkflowFollowUps(task, req.user?.id || null)
: [];
const sourceUpdate = !wasDoneBefore && status === 'done'
? await clubWorkflowSourceService.syncSourceStateForCompletedTask(task)
: null;
res.status(200).json({ task, followUpTasks, sourceUpdate });
} catch (error) {
console.error('[updateClubTaskStatus] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Status konnte nicht gespeichert werden.'),
});
}
};
export const materializeAutomatedClubTasks = async (req, res) => {
try {
const { clubId } = req.params;
const automationKeys = Array.isArray(req.body?.automationKeys) ? req.body.automationKeys : [];
if (automationKeys.length === 0) {
return res.status(400).json({ error: 'Es wurden keine Automatik-Schlüssel übergeben.' });
}
const tasks = await clubTaskAutomationService.materializeSuggestions(clubId, req.user?.id || null, automationKeys);
res.status(201).json({ tasks });
} catch (error) {
console.error('[materializeAutomatedClubTasks] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Automatische Aufgaben konnten nicht erstellt werden.'),
});
}
};
export const dismissAutomatedClubTaskSuggestion = async (req, res) => {
try {
const { clubId } = req.params;
const payload = req.body || {};
const suppression = await clubTaskAutomationService.dismissSuggestion(clubId, req.user?.id || null, payload);
res.status(200).json({ success: true, suppression });
} catch (error) {
console.error('[dismissAutomatedClubTaskSuggestion] - Error:', error);
if (isMissingTaskSuppressionTableError(error)) {
return res.status(500).json({
error: 'Die Tabelle club_task_suppressions fehlt noch. Bitte die aktuelle SQL-Datei auf dem System ausführen.',
});
}
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Vorschlag konnte nicht ausgeblendet werden.'),
});
}
};
export const deleteClubTask = async (req, res) => {
try {
const { clubId, taskId } = req.params;
const task = await loadTaskOrThrow(clubId, taskId);
await task.destroy();
res.status(200).json({ success: true });
} catch (error) {
console.error('[deleteClubTask] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Aufgabe konnte nicht gelöscht werden.'),
});
}
};

0
backend/controllers/clubTeamController.js Normal file → Executable file
View File

0
backend/controllers/clubVenueController.js Normal file → Executable file
View File

14
backend/controllers/clubsController.js Normal file → Executable file
View File

@@ -67,7 +67,12 @@ export const updateClubSettings = async (req, res) => {
autoFetchRankings,
countryCode,
stateCode,
memberDataQualityRequirements
memberDataQualityRequirements,
feeRules,
outgoingInvoicePrefix,
outgoingInvoiceNextNumber,
incomingInvoicePrefix,
incomingInvoiceNextNumber
} = req.body;
const updated = await ClubService.updateClubSettings(token, clubid, {
greetingText,
@@ -76,7 +81,12 @@ export const updateClubSettings = async (req, res) => {
autoFetchRankings,
countryCode,
stateCode,
memberDataQualityRequirements
memberDataQualityRequirements,
feeRules,
outgoingInvoicePrefix,
outgoingInvoiceNextNumber,
incomingInvoicePrefix,
incomingInvoiceNextNumber
});
res.status(200).json(updated);
} catch (error) {

0
backend/controllers/diaryController.js Normal file → Executable file
View File

0
backend/controllers/diaryDateActivityController.js Normal file → Executable file
View File

0
backend/controllers/diaryDateTagController.js Normal file → Executable file
View File

0
backend/controllers/diaryMemberActivityController.js Normal file → Executable file
View File

0
backend/controllers/diaryMemberController.js Normal file → Executable file
View File

0
backend/controllers/diaryNoteController.js Normal file → Executable file
View File

0
backend/controllers/diaryTagController.js Normal file → Executable file
View File

0
backend/controllers/friendlyMatchController.js Normal file → Executable file
View File

View File

0
backend/controllers/friendlyMatchSharedController.js Normal file → Executable file
View File

0
backend/controllers/groupController.js Normal file → Executable file
View File

6
backend/controllers/matchController.js Normal file → Executable file
View File

@@ -4,7 +4,7 @@ import { emitScheduleMatchUpdated } from '../services/socketService.js';
import { devLog } from '../utils/logger.js';
export const uploadCSV = async (req, res) => {
try {
const { clubId } = req.body;
const clubId = req.body?.clubId ?? req.query?.clubId;
const { authcode: userToken} = req.headers;
const filePath = req.file.path;
const result = await MatchService.importCSV(userToken, clubId, filePath);
@@ -16,7 +16,9 @@ export const uploadCSV = async (req, res) => {
});
} catch (error) {
console.error('Error importing CSV:', error);
return res.status(500).json({ error: 'Failed to import CSV data' });
return res.status(500).json({
error: error.message || 'Failed to import CSV data'
});
}
};

0
backend/controllers/memberActivityController.js Normal file → Executable file
View File

317
backend/controllers/memberController.js Normal file → Executable file
View File

@@ -1,7 +1,20 @@
import { Op } from 'sequelize';
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 CalendarEvent from '../models/CalendarEvent.js';
import TrainingGroup from '../models/TrainingGroup.js';
import ClubCommunicationRecipient from '../models/ClubCommunicationRecipient.js';
import ClubCommunicationThread from '../models/ClubCommunicationThread.js';
import ClubCommunicationMessage from '../models/ClubCommunicationMessage.js';
import ClubSepaMandate from '../models/ClubSepaMandate.js';
import MemberProfileChangeRequest from '../models/MemberProfileChangeRequest.js';
import MemberEventResponse from '../models/MemberEventResponse.js';
import Participant from '../models/Participant.js';
import DiaryDate from '../models/DiaryDates.js';
import { devLog } from '../utils/logger.js';
const getClubMembers = async(req, res) => {
@@ -28,12 +41,13 @@ const getWaitingApprovals = async(req, res) => {
const setClubMembers = async (req, res) => {
try {
const { id: memberId, firstname: firstName, lastname: lastName, street, city, postalCode, birthdate, phone, email, active,
testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, adultReleaseApproved, adultReserveApproved, contacts } = req.body;
const { id: memberId, firstname: firstName, lastname: lastName, street, city, postalCode, birthdate, phone, email, active,
testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, adultReleaseApproved, adultReserveApproved,
contributionGroupCode, contacts } = req.body;
const { id: clubId } = req.params;
const { authcode: userToken } = req.headers;
const addResult = await MemberService.setClubMember(userToken, clubId, memberId, firstName, lastName, street, city, postalCode, birthdate,
phone, email, active, testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, adultReleaseApproved, adultReserveApproved, contacts);
phone, email, active, testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, adultReleaseApproved, adultReserveApproved, contributionGroupCode, contacts);
// Emit Socket-Event wenn Member erfolgreich erstellt/aktualisiert wurde
if (addResult.status === 200) {
@@ -47,6 +61,33 @@ const setClubMembers = async (req, res) => {
}
}
const getMemberSepaMandate = async (req, res) => {
try {
const { clubId, memberId } = req.params;
const { authcode: userToken } = req.headers;
const result = await MemberService.getMemberSepaMandate(userToken, Number(clubId), Number(memberId));
res.status(result.status || 500).json(result.response);
} catch (error) {
console.error('[getMemberSepaMandate] - Error:', error);
res.status(500).json({ success: false, error: 'SEPA-Mandat konnte nicht geladen werden.' });
}
};
const saveMemberSepaMandate = async (req, res) => {
try {
const { clubId, memberId } = req.params;
const { authcode: userToken } = req.headers;
const result = await MemberService.saveMemberSepaMandate(userToken, Number(clubId), Number(memberId), req.body || {});
if (result.status === 200) {
emitMemberChanged(clubId);
}
res.status(result.status || 500).json(result.response);
} catch (error) {
console.error('[saveMemberSepaMandate] - Error:', error);
res.status(500).json({ success: false, error: 'SEPA-Mandat konnte nicht gespeichert werden.' });
}
};
const getMemberPlayInterests = async (req, res) => {
try {
const { clubId } = req.params;
@@ -323,10 +364,267 @@ 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.' });
}
};
const getApprovedLinkedMember = async (clubId, userId) => {
const clubUser = await UserClub.findOne({ where: { clubId, userId, approved: true } });
if (!clubUser) return { error: 'Kein Zugriff auf diesen Verein.', status: 403 };
const member = await Member.findOne({ where: { clubId, userId, active: true } });
if (!member) return { error: 'Für dieses Konto ist kein aktives Mitgliedsprofil verknüpft.', status: 404 };
return { member };
};
const getMemberDashboard = async (req, res) => {
try {
const clubId = Number(req.params.clubId);
const userId = Number(req.user?.id);
const access = await getApprovedLinkedMember(clubId, userId);
if (access.error) return res.status(access.status).json({ error: access.error });
const { member } = access;
const today = new Date().toISOString().slice(0, 10);
const [events, trainingGroups, recipients, sepaMandate, changeRequests, trainingDates] = await Promise.all([
CalendarEvent.findAll({
where: { clubId, startDate: { [Op.gte]: today }, status: { [Op.notIn]: ['cancelled', 'archived'] } },
attributes: ['id', 'title', 'startDate', 'endDate', 'eventType', 'location', 'registrationDeadline'],
order: [['startDate', 'ASC']], limit: 5,
}),
TrainingGroup.findAll({ where: { clubId }, include: [{ model: Member, as: 'members', where: { id: member.id }, attributes: [], through: { attributes: [] } }, { association: 'trainingTimes', required: false }], order: [['name', 'ASC']] }),
ClubCommunicationRecipient.findAll({ where: { clubId, memberId: member.id, deliveryStatus: 'sent' }, include: [{ model: ClubCommunicationThread, as: 'thread', attributes: ['id', 'subject', 'sentAt'] }], order: [['deliveredAt', 'DESC']], limit: 5 }),
ClubSepaMandate.findOne({ where: { clubId, memberId: member.id, status: 'active', revokedAt: null }, attributes: ['id'] }),
MemberProfileChangeRequest.findAll({ where: { clubId, memberId: member.id, status: 'open' }, attributes: ['id', 'requestedChanges', 'createdAt'], order: [['createdAt', 'DESC']] }),
DiaryDate.findAll({ where: { clubId, date: { [Op.gte]: today } }, include: [{ model: Member, as: 'participants', where: { id: member.id }, required: false, through: { attributes: ['attendanceStatus'] } }], attributes: ['id', 'date', 'trainingStart', 'trainingEnd'], order: [['date', 'ASC']], limit: 5 }),
]);
const responses = await MemberEventResponse.findAll({ where: { clubId, memberId: member.id, eventId: events.map((event) => event.id) } });
const responseByEvent = new Map(responses.map((response) => [response.eventId, response.status]));
res.json({
profile: { id: member.id, firstName: member.firstName, lastName: member.lastName, email: member.email, phone: member.phone, street: member.street, postalCode: member.postalCode, city: member.city },
membership: { status: member.membershipStatus, type: member.membershipType, joinedOn: member.joinedOn, needsSepaMandate: member.needsSepaMandate, hasActiveSepaMandate: Boolean(sepaMandate) },
openProfileChangeRequests: changeRequests,
upcomingEvents: events.map((event) => ({ ...event.toJSON(), responseStatus: responseByEvent.get(event.id) || null })),
trainingGroups: trainingGroups.map((group) => ({ id: group.id, name: group.name, trainingTimes: group.trainingTimes || [] })),
upcomingTrainingDates: trainingDates.map((date) => ({ id: date.id, date: date.date, trainingStart: date.trainingStart, trainingEnd: date.trainingEnd, attendanceStatus: date.participants?.[0]?.Participant?.attendanceStatus || 'present' })),
messages: recipients.map((recipient) => ({ id: recipient.thread?.id || recipient.id, subject: recipient.thread?.subject || 'Vereinsnachricht', sentAt: recipient.deliveredAt || recipient.thread?.sentAt || null })),
});
} catch (error) {
console.error('[getMemberDashboard] - Error:', error);
res.status(500).json({ error: 'Persönlicher Bereich konnte nicht geladen werden.' });
}
};
const createProfileChangeRequest = async (req, res) => {
try {
const clubId = Number(req.params.clubId);
const userId = Number(req.user?.id);
const access = await getApprovedLinkedMember(clubId, userId);
if (access.error) return res.status(access.status).json({ error: access.error });
const allowedFields = ['email', 'phone', 'street', 'postalCode', 'city'];
const requestedChanges = Object.fromEntries(allowedFields
.filter((field) => typeof req.body?.[field] === 'string' && req.body[field].trim() !== '')
.map((field) => [field, req.body[field].trim()]));
if (!Object.keys(requestedChanges).length) return res.status(400).json({ error: 'Bitte mindestens ein gültiges Kontaktfeld angeben.' });
const request = await MemberProfileChangeRequest.create({ clubId, memberId: access.member.id, requestedByUserId: userId, requestedChanges });
res.status(201).json({ id: request.id, status: request.status, requestedChanges: request.requestedChanges, createdAt: request.createdAt });
} catch (error) {
console.error('[createProfileChangeRequest] - Error:', error);
res.status(500).json({ error: 'Änderungsanfrage konnte nicht eingereicht werden.' });
}
};
const respondToCalendarEvent = async (req, res) => {
try {
const clubId = Number(req.params.clubId);
const eventId = Number(req.params.eventId);
const status = req.body?.status;
if (!['attending', 'declined'].includes(status)) return res.status(400).json({ error: 'Ungültige Rückmeldung.' });
const access = await getApprovedLinkedMember(clubId, Number(req.user?.id));
if (access.error) return res.status(access.status).json({ error: access.error });
const event = await CalendarEvent.findOne({ where: { id: eventId, clubId } });
if (!event) return res.status(404).json({ error: 'Veranstaltung nicht gefunden.' });
const today = new Date().toISOString().slice(0, 10);
if (event.startDate < today || (event.registrationDeadline && event.registrationDeadline < today)) return res.status(409).json({ error: 'Für diese Veranstaltung sind keine Rückmeldungen mehr möglich.' });
const [response] = await MemberEventResponse.upsert({ clubId, eventId, memberId: access.member.id, status }, { returning: true });
res.json({ eventId, status: response.status });
} catch (error) {
console.error('[respondToCalendarEvent] - Error:', error);
res.status(500).json({ error: 'Rückmeldung konnte nicht gespeichert werden.' });
}
};
const updateOwnTrainingAttendance = async (req, res) => {
try {
const clubId = Number(req.params.clubId);
const diaryDateId = Number(req.params.diaryDateId);
const attendanceStatus = req.body?.attendanceStatus;
if (!['present', 'excused', 'cancelled'].includes(attendanceStatus)) return res.status(400).json({ error: 'Ungültiger Trainingsstatus.' });
const access = await getApprovedLinkedMember(clubId, Number(req.user?.id));
if (access.error) return res.status(access.status).json({ error: access.error });
const diaryDate = await DiaryDate.findOne({ where: { id: diaryDateId, clubId } });
if (!diaryDate) return res.status(404).json({ error: 'Trainingstag nicht gefunden.' });
const [participant] = await Participant.findOrCreate({ where: { diaryDateId, memberId: access.member.id }, defaults: { attendanceStatus } });
if (!participant.isNewRecord) await participant.update({ attendanceStatus });
res.json({ diaryDateId, attendanceStatus: participant.attendanceStatus });
} catch (error) {
console.error('[updateOwnTrainingAttendance] - Error:', error);
res.status(500).json({ error: 'Trainingsrückmeldung konnte nicht gespeichert werden.' });
}
};
const getEventResponses = async (req, res) => {
try {
const clubId = Number(req.params.clubId);
const eventId = Number(req.params.eventId);
const responses = await MemberEventResponse.findAll({ where: { clubId, eventId }, include: [{ model: Member, as: 'member', attributes: ['id', 'firstName', 'lastName'] }], order: [['updatedAt', 'DESC']] });
res.json(responses.map((response) => ({ id: response.id, memberId: response.memberId, status: response.status, updatedAt: response.updatedAt, member: response.member ? { id: response.member.id, firstName: response.member.firstName, lastName: response.member.lastName } : null })));
} catch (error) {
console.error('[getEventResponses] - Error:', error);
res.status(500).json({ error: 'Rückmeldungen konnten nicht geladen werden.' });
}
};
const getProfileChangeRequests = async (req, res) => {
try {
const clubId = Number(req.params.clubId);
const requests = await MemberProfileChangeRequest.findAll({
where: { clubId, ...(req.query.status ? { status: req.query.status } : {}) },
include: [{ model: Member, as: 'member', attributes: ['id', 'firstName', 'lastName'] }],
order: [['createdAt', 'DESC']],
});
res.json(requests.map((request) => ({ id: request.id, memberId: request.memberId, status: request.status, requestedChanges: request.requestedChanges, reviewNote: request.reviewNote, createdAt: request.createdAt, member: request.member ? { id: request.member.id, firstName: request.member.firstName, lastName: request.member.lastName } : null })));
} catch (error) {
console.error('[getProfileChangeRequests] - Error:', error);
res.status(500).json({ error: 'Änderungsanfragen konnten nicht geladen werden.' });
}
};
const reviewProfileChangeRequest = async (req, res) => {
try {
const clubId = Number(req.params.clubId);
const requestId = Number(req.params.requestId);
const status = req.body?.status;
const reviewNote = typeof req.body?.reviewNote === 'string' ? req.body.reviewNote.trim() : null;
if (!['approved', 'rejected'].includes(status)) return res.status(400).json({ error: 'Ungültiger Prüfstatus.' });
const request = await MemberProfileChangeRequest.findOne({ where: { id: requestId, clubId, status: 'open' }, include: [{ model: Member, as: 'member' }] });
if (!request || !request.member) return res.status(404).json({ error: 'Offene Änderungsanfrage nicht gefunden.' });
if (status === 'approved') await request.member.update(request.requestedChanges);
await request.update({ status, reviewNote, reviewedByUserId: req.user.id, reviewedAt: new Date() });
if (status === 'approved') emitMemberChanged(clubId);
res.json({ id: request.id, status: request.status });
} catch (error) {
console.error('[reviewProfileChangeRequest] - Error:', error);
res.status(500).json({ error: 'Änderungsanfrage konnte nicht geprüft werden.' });
}
};
const getMemberInbox = async (req, res) => {
try {
const clubId = Number(req.params.clubId);
const userId = Number(req.user?.id);
const access = await getApprovedLinkedMember(clubId, userId);
if (access.error) return res.status(access.status).json({ error: access.error });
const recipients = await ClubCommunicationRecipient.findAll({
where: { clubId, memberId: access.member.id, deliveryStatus: 'sent' },
include: [{
model: ClubCommunicationThread,
as: 'thread',
required: true,
where: { status: 'sent' },
attributes: ['id', 'subject', 'threadType', 'replyTo', 'sentAt'],
include: [{ model: ClubCommunicationMessage, as: 'messages', required: false, attributes: ['id', 'body', 'direction', 'messageType', 'createdByUserId', 'createdAt'] }],
}],
order: [['deliveredAt', 'DESC']],
});
const items = recipients.map((recipient) => {
const thread = recipient.thread;
const messages = (thread?.messages || [])
.filter((message) => message.messageType === 'message' && (message.direction === 'outbound' || (message.direction === 'inbound' && Number(message.createdByUserId) === userId)))
.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
.map((message) => ({ id: message.id, body: message.body, direction: message.direction, createdAt: message.createdAt }));
const latestOutbound = [...messages].reverse().find((message) => message.direction === 'outbound');
return {
recipientId: recipient.id,
threadId: thread.id,
subject: thread.subject,
threadType: thread.threadType,
sentAt: recipient.deliveredAt || thread.sentAt,
readAt: recipient.readAt,
canAskQuestion: Boolean(thread.replyTo),
preview: latestOutbound?.body || '',
messages,
};
});
res.json({ items, unreadCount: items.filter((item) => !item.readAt).length });
} catch (error) {
console.error('[getMemberInbox] - Error:', error);
res.status(500).json({ error: 'Nachrichten konnten nicht geladen werden.' });
}
};
const markInboxItemRead = async (req, res) => {
try {
const clubId = Number(req.params.clubId);
const recipientId = Number(req.params.recipientId);
const access = await getApprovedLinkedMember(clubId, Number(req.user?.id));
if (access.error) return res.status(access.status).json({ error: access.error });
const recipient = await ClubCommunicationRecipient.findOne({ where: { id: recipientId, clubId, memberId: access.member.id, deliveryStatus: 'sent' } });
if (!recipient) return res.status(404).json({ error: 'Nachricht nicht gefunden.' });
if (!recipient.readAt) await recipient.update({ readAt: new Date() });
res.json({ recipientId, readAt: recipient.readAt });
} catch (error) {
console.error('[markInboxItemRead] - Error:', error);
res.status(500).json({ error: 'Lesestatus konnte nicht gespeichert werden.' });
}
};
const askInboxQuestion = async (req, res) => {
try {
const clubId = Number(req.params.clubId);
const recipientId = Number(req.params.recipientId);
const userId = Number(req.user?.id);
const body = String(req.body?.body || '').trim();
if (!body) return res.status(400).json({ error: 'Bitte formuliere deine Rückfrage.' });
if (body.length > 4000) return res.status(400).json({ error: 'Die Rückfrage ist zu lang.' });
const access = await getApprovedLinkedMember(clubId, userId);
if (access.error) return res.status(access.status).json({ error: access.error });
const recipient = await ClubCommunicationRecipient.findOne({
where: { id: recipientId, clubId, memberId: access.member.id, deliveryStatus: 'sent' },
include: [{ model: ClubCommunicationThread, as: 'thread', required: true, attributes: ['id', 'status', 'replyTo'] }],
});
if (!recipient?.thread || recipient.thread.status !== 'sent' || !recipient.thread.replyTo) return res.status(409).json({ error: 'Für diese Nachricht ist keine Rückfrage vorgesehen.' });
const message = await ClubCommunicationMessage.create({ threadId: recipient.thread.id, clubId, body, direction: 'inbound', messageType: 'message', createdByUserId: userId });
if (!recipient.readAt) await recipient.update({ readAt: new Date() });
res.status(201).json({ id: message.id, body: message.body, direction: message.direction, createdAt: message.createdAt });
} catch (error) {
console.error('[askInboxQuestion] - Error:', error);
res.status(500).json({ error: 'Rückfrage konnte nicht gesendet werden.' });
}
};
export {
getClubMembers,
getWaitingApprovals,
setClubMembers,
getMemberSepaMandate,
saveMemberSepaMandate,
getMemberPlayInterests,
setMemberPlayInterest,
uploadMemberImage,
@@ -342,5 +640,16 @@ export {
requestClickTtPlayerRegistration,
deleteMemberImage,
setPrimaryMemberImage,
generateMemberGallery
generateMemberGallery,
linkMemberUser,
getMemberDashboard,
createProfileChangeRequest,
respondToCalendarEvent,
updateOwnTrainingAttendance,
getEventResponses,
getProfileChangeRequests,
reviewProfileChangeRequest,
getMemberInbox,
markInboxItemRead,
askInboxQuestion
};

0
backend/controllers/memberGroupPhotoController.js Normal file → Executable file
View File

0
backend/controllers/memberNoteController.js Normal file → Executable file
View File

0
backend/controllers/memberOrderController.js Normal file → Executable file
View File

0
backend/controllers/memberTransferConfigController.js Normal file → Executable file
View File

0
backend/controllers/mobileFeedbackController.js Normal file → Executable file
View File

0
backend/controllers/myTischtennisController.js Normal file → Executable file
View File

76
backend/controllers/myTischtennisUrlController.js Normal file → Executable file
View File

@@ -10,6 +10,7 @@ import Season from '../models/Season.js';
import User from '../models/User.js';
import HttpError from '../exceptions/HttpError.js';
import { devLog } from '../utils/logger.js';
import { hasUserClubAccess } from '../utils/userUtils.js';
import { randomUUID } from 'crypto';
const teamDataFetchJobs = new Map();
@@ -249,39 +250,37 @@ class MyTischtennisUrlController {
throw new HttpError('Club team not found', 404);
}
let league;
// First, try to find existing league by name and season
const leagueName = completeData.leagueName || completeData.groupname;
league = await League.findOne({
where: {
name: leagueName,
seasonId: season.id,
clubId: team.clubId
let league;
// The league chosen for a club team is also used by the schedule filters.
// Keep that assignment and only enrich it with the MyTischtennis metadata.
if (team.leagueId) {
league = await League.findOne({
where: { id: team.leagueId, clubId: team.clubId }
});
if (league) {
devLog(`Keeping team's existing league: ${league.name} (ID: ${league.id})`);
}
});
}
if (!league) {
league = await League.findOne({
where: {
name: leagueName,
seasonId: season.id,
clubId: team.clubId
}
});
}
if (league) {
devLog(`Found existing league: ${league.name} (ID: ${league.id})`);
// Update myTischtennis fields
devLog(`Using existing league: ${league.name} (ID: ${league.id})`);
await league.update({
myTischtennisGroupId: completeData.groupId,
association: completeData.association,
groupname: completeData.groupname
});
} else if (team.leagueId) {
// Team has a league assigned, update it
league = await League.findByPk(team.leagueId);
if (league) {
devLog(`Updating team's existing league: ${league.name} (ID: ${league.id})`);
await league.update({
name: leagueName,
myTischtennisGroupId: completeData.groupId,
association: completeData.association,
groupname: completeData.groupname
});
}
} else if (createLeague) {
// Create new league
devLog(`Creating new league: ${leagueName}`);
@@ -635,15 +634,34 @@ class MyTischtennisUrlController {
/**
* Configure league from myTischtennis table URL
* POST /api/mytischtennis/configure-league
* Body: { url: string, createSeason?: boolean }
* Body: { url: string, clubId: number, createSeason?: boolean }
*/
async configureLeague(req, res, next) {
try {
const { url, createSeason } = req.body;
const { url, createSeason, clubId } = req.body;
const userIdOrEmail = req.headers.userid;
if (!url) {
throw new HttpError('URL is required', 400);
if (!url || !clubId) {
throw new HttpError('URL and clubId are required', 400);
}
let userId = userIdOrEmail;
if (isNaN(userIdOrEmail)) {
const user = await User.findOne({ where: { email: userIdOrEmail } });
if (!user) {
throw new HttpError('User not found', 404);
}
userId = user.id;
}
const normalizedClubId = Number.parseInt(clubId, 10);
if (!Number.isInteger(normalizedClubId) || normalizedClubId <= 0) {
throw new HttpError('clubId must be a valid number', 400);
}
const hasAccess = await hasUserClubAccess(userId, normalizedClubId);
if (!hasAccess) {
throw new HttpError('Keine Berechtigung für diesen Verein', 403);
}
// Parse URL
@@ -669,6 +687,7 @@ class MyTischtennisUrlController {
// Find or create league
let league = await League.findOne({
where: {
clubId: normalizedClubId,
myTischtennisGroupId: parsedData.groupId,
association: parsedData.association
}
@@ -677,6 +696,7 @@ class MyTischtennisUrlController {
if (!league) {
league = await League.create({
name: parsedData.groupnameOriginal, // Verwende die originale URL-kodierte Version
clubId: normalizedClubId,
myTischtennisGroupId: parsedData.groupId,
association: parsedData.association,
groupname: parsedData.groupnameOriginal, // Verwende die originale URL-kodierte Version

0
backend/controllers/officialTournamentController.js Normal file → Executable file
View File

0
backend/controllers/participantController.js Normal file → Executable file
View File

87
backend/controllers/permissionController.js Normal file → Executable file
View File

@@ -76,6 +76,29 @@ export const updateUserRole = async (req, res) => {
}
};
export const updateUserRoles = async (req, res) => {
try {
const { clubId, userId: targetUserId } = req.params;
const { roleIds } = req.body;
const updatingUserId = req.user.id;
const result = await permissionService.setUserRoles(
parseInt(targetUserId),
parseInt(clubId),
Array.isArray(roleIds) ? roleIds : [],
updatingUserId
);
res.json(result);
} catch (error) {
console.error('Error updating user roles:', error);
if (error.message && error.message.toLowerCase().includes('keine berechtigung')) {
return res.status(403).json({ error: error.message });
}
res.status(400).json({ error: error.message });
}
};
/**
* Update user custom permissions
*/
@@ -128,6 +151,62 @@ export const getPermissionStructure = async (req, res) => {
}
};
export const getClubRoles = async (req, res) => {
try {
const { clubId } = req.params;
const roles = await permissionService.getClubRoles(parseInt(clubId, 10), req.user.id);
res.json(roles);
} catch (error) {
console.error('Error getting club roles:', error);
if (error.message && error.message.toLowerCase().includes('keine berechtigung')) {
return res.status(403).json({ error: error.message });
}
res.status(400).json({ error: error.message });
}
};
export const createClubRole = async (req, res) => {
try {
const { clubId } = req.params;
const role = await permissionService.createClubRole(parseInt(clubId, 10), req.body || {}, req.user.id);
res.status(201).json(role);
} catch (error) {
console.error('Error creating club role:', error);
if (error.message && error.message.toLowerCase().includes('keine berechtigung')) {
return res.status(403).json({ error: error.message });
}
res.status(400).json({ error: error.message });
}
};
export const updateClubRole = async (req, res) => {
try {
const { clubId, roleId } = req.params;
const role = await permissionService.updateClubRole(parseInt(clubId, 10), parseInt(roleId, 10), req.body || {}, req.user.id);
res.json(role);
} catch (error) {
console.error('Error updating club role:', error);
if (error.message && error.message.toLowerCase().includes('keine berechtigung')) {
return res.status(403).json({ error: error.message });
}
res.status(400).json({ error: error.message });
}
};
export const deleteClubRole = async (req, res) => {
try {
const { clubId, roleId } = req.params;
const result = await permissionService.deleteClubRole(parseInt(clubId, 10), parseInt(roleId, 10), req.user.id);
res.json(result);
} catch (error) {
console.error('Error deleting club role:', error);
if (error.message && error.message.toLowerCase().includes('keine berechtigung')) {
return res.status(403).json({ error: error.message });
}
res.status(400).json({ error: error.message });
}
};
/**
* Update user status (activate/deactivate)
*/
@@ -158,10 +237,14 @@ export default {
getUserPermissions,
getClubMembersWithPermissions,
updateUserRole,
updateUserRoles,
updateUserPermissions,
updateUserStatus,
getAvailableRoles,
getPermissionStructure
getPermissionStructure,
getClubRoles,
createClubRole,
updateClubRole,
deleteClubRole,
};

0
backend/controllers/predefinedActivityController.js Normal file → Executable file
View File

View File

0
backend/controllers/seasonController.js Normal file → Executable file
View File

0
backend/controllers/sessionController.js Normal file → Executable file
View File

0
backend/controllers/teamController.js Normal file → Executable file
View File

0
backend/controllers/teamDocumentController.js Normal file → Executable file
View File

0
backend/controllers/tournamentController.js Normal file → Executable file
View File

0
backend/controllers/tournamentStagesController.js Normal file → Executable file
View File

0
backend/controllers/trainingCancellationController.js Normal file → Executable file
View File

0
backend/controllers/trainingGroupController.js Normal file → Executable file
View File

0
backend/controllers/trainingStatsController.js Normal file → Executable file
View File

0
backend/controllers/trainingTimeController.js Normal file → Executable file
View File

Some files were not shown because too many files have changed in this diff Show More