359 Commits

Author SHA1 Message Date
Torsten Schulz (local)
542fae089c implementierung der ersten schritte eine komplett-suite 2026-06-19 15:47:32 +02:00
Torsten Schulz (local)
111b37b287 android version upgrade
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 48s
2026-06-10 13:03:35 +02:00
Torsten Schulz (local)
8ef1f49118 fixed date fields
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 48s
2026-06-10 09:55:50 +02:00
Torsten Schulz (local)
8d1bce2ff9 Fixed tournament - groups in end round and place 3
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 47s
2026-06-10 08:08:31 +02:00
Torsten Schulz (local)
5423f24969 members filters as panels
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 48s
2026-06-09 16:01:04 +02:00
Torsten Schulz (local)
2fa7f9b537 Better dialogs
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 45s
2026-06-09 09:54:53 +02:00
Torsten Schulz (local)
16465fafc8 Feedback-Window
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 50s
2026-06-09 07:57:36 +02:00
Torsten Schulz (local)
f0142d5682 auto-set von "hat gespielt"
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 45s
2026-06-06 12:56:48 +02:00
Torsten Schulz (local)
5194d4582f Freundschaftsspiele korrigiert
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 52s
2026-06-06 12:42:17 +02:00
Torsten Schulz (local)
5727404f88 feat: Implement filtering for regular matches and update related functionalities
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 48s
2026-05-31 18:51:00 +02:00
Torsten Schulz (local)
0ff67dae80 Implement cross-club friendly match concept with invitations and shared matches
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 49s
- Added controllers for handling friendly match invitations and shared matches.
- Created migration scripts for `friendly_match_invitation` and `friendly_match_shared` tables.
- Developed models for `FriendlyMatchInvitation` and `FriendlyMatchShared`.
- Established routes for managing invitations and shared matches.
- Implemented services for business logic related to invitations and shared matches.
- Documented the concept plan for the new feature including API endpoints and data models.
2026-05-30 17:50:35 +02:00
Torsten Schulz (local)
359527eb5b feat: Implement localization for excludeFromBilling label in diary components
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
2026-05-30 16:14:28 +02:00
Torsten Schulz (local)
9d9481ac76 feat: Add excludeFromBilling option for diary dates and update related functionality
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
2026-05-30 16:10:34 +02:00
Torsten Schulz (local)
25f3802d66 Refactor code structure for improved readability and maintainability
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 45s
2026-05-30 15:53:07 +02:00
Torsten Schulz (local)
88d852719d feat: Dynamically adjust dialog height based on screen size in DiaryCourtDrawing
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 46s
2026-05-29 17:31:57 +02:00
Torsten Schulz (local)
d6e51cd8d2 feat: Sort members by first name and then last name in member lists
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
2026-05-28 18:03:11 +02:00
Torsten Schulz (local)
7ba25b2572 feat: Sort members alphabetically by first and last name in player selection and friendly match dialogs
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 47s
2026-05-28 17:59:53 +02:00
Torsten Schulz (local)
390d1a8897 feat: Update app version to 1.6.2 and increment version code to 17
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
2026-05-28 17:40:45 +02:00
Torsten Schulz (local)
7942e6108a feat: Add home/away game labels and participant count to friendly match schedule
Some checks failed
Deploy tt-tagebuch / deploy (push) Has been cancelled
2026-05-28 17:40:23 +02:00
Torsten Schulz (local)
211420444e feat: Enhance plan item display with visual representation and interaction options
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 45s
2026-05-28 13:08:41 +02:00
Torsten Schulz (local)
4cf0ee2be8 feat: Update match display logic to correctly reflect player sets and outcomes
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 45s
2026-05-28 11:02:25 +02:00
Torsten Schulz (local)
e57cdc6ad8 Refactor code structure for improved readability and maintainability
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 53s
2026-05-27 23:53:41 +02:00
Torsten Schulz (local)
2e7cf0c28d feat: Update age group categories in training stats for better accuracy
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 46s
2026-05-22 14:49:23 +02:00
Torsten Schulz (local)
75a17d42b5 Refactor code structure for improved readability and maintainability
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
2026-05-22 14:34:16 +02:00
Torsten Schulz (local)
6adf6b73e8 feat: Simplify plan duration display logic in diary activities
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
2026-05-22 14:16:39 +02:00
Torsten Schulz (local)
84c63bc7d2 feat: Add plan duration display to diary activities and enhance styling
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
2026-05-22 14:04:49 +02:00
Torsten Schulz (local)
0f946e9514 feat: Optimize activity ordering and enhance plan item movement functionality
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 45s
2026-05-22 13:48:43 +02:00
Torsten Schulz (local)
3814d9f178 feat: Update release build of the mobile app
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 47s
2026-05-20 12:29:14 +02:00
Torsten Schulz (local)
6aa544a1de feat: Enhance socket service for club management and event handling
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 46s
- Implemented club connection management in SocketService to handle joining and leaving clubs.
- Added event handling for various real-time updates including participant changes and diary notes.
- Updated AppRoot and DiaryDetailScreen to utilize new socket service features for real-time data synchronization.
- Introduced member portrait upload functionality in DiaryDetailScreen.
- Improved clipboard management across multiple screens for better user experience.
- Updated versioning in libs.versions.toml for app version increment.
- Refactored navigation icons to use AutoMirrored icons for better compatibility.
2026-05-20 11:36:00 +02:00
Torsten Schulz (local)
1c5457ae8c feat: Update app version to 1.4.3 and increment version code to 13; enhance BottomNavigationItem label display
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 47s
2026-05-18 14:25:26 +02:00
Torsten Schulz (local)
37de4e0cb5 feat: Update app version to 1.4.2 and increment version code to 12
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
2026-05-18 14:12:26 +02:00
Torsten Schulz (local)
ecfd3bf851 feat: Implement account deletion feature with UI and API integration
Some checks failed
Deploy tt-tagebuch / deploy (push) Has been cancelled
2026-05-18 14:12:14 +02:00
Torsten Schulz (local)
b9bbd45ae9 Refactor code structure and remove redundant sections for improved readability and maintainability
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 47s
2026-05-18 13:01:35 +02:00
Torsten Schulz (local)
197f06989f feat: Implement friendly match management with socket integration and UI updates
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 48s
2026-05-18 10:02:31 +02:00
Torsten Schulz (local)
f9ab3d9932 feat: Add friendly match management features including API integration and UI updates
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
- Implemented API methods for listing, creating, updating, and deleting friendly matches.
- Enhanced the ScheduleManager to handle friendly matches, including loading and state management.
- Updated UI components to support editing and displaying friendly match results.
- Modified localization files to reflect changes in terminology for match sets.
2026-05-18 09:39:00 +02:00
Torsten Schulz (local)
5dfdcb63bc feat: Implement friendly match management features
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
- Added backend support for managing friendly matches including listing, creating, updating, and deleting matches.
- Introduced a new database table `friendly_match` with relevant fields for match details.
- Created a service layer to handle business logic related to friendly matches.
- Developed API routes for friendly match operations with appropriate authentication and authorization.
- Added a Vue component for managing participants in friendly matches, allowing selection of members and manual entry of names.
- Updated existing tournament editor screens to integrate friendly match functionalities.
2026-05-18 00:43:42 +02:00
Torsten Schulz (local)
040e758044 feat: implement table distribution logic and update UI for match assignments
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
2026-05-18 00:06:56 +02:00
Torsten Schulz (local)
697e67d46e feat: implement table assignment and distribution for tournament matches
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
2026-05-17 23:55:39 +02:00
Torsten Schulz (local)
6c7ae6860b feat: separate backend base URL configurations for release and debug builds
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 46s
2026-05-17 22:46:07 +02:00
Torsten Schulz (local)
f8f1c797e7 feat: add number of tables to tournament updates and enhance related UI components
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 47s
2026-05-16 00:18:59 +02:00
Torsten Schulz (local)
40bd5e0745 feat: improve user token handling and add club selection clearing button in settings
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
2026-05-15 16:52:26 +02:00
Torsten Schulz (local)
d955577d9a feat: update app version to 1.3.2 and increment version code to 8
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
2026-05-15 16:27:45 +02:00
Torsten Schulz (local)
3d0c298af7 feat: add club selection clearing button and improve navigation logic after login
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
2026-05-15 16:26:45 +02:00
Torsten Schulz (local)
669f3f0365 feat: update app version to 1.3.1 and increment version code to 7
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 45s
2026-05-15 16:17:11 +02:00
Torsten Schulz (local)
eb54b4f7cf feat(i18n): add scripts for locale translation and patching
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 45s
- Implemented `fill-de-extended-gaps.js` to fill missing billing/orders keys in de-extended from de.
- Created `fill-i18n-deep.py` for deep translation of locale JSONs using deep-translator with fallback options.
- Added `fill-i18n-locales.js` to translate locale JSONs and write overrides for untranslated keys.
- Introduced `fix-en-leaks.py` to translate keys that still match the en-US merge, addressing English leaks.
- Developed `patch-de-ch-swiss.js` to replace 'ß' with 'ss' in de-CH.json without deleting existing entries.
- Created `patch-en-gb-au.js` to apply UK/AU spelling corrections in en-GB and en-AU locales.
- Added shell scripts `run-fix-en-leaks.sh` and `run-i18n-deep-fill.sh` for sequential execution of translation tasks.
- Implemented `update-i18n-todo-stats.js` to update statistics in the I18N_TODO.md file based on translation completeness.
2026-05-15 15:52:54 +02:00
Torsten Schulz (local)
320010b94e feat(Networking): enhance offline handling and localization for diary and members data
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
- Added `ACCESS_NETWORK_STATE` permission in AndroidManifest.xml to monitor network connectivity.
- Introduced `NetworkConnectivityHolder` to manage network state and trigger data refresh when connectivity is restored.
- Updated `DiaryManager` and `MembersManager` to support offline caching, allowing users to view previously loaded data when offline.
- Enhanced localization by adding new keys for offline cache hints in both German and English, improving user experience during connectivity issues.
- Updated UI components to display offline cache messages, ensuring users are informed when data is being served from cache.
2026-05-15 08:10:01 +02:00
Torsten Schulz (local)
2f15827658 feat(Calendar): update localization and enhance regression checklist
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 47s
- Updated the regression checklist to reflect the completion of Phase 14.
- Incremented versionCode to 4 and versionName to 1.2.0 in build.gradle.kts for the new release.
- Added new localization keys for calendar features in multiple languages, improving user accessibility and clarity.
- Enhanced the generate-mobile-i18n script to support new calendar translations, ensuring comprehensive coverage across locales.
2026-05-15 07:55:32 +02:00
Torsten Schulz (local)
bf3e1af084 feat(TrainingGroup): add excludeFromQuickDiaryCreate feature and update related logic
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
- Introduced a new boolean field `excludeFromQuickDiaryCreate` in the TrainingGroup model to control group visibility in quick diary creation.
- Updated the `updateTrainingGroup` service method to handle the new field, allowing for dynamic updates based on user input.
- Enhanced the TrainingTimesTab component to include a checkbox for excluding groups from quick diary creation, improving user interaction.
- Updated localization files to include new strings related to the exclude feature, ensuring clarity in multiple languages.
- Refactored logic in DiaryView and mobile app to consider the new exclusion criteria when suggesting training slots.
2026-05-14 22:47:02 +02:00
Torsten Schulz (local)
83294406a4 feat(Diary): implement quick create functionality for training days and enhance localization
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
- Added a new button for quick creation of training days in the DiaryView, improving user experience.
- Implemented logic to find the next available training slot across groups and create a training day entry.
- Enhanced localization by adding new keys for quick create messages in multiple languages, ensuring better accessibility for users.
- Updated the DiaryManager to handle quick create operations and clear errors effectively.
2026-05-14 22:35:29 +02:00
Torsten Schulz (local)
95a3e9438a chore: update versioning and launcher icons
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 46s
- Incremented versionCode to 2 and versionName to 1.0.1 in build.gradle.kts for the new release.
- Updated the ic_launcher_background color in ic_launcher_colors.xml to better match the app's branding.
- Replaced multiple launcher icon assets across various resolutions to ensure consistency in the app's appearance.
2026-05-14 19:27:39 +02:00
Torsten Schulz (local)
56ebffce69 fix: update application namespace and clean up deprecated files
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 45s
- Changed application namespace from `de.tt_tagebuch.app` to `de.tsschulz.tt_tagebuch` in build.gradle.kts and AndroidManifest.xml for consistency.
- Updated DEVELOPMENT.md to reflect the correct package name for the app.
- Removed deprecated files related to AppDependencies, MainActivity, MainApplication, and PDF generation, streamlining the codebase.
- Enhanced TODO.md to reflect the current status of the Android team planning phase implementation.
2026-05-14 19:17:51 +02:00
Torsten Schulz (local)
e0196a6617 feat(TeamManagement): enhance team management features and introduce planning phase for Android
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
- Updated TODO.md to outline the new planning phase for Android, aligning it with existing web functionalities for team management.
- Refactored AppDependencies to include TeamDocumentsApi, improving API integration for team-related documents.
- Replaced MobileTeamsScreen with TeamManagementScreen in ClubStammdatenScreens for better navigation.
- Enhanced TeamManagementScreen with improved state management and UI updates for team editing and data loading.
- Added new API methods in ClubTeamsApi for managing team lineups, supporting better team planning and organization.
- Introduced new methods in MatchesApi and MyTischtennisApi to enhance match and team data handling.
2026-05-14 18:31:15 +02:00
Torsten Schulz (local)
2f3f4fb275 feat(Tournament): update tournament participation UI and localization
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
- Changed the icon for tournament participations in the navigation to better represent the feature.
- Updated localization keys for tournament participations across multiple languages, enhancing clarity and user understanding.
- Introduced a new tab for official tournament participations in the mobile app, improving navigation and access to tournament details.
- Enhanced the TournamentsScreen to include options for creating and managing official tournaments, streamlining user interactions.
2026-05-14 17:51:19 +02:00
Torsten Schulz (local)
3d1dfe9a4c feat(Localization): enhance localization for tournament statistics and UI components
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
- Added new localization keys for tournament statistics panels across multiple languages, improving user accessibility.
- Updated the TournamentsScreen in the mobile app to include a search feature and display internal tournament statistics.
- Enhanced the Tournaments API to support fetching internal tournament statistics, providing detailed insights for users.
- Improved UI components for better organization and interaction within the tournaments section, enhancing overall user experience.
2026-05-14 16:25:16 +02:00
Torsten Schulz (local)
6ef1d79a5f feat(TrainingStats): enhance training statistics view with collapsible panels and localization
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 45s
- Refactored the TrainingStatsView to implement collapsible sections for better organization of training statistics.
- Added new localization keys for training statistics panels in both German and English.
- Updated the mobile app's TrainingStatsScreen to utilize the new collapsible panel structure, improving user experience.
- Enhanced the MembersManager to merge training statistics into member data, providing a comprehensive view of member participation.
- Introduced new API methods for quick updates and transfers of member data, streamlining member management processes.
2026-05-14 16:15:19 +02:00
Torsten Schulz (local)
7981371136 feat(TrainingCancellation): enhance cancellation functionality and localization support
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
- Updated the training cancellation controller to accept training group IDs, improving the cancellation process.
- Modified the database schema to include a JSON field for training group IDs in the training cancellations table.
- Enhanced the TrainingCancellation model to support the new training group IDs field.
- Updated the training cancellation service to normalize and handle training group IDs effectively.
- Added localization support for training cancellation features across multiple languages, improving user experience.
2026-05-13 10:57:23 +02:00
Torsten Schulz (local)
004801b1a6 feat(Calendar): integrate CalendarEvent model and enhance calendar functionality
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
- Added CalendarEvent model to the backend, establishing relationships with the Club model for better event management.
- Updated server.js to include calendarEventRoutes, enabling API access for calendar events.
- Enhanced CalendarView.vue to support custom event creation and management, improving user interaction with the calendar.
- Refactored various components to streamline event handling and improve overall user experience in the calendar interface.
- Updated TODO and DEVELOPMENT documentation to reflect new calendar features and architectural decisions.
2026-05-13 10:21:30 +02:00
Torsten Schulz (local)
9be5f50ede feat(TODO): update phases for orders, billing, and calendar features
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
- Marked orders and billing tasks as complete in the TODO list, detailing the associated components and APIs.
- Introduced a new phase for calendar features, outlining tasks for navigation, data loading, and event management.
- Enhanced the AppDependencies to include BillingApi and MemberOrdersApi for improved billing and order management.
- Updated AppRoot and SettingsScreen to incorporate billing and orders sections, enhancing user navigation and functionality.
2026-05-13 00:19:30 +02:00
Torsten Schulz (local)
ea46a6d4f9 feat(CalendarView): enhance training slot merging and event normalization
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
- Added a new method to normalize training time keys, improving consistency in event handling.
- Updated the logic for merging recurring training slots to include only relevant events, enhancing calendar management.
- Improved subtitle handling for merged training slots to reflect recurring status and additional details, providing clearer event information.
2026-05-13 00:11:03 +02:00
Torsten Schulz (local)
61b1f27e5e feat(CalendarView): merge recurring training slots and enhance event filtering
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 42s
- Implemented a new method to merge recurring training slots with identical weekdays and time windows, improving calendar event management.
- Updated event filtering logic to exclude cancelled training sessions, ensuring only relevant training events are displayed.
- Enhanced the loading process to handle source errors more effectively, improving user experience in the CalendarView.
2026-05-13 00:07:47 +02:00
Torsten Schulz (local)
54d9b9fc86 feat(MatchService): enhance match filtering for own teams and update mobile app settings
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
- Added logic in MatchService to filter matches based on the user's own teams, ensuring only relevant matches are displayed.
- Updated the mobile app's TODO list to reflect progress on ClubSettings and Predefined Activities features.
- Enhanced the AppRoot and ClubStammdatenScreens to support new settings and permissions for club management.
- Introduced new API methods for creating and updating training groups and times, improving the training management capabilities.
- Refactored MembersManager to include methods for managing training groups and times, streamlining the member management process.
2026-05-13 00:01:25 +02:00
Torsten Schulz (local)
57468f1efb feat(Tournament): add official tournament participation feature
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
- Introduced functionality to load and display official tournament participation events in the CalendarView.
- Updated the API client to fetch official tournament data, enhancing the event management capabilities.
- Added new UI elements to represent official tournaments, including visual indicators and event details.
- Enhanced the ClubManager to support fetching and managing official tournament data.
- Updated the mobile app's TODO list to reflect progress on tournament-related features.
2026-05-12 23:52:54 +02:00
Torsten Schulz (local)
bea5facb7d feat(ClubSettings): add country and state code fields for regional calendar data
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
- Introduced `countryCode` and `stateCode` fields in the Club model to support regional calendar data.
- Updated ClubSettings component to allow users to select their country and state, enhancing the configuration options for clubs.
- Enhanced the ClubService to handle normalization of country and state codes during updates.
- Added new routes and middleware to support the training cancellation feature and calendar integration in the backend.
- Updated frontend navigation to include a calendar link, improving user access to scheduling features.
2026-05-12 23:46:07 +02:00
Torsten Schulz (local)
1e23171370 feat(TournamentTab): add HTML escaping utility and improve player name rendering
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 42s
- Introduced `escapeHtml` method to sanitize HTML content, enhancing security against XSS attacks.
- Refactored player name rendering in tournament results to utilize the new HTML escaping method, ensuring safe display of player names and table data.
2026-05-12 23:23:04 +02:00
Torsten Schulz (local)
48f71b9df1 chore: update .gitignore and enhance backend and mobile app functionality
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
- Added mobile app build directories and configuration files to .gitignore for cleaner repository management.
- Improved error handling in diaryMemberController by requiring diaryDateId and memberId query parameters.
- Refactored DiaryMemberService to log tag IDs instead of raw values for better debugging.
- Enhanced TournamentParticipantsTab and TournamentTab components with improved touch-action properties for better user experience.
- Updated mobile app's gradle.properties and build.gradle.kts for compatibility with AGP 9.x and Kotlin 2.1.21, including new dependencies for Coil and UCrop.
- Refactored MainApplication to simplify initialization and improved MainActivity to handle dependencies more robustly.
- Updated various UI components in the mobile app to enhance layout and functionality, including MemberDetailScreen and MemberEditScreen.
2026-05-12 23:14:31 +02:00
Torsten Schulz (local)
27f8af559b fix(DiaryView): update plan status badge rendering logic
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 45s
- Enhanced the condition for displaying the plan status badge to ensure it only renders when a valid label is present, improving clarity in the activity display.
- Refactored the loading of the training plan to use a dedicated method, streamlining the data fetching process and improving code organization.
- Initialized activity member maps after loading the training plan to ensure accurate member tracking for activities.
2026-05-12 09:49:48 +02:00
Torsten Schulz (local)
be9caf92a4 refactor(DiaryDateActivityService): improve orderId calculation logic for group activities
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
- Updated the logic for determining the maximum orderId to handle cases with and without groupId more clearly.
- Enhanced code readability by consolidating the where clause for fetching max orderId based on group context.
2026-05-08 14:02:25 +02:00
Torsten Schulz (local)
808493c06e refactor(DiaryView): streamline activity posting logic by consolidating group handling
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
- Simplified the logic for posting activities by reducing the number of API calls when a group is selected.
- Updated the handling of `groupId` to ensure it is set correctly based on the selected group filter, improving code clarity and maintainability.
2026-05-08 13:57:31 +02:00
Torsten Schulz (local)
2b16cdff53 refactor(DiaryView): simplify grouped plan table structure and improve rendering logic
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
- Removed the "Gemeinsam" header from the grouped plan table for a cleaner layout.
- Adjusted the rendering logic to conditionally display shared items and group items more efficiently, enhancing clarity in the activity display.
- Updated colspan attributes to ensure proper alignment and presentation of table data.
2026-05-08 13:50:45 +02:00
Torsten Schulz (local)
9622e9bdb7 feat(DiaryView): implement grouped plan table for enhanced activity display
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 42s
- Added a new grouped plan table view in the DiaryView component to display activities organized by groups.
- Introduced computed properties `showGroupedPlanTable` and `groupedPlanRows` to manage the display logic and data structure for grouped activities.
- Enhanced the template to conditionally render the grouped view, improving user experience and clarity in activity management.
2026-05-08 13:11:56 +02:00
Torsten Schulz (local)
d1fb6d4e74 feat(DiaryView): improve sorting of training plan items by start time
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 42s
- Introduced a new `toMinutes` function to convert time strings into minutes for accurate sorting.
- Enhanced the `filteredTrainingPlan` computed property to sort items by start time, orderId, and id, ensuring a more organized display of training plans.
- Maintained existing filtering logic while integrating the new sorting functionality for improved user experience.
2026-05-08 13:04:49 +02:00
Torsten Schulz (local)
810ad07b96 feat(DiaryView): enhance time calculation for group activities
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
- Updated the `calculateAllItemTimes` method to improve handling of group activities, ensuring accurate start and end times based on groupId.
- Introduced a global cursor for managing time across individual and group activities, enhancing the overall scheduling logic.
- Modified the template to include a new condition for displaying the plan composer field, allowing for better activity management.
2026-05-08 12:59:30 +02:00
Torsten Schulz (local)
912bf88c3f feat(DiaryDateActivity, DiaryView): integrate groupId for enhanced activity management
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 42s
- Added `groupId` field to the DiaryDateActivity model to support group-specific activities.
- Updated create and update methods in DiaryDateActivityService to handle groupId, ensuring proper validation and association with groups.
- Enhanced DiaryView component to allow selection of groups for activities, improving user experience and activity organization.
- Implemented filtering logic in DiaryView to display activities based on selected group, enhancing data clarity and usability.
2026-05-08 11:58:57 +02:00
Torsten Schulz (local)
93796cecd6 feat(DiaryView, DiaryDateActivityService): implement group filtering and enhance activity display
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
- Added a group filter dropdown in the DiaryView component to allow users to filter group activities by selected group.
- Updated the DiaryDateActivityService to include groupId in the query for maximum orderId, improving activity management.
- Enhanced the display logic for group activities to reflect the selected filter, improving user experience and data clarity.
2026-05-08 11:35:22 +02:00
Torsten Schulz (local)
940f77e29b feat(DiaryDateActivity): enhance group activity management with duration and order features
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
- Added `duration`, `durationText`, and `orderId` fields to the GroupActivity model to support detailed activity tracking.
- Updated `addGroupActivity` and `updateGroupActivity` methods in the DiaryDateActivityService to handle new fields, improving activity management capabilities.
- Enhanced the DiaryView component to allow users to input and display duration and duration text for group activities, improving user experience and data clarity.
2026-05-08 11:28:22 +02:00
Torsten Schulz (local)
25c3b90972 feat(TeamManagementView): enhance league validation logic for team management
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
- Updated league validation to consider both leagueId and plannedLeagueName, allowing for more flexible team management workflows.
- Adjusted missing items logic to reflect the new validation criteria, improving user feedback on required fields.
- Enhanced tooltip and state management to accurately represent league requirements based on the updated validation rules.
2026-05-06 09:55:53 +02:00
Torsten Schulz (local)
95bfbf86a4 feat(MemberOrder, MemberOrderHistory, MemberOrderService, OrdersPanel): add paidConfirmed field and update related logic
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 42s
- Introduced a new boolean field `paidConfirmed` in MemberOrder and MemberOrderHistory models to track payment confirmation status.
- Updated serialization functions in MemberOrderService to include `paidConfirmed` in order and history entries.
- Enhanced OrdersPanel component to allow users to set and display the `paidConfirmed` status for orders.
- Added localization support for the new `paidConfirmed` label in German.
- Adjusted related logic to ensure proper handling of the `paidConfirmed` state throughout the application.
2026-05-06 09:05:28 +02:00
Torsten Schulz (local)
4bef76d6dd feat(TeamPlanningBoard): enhance team planning UI and button functionality
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
- Updated the TeamPlanningBoard component to improve the layout and styling of the planning team button, enhancing user interaction.
- Changed button title and aria-label for better accessibility and clarity, reflecting its new function to open the team in the workspace.
- Adjusted CSS styles for planning team inputs and buttons to ensure a more responsive design and improved visual consistency.
2026-05-06 08:52:45 +02:00
Torsten Schulz (local)
01aaca5928 feat(TeamPlanningBoard, TeamManagementView): add team conversion functionality
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
- Introduced a new button in TeamPlanningBoard for converting planning teams to regular teams, enhancing team management capabilities.
- Implemented the `convertPlanningTeamToRegular` method in TeamManagementView to handle the conversion process, including validation and UI updates.
- Updated event handling to ensure seamless integration of the new feature within the existing team management workflow.
2026-05-06 07:59:10 +02:00
Torsten Schulz (local)
92aebaadb1 feat(MemberGalleryDialog): filter visible members based on participant status
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
- Updated MemberGalleryDialog to introduce a computed property, `visibleGalleryMembers`, which filters gallery members based on their participant status and a custom visibility function.
- Modified the template to use `visibleGalleryMembers` instead of `galleryMembers`, ensuring only relevant members are displayed.
- Passed `participantStatusMap` as a prop to enhance member visibility logic, improving the user experience in the member gallery.
2026-05-05 15:05:40 +02:00
Torsten Schulz (local)
d84438f52c fix(TournamentGroupsTab): allow empty values in max group size input normalization
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 42s
- Updated the normalization function in TournamentGroupsTab to permit empty values for max group size input, enhancing flexibility for user input.
- This change improves the user experience by allowing users to clear the input field without triggering validation errors.
2026-04-29 09:35:03 +02:00
Torsten Schulz (local)
715980d49c feat(TournamentGroupsTab, TournamentTab): improve input normalization and event handling
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 42s
- Enhanced input fields in TournamentGroupsTab to utilize a normalization function for numeric inputs, allowing for empty values and enforcing minimum constraints.
- Added blur event handlers to trigger updates on input loss of focus, improving user experience.
- Updated TournamentTab to remove redundant modus change handling on advancingPerGroup update, streamlining the event flow.
2026-04-29 09:29:54 +02:00
Torsten Schulz (local)
cb7a027462 feat(TournamentGroupsTab, TournamentTab): enhance input handling and normalization
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
- Updated input fields in TournamentGroupsTab to use a normalization function for better handling of numeric inputs, allowing for empty values and ensuring minimum constraints.
- Added blur event handlers to trigger updates on input loss of focus, improving user experience.
- Modified TournamentTab to use nullish coalescing for safer access to group counts, enhancing robustness against undefined values.
2026-04-29 09:16:29 +02:00
Torsten Schulz (local)
9d58f24201 feat(i18n): add 'billing' translation to multiple languages
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 41s
- Added translations for the 'billing' navigation item in various localization files, including German, English, Spanish, French, Italian, Japanese, Polish, Thai, Filipino, and Chinese.
- Ensured consistent user experience across different locales by updating the relevant JSON files.
2026-04-28 16:57:57 +02:00
Torsten Schulz (local)
a7f967a730 feat(MembersOverviewSection, i18n): add QTTR sorting option and localization support
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 42s
- Introduced a new sorting option for QTTR values in the MembersOverviewSection, enhancing data organization capabilities.
- Updated localization files for multiple languages to include translations for the new QTTR sorting option, ensuring consistent user experience across different locales.
- Implemented logic to handle QTTR sorting in the MembersView, allowing users to sort members based on their QTTR values effectively.
2026-04-28 15:12:05 +02:00
Torsten Schulz (local)
87addd0c65 feat(MembersOverviewSection): add quick filter for training groups and improve layout
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
- Introduced a new quick filter dropdown for selecting training groups, enhancing user experience in the MembersOverviewSection.
- Removed the previous training group filter from the advanced filters section to streamline the interface.
- Updated styles for the quick filter group to improve layout and responsiveness.
2026-04-28 12:13:42 +02:00
Torsten Schulz (local)
5ef7447200 feat(MemberTransferService, MemberTransferDialog): clarify member transfer process and enhance UI feedback
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
- Updated MemberTransferService to explicitly load only active members with testMembership = false, improving clarity in the transfer logic.
- Added a UI hint in MemberTransferDialog to inform users that only active members will be transferred, enhancing user experience and transparency.
2026-04-27 15:12:49 +02:00
Torsten Schulz (local)
725ede8dbf feat(BillingService, BillingView): enhance locale handling for billing PDF generation
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 42s
- Added methods to normalize and format numbers based on locale in BillingService, improving internationalization support.
- Updated _fillSessionRows and _renderBillingPdfFromTemplate methods to accept locale as a parameter for consistent number formatting.
- Modified BillingView to pass the current locale when generating billing PDFs, ensuring accurate representation of numerical values.
2026-04-25 10:07:45 +02:00
Torsten Schulz (local)
2339e12410 feat(BillingController, BillingService): enhance billing template handling and error logging
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 41s
- Updated billingController to use a dynamic upload directory for billing templates, improving file management.
- Added error handling in billingService to log warnings when templates are missing or not found, enhancing debugging capabilities.
- Improved user feedback by returning specific error messages when template-related issues occur during billing runs.
2026-04-25 10:00:50 +02:00
Torsten Schulz (local)
be9d26e51e feat(BillingService): implement fallback file path resolution for migrated workspaces
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 42s
- Added logic to search for PDF templates in known upload directories when the base file name is provided.
- Enhanced the robustness of file handling by checking multiple potential template directories for existing files.
- Improved error handling by returning null for invalid base names, ensuring cleaner path resolution.
2026-04-25 09:52:31 +02:00
Torsten Schulz (local)
5f07a3e3d6 feat(BillingService): add file path resolution for PDF templates
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
- Implemented a new method to resolve existing file paths for PDF templates, enhancing file handling robustness.
- Updated PDF storage path handling to ensure correct resolution of paths before file operations, improving error handling and reliability.
- Refactored related logic to utilize the new path resolution method across various template operations.
2026-04-25 09:46:59 +02:00
Torsten Schulz (local)
630c202fd2 style(MemberGalleryDialog): simplify participant item styling
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 42s
- Removed redundant box-shadow properties for participant items in MemberGalleryDialog to streamline the visual design.
- Enhanced clarity of participant item styling while maintaining the intended visual effect.
2026-04-25 09:42:06 +02:00
Torsten Schulz (local)
3462a5497c refactor(MemberService, MemberGalleryDialog): update image selection logic and enhance participant styling
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s
- Refactored image selection logic in MemberService to prioritize primary images based on sortOrder.
- Updated MemberGalleryDialog styles for participant items, adding visual indicators and improved background colors for better user experience.
2026-04-25 09:25:40 +02:00
Torsten Schulz (local)
6ea92bef49 refactor(MatchReportApiDialog): update draft status display and improve styling
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 41s
- Removed the old draft status indicator and replaced it with a new draft status bar for better visibility.
- Enhanced the styling of the draft status bar to improve user experience and consistency.
- Updated the logic to return a 'Bereit' status when applicable, ensuring accurate draft state representation.
- Integrated local draft persistence and synchronization methods to maintain draft state effectively.
2026-04-23 11:01:04 +02:00
Torsten Schulz (local)
be1108511f feat(MatchReportApiDialog): implement draft status indicators and local draft persistence
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 42s
- Added visual indicators for draft status in the MatchReportApiDialog, displaying messages based on syncing state.
- Introduced local draft persistence functionality, allowing users to save and restore match report drafts.
- Enhanced state management for draft synchronization, including timers and error handling.
- Updated methods to ensure local drafts are persisted and cleared appropriately during the match report submission process.
2026-04-23 10:35:24 +02:00
Torsten Schulz (local)
37c3ffa899 feat(OrdersPanel): add completion state filter for order management
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 42s
- Introduced a new dropdown filter for order completion states (paid, handed_over) in the OrdersPanel component.
- Updated the filtering logic to include completion state checks when loading orders.
- Added corresponding German localization for the new filter options to enhance user experience.
2026-04-22 09:52:35 +02:00
Torsten Schulz (local)
1f477a4458 feat(OrdersPanel): implement sorting for order history entries
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 42s
- Added a new function to sort order history entries in descending order based on their timestamps.
- Integrated the sorting function into the OrdersPanel component to enhance the display of order history.
2026-04-22 09:47:52 +02:00
Torsten Schulz (local)
1d67b68b44 feat(MemberOrder): add budget field to MemberOrder and MemberOrderHistory models
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 41s
- Introduced a new budget field in both MemberOrder and MemberOrderHistory models to track budget amounts.
- Updated memberOrderService to handle budget in serialization and normalization processes.
- Enhanced OrdersPanel component to include budget input and display in the UI.
- Added German localization for the new budget term to ensure consistency across languages.
2026-04-22 08:53:57 +02:00
Torsten Schulz (local)
41bbf81958 chore: remove obsolete Android app configuration files
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
- Deleted build.gradle.kts, gradle.properties, and gradlew files as part of the cleanup process.
- Removed local.properties and various generated files from the .gradle directory to streamline the project structure.
- Cleared out unnecessary build artifacts and intermediate files to improve project maintainability.
2026-04-21 15:15:21 +02:00
Torsten Schulz (local)
c8dedb10cc feat(DiaryParticipantsPanel, DiaryView): add group photo cropping functionality
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 39s
- Introduced a new button in the DiaryParticipantsPanel for opening the group photo crop dialog.
- Integrated the GroupPhotoCropDialog component in DiaryView, allowing users to manage group photos.
- Added state management for showing the group photo crop dialog and handling member image updates.
- Enhanced styling for the new button to maintain consistency with existing UI elements.
2026-04-17 14:52:26 +02:00
Torsten Schulz (local)
894c84b94a feat(DiaryParticipantsPanel): enhance gallery button styling and functionality
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 37s
- Updated the gallery button to include icons for improved visual feedback during loading states.
- Refined button styles with a new gradient background and hover effects for better user interaction.
- Adjusted button layout to ensure consistent alignment and spacing of elements.
2026-04-17 11:58:37 +02:00
Torsten Schulz (local)
9c738e8063 feat(DiaryOverview): enhance styling and layout for diary overview panels
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 37s
- Added scoped CSS styles to improve the layout and visual presentation of diary overview panels.
- Implemented responsive design adjustments for better usability on different screen sizes.
- Updated time range label formatting in DiaryView to ensure consistent display of training start and end times.
2026-04-17 11:20:46 +02:00
Torsten Schulz (local)
69418d8f9a fix(Localization): correct German translations for consistency and clarity
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 36s
- Updated various phrases in the German localization files to ensure proper spelling and grammar.
- Improved user interface text in the group photo management feature for better readability.
- Ensured consistency in terminology across the application, enhancing the overall user experience.
2026-04-16 08:24:31 +02:00
Torsten Schulz (local)
a4f6b9b8b3 feat(Localization): update and enhance localization files for multiple languages
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 39s
- Added new terms related to mobile app usage and member play interests across various languages.
- Improved existing translations for better clarity and consistency.
- Included hints for mobile club selection to enhance user experience on smartphones.
- Ensured all localization files are synchronized with the latest application features.
2026-04-15 22:48:54 +02:00
Torsten Schulz (local)
1dd7bb24ea feat(MemberGroupPhoto): implement group photo management functionality
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 38s
- Added MemberGroupPhoto model and established relationships with Club and User models.
- Introduced new routes for managing group photos in the backend.
- Enhanced frontend components to support group photo cropping and member image updates.
- Updated localization files to include new terms related to group photo processing across multiple languages.
- Refactored server.js to include MemberGroupPhoto in the synchronization process.
2026-04-15 22:45:35 +02:00
Torsten Schulz (local)
5fa34637ba feat(ClubSettings): add member data quality requirements configuration
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 38s
- Introduced new settings for member data quality requirements in club settings, allowing configuration of required fields such as street, postal code, city, phone, and email.
- Updated the backend to handle the new memberDataQualityRequirements field in club settings.
- Enhanced the frontend to display and manage these requirements in the ClubSettings view, improving user experience and data integrity.
- Added localization support for new terms related to member data quality across multiple languages.
2026-04-15 22:15:04 +02:00
Torsten Schulz (local)
4cfc82c7aa feat(ScheduleView): enhance player selection logic and eligibility checks
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 37s
- Introduced new methods to determine the lineup half for matches and fetch eligible member IDs based on the selected team.
- Improved player selection dialog to filter visible members based on active status and eligibility, ensuring a more accurate representation of available players.
- Added logic to handle preselected player IDs, enhancing the user experience during team selection.
2026-04-15 13:17:32 +02:00
Torsten Schulz (local)
3ce1702367 fix(AutoFetchMatchResultsService): improve date parsing with timezone handling
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 36s
- Enhanced date parsing logic to handle ISO strings with explicit timezones, converting them to local time before persisting.
- Added normalization and validation checks to ensure accurate date and time extraction from input strings.
- Improved robustness of the date handling functionality, ensuring better compatibility with various date formats.
2026-04-15 11:33:24 +02:00
Torsten Schulz (local)
58012e0a44 feat(SeasonSelector): implement logic to determine the current running season
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 37s
- Added a method to retrieve the current running season ID based on the current date and available season entries.
- Updated the season selection logic to default to the running season if no season is selected and `showCurrentSeason` is true.
- Enhanced localization files to include new terms related to member scopes, improving user experience across multiple languages.
2026-04-15 11:20:21 +02:00
Torsten Schulz (local)
c62e91d997 fix(TeamManagement): refine eligibility checks and streamline assignment removal
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 37s
- Adjusted member eligibility comparison logic to ensure correct validation.
- Implemented functions to remove ineligible planning assignments for teams, enhancing data integrity.
- Integrated automatic cleanup of ineligible assignments upon team updates, improving user experience.
2026-04-15 11:02:52 +02:00
Torsten Schulz (local)
7d483ebf02 fix(TeamManagement): correct member eligibility logic and add eligibility check for planning teams
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 37s
- Updated the eligibility comparison logic to ensure correct member and team index validation.
- Introduced a new function to check if a member is eligible for a planning team based on age group and gender.
- Enhanced the team drop functionality to include eligibility validation, providing user feedback for ineligible members.
2026-04-15 10:59:33 +02:00
Torsten Schulz (local)
2dff5221e3 feat(MemberPlayInterest): implement play interest management for members
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 38s
- Added new endpoints to get and set member play interests in the memberController.
- Integrated MemberPlayInterest model into the application, establishing relationships with Member and Club models.
- Updated memberRoutes to include routes for managing member play interests.
- Enhanced memberService to handle play interest retrieval and updates.
- Updated localization files to include new terms related to member play interests.
- Refactored server.js to include MemberPlayInterest in the synchronization process.
2026-04-15 10:48:10 +02:00
Torsten Schulz (local)
45c701b149 feat(SEO): update meta tags and structured data for improved search visibility
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 36s
- Revised title and description meta tags in index.html for better alignment with the application's focus on table tennis club management.
- Updated Open Graph and Twitter meta tags to reflect the new branding and features of the application.
- Enhanced structured data implementation in SeoLandingPage.vue to support JSON-LD for better SEO performance.
- Adjusted sitemap.xml to reflect updated last modified dates for improved indexing.
- Refined content in ClubMemberManagementPage.vue to emphasize member profiles and data management.
2026-04-09 09:43:38 +02:00
Torsten Schulz (local)
951842c824 feat(Dialog): enhance dialog functionality with resizing and improved positioning
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 36s
- Added support for dialog resizing, allowing users to adjust the size of non-modal dialogs.
- Implemented dynamic positioning for dialogs based on user-defined coordinates or calculated defaults.
- Updated Vuex store to manage dialog dimensions and positions effectively.
- Enhanced the BaseDialog component with resize handles and improved drag-and-drop functionality.
- Updated localization strings to include new resize functionality for better user guidance.
2026-04-08 15:09:47 +02:00
Torsten Schulz (local)
4f8e2fee89 refactor(TournamentStats): remove InternalTournamentStats dialog and streamline state management
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 36s
- Removed the InternalTournamentStats component from App.vue and its associated state management in Vuex.
- Updated the DialogManager to include InternalTournamentStats, allowing for better dialog handling.
- Refactored the TournamentsView to utilize a new method for opening the InternalTournamentStats dialog.
- Enhanced the InternalTournamentStats component by simplifying its template and removing unnecessary props and methods.
- Improved the logic for displaying tournament statistics based on club selection, ensuring a cleaner user experience.
2026-04-08 15:02:09 +02:00
Torsten Schulz (local)
757507f212 feat(TournamentStats): integrate InternalTournamentStats dialog and state management
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 36s
- Added InternalTournamentStats component to App.vue for displaying tournament statistics.
- Implemented state management for the dialog's visibility using Vuex, allowing for better control of the dialog's open state.
- Updated the TournamentsView to utilize the new Vuex mutation for opening the statistics dialog.
- Enhanced the InternalTournamentStats component with a new gender selection dropdown, replacing the previous checkbox implementation for improved user experience.
- Updated localization strings to support new filtering options and terminology related to gender and age classes across multiple languages.
2026-04-08 14:26:42 +02:00
Torsten Schulz (local)
003b8fd3bc feat(TournamentStats): update age class filtering UI and logic in InternalTournamentStats component
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 37s
- Replaced age class filtering with band and gender options for improved user selection.
- Introduced new methods for handling band and gender checkbox changes, enhancing the filtering logic.
- Updated the component's state management to accommodate selected bands and genders.
- Enhanced localization strings to support new filtering options, improving user accessibility and understanding.
2026-04-08 14:01:47 +02:00
Torsten Schulz (local)
bbd9f08e97 feat(TournamentStats): enhance internal tournament statistics with member profile integration
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 37s
- Integrated member profile data (birth date and gender) into the internal tournament statistics calculations for more accurate age class filtering.
- Removed deprecated age class filtering functions and streamlined the statistics computation process.
- Updated the InternalTournamentStats component to reflect changes in age class options and improved sorting logic.
- Enhanced localization strings across multiple languages to support new terminology related to age classes and gender, improving user accessibility and understanding.
2026-04-08 13:48:41 +02:00
Torsten Schulz (local)
30994adee8 feat(TournamentStats): enhance age class sorting and localization for tournament statistics
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 36s
- Improved the sorting logic for age class options in the InternalTournamentStats component, incorporating gender and name for better organization.
- Added new utility functions for handling gender labels and age class sorting values, enhancing clarity in tournament statistics.
- Updated localization strings across multiple languages to reflect changes in age class and gender terminology, improving user accessibility and understanding.
2026-04-08 13:12:19 +02:00
Torsten Schulz (local)
27f8186d91 feat(TournamentStats): enhance internal tournament statistics with age class filtering
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 37s
- Updated the `getInternalTournamentPlayerStats` endpoint to accept age class keys for more granular statistics.
- Introduced new utility functions for handling age class filtering in the internal tournament stats service.
- Enhanced the InternalTournamentStats component with a new age class filter UI, allowing users to select specific age classes for their statistics.
- Updated localization strings across multiple languages to support the new age class filtering feature, improving user accessibility and understanding.
2026-04-08 12:50:20 +02:00
Torsten Schulz (local)
c1b8b2c665 feat(TournamentStats): refine internal tournament scoring and enhance UI features
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 36s
- Updated the scoring logic for internal tournaments to reflect percentage-based placements, improving clarity and fairness in rankings.
- Refactored the `groupPointsFromRankings` function to `groupPercentFromRankings` for better readability and accuracy in calculations.
- Enhanced the InternalTournamentStats component with a new PDF export feature and improved dialog positioning for better user experience.
- Updated localization strings across multiple languages to align with the new scoring system and UI enhancements, ensuring better accessibility and understanding for users.
2026-04-08 11:20:46 +02:00
Torsten Schulz (local)
43dbd5442a feat(TournamentStats): update internal tournament scoring logic and UI enhancements
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 36s
- Revised scoring system for internal tournaments to assign points based on placement (1st = 100, 2nd = 99, etc.), with adjustments for tied ranks and a cap at 101 points.
- Refactored `groupPointsFromRankings` function for improved clarity and efficiency.
- Enhanced the InternalTournamentStats component UI, integrating a dialog for better user interaction and accessibility.
- Updated localization strings across multiple languages to reflect the new scoring system and UI changes, improving user understanding and experience.
2026-04-08 11:02:34 +02:00
Torsten Schulz (local)
4a53801a54 feat(TournamentStats): add internal tournament statistics endpoint and localization updates
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 37s
- Implemented a new endpoint `getInternalTournamentStats` in the tournament controller to retrieve statistics for internal tournaments based on club ID and selected months.
- Enhanced the tournament service to compute player statistics, including absolute and average rankings.
- Updated tournament routes to include the new statistics endpoint.
- Added localization strings for internal tournament statistics in multiple languages, improving user accessibility and experience.
- Integrated the new statistics component into the TournamentsView for better user interaction.
2026-04-08 10:40:33 +02:00
Torsten Schulz (local)
50fa07d0b7 feat(i18n): enhance localization files with new configuration prompts
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 35s
- Added new localization strings across multiple languages for missing configuration prompts, including messages for league selection and MyTischtennis URL requirements.
- Updated TeamManagementView to display missing configuration alerts, improving user guidance during team setup.
2026-04-02 09:14:07 +02:00
Torsten Schulz (local)
a94ad55a2d Revert "feat(UI): enhance styling and accessibility across components"
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 34s
This reverts commit cf57ade3f0.
2026-04-02 08:46:52 +02:00
Torsten Schulz (local)
cf57ade3f0 feat(UI): enhance styling and accessibility across components
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 35s
- Added `-webkit-overflow-scrolling: touch` and `overscroll-behavior-y: contain` to App.vue for improved scrolling behavior on touch devices.
- Updated DialogManager.vue to include `min-height: 0` and `overflow-x: hidden` for better content management.
- Set `min-height: 0` in TeamManagementOverview.vue and TeamManagementView.vue to ensure consistent layout.
- Enhanced localization files across multiple languages to include new terms for user administration, improving user experience.
2026-04-02 08:43:26 +02:00
Torsten Schulz (local)
8503c9a79d feat(TeamManagement): enhance TeamListCard and TeamManagementOverview with improved accessibility and styling
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 34s
- Updated TeamListCard to include keyboard accessibility features, allowing users to trigger edit actions via keyboard events.
- Refactored TeamListCard layout for better visual organization, including a new league row for improved information display.
- Enhanced styling for TeamListCard, including hover and focus states for better user interaction.
- Adjusted grid layout in TeamManagementOverview for improved responsiveness and spacing.
- Removed redundant styles from TeamManagementView, centralizing styling in TeamListCard for consistency.
2026-04-02 08:37:41 +02:00
Torsten Schulz (local)
68b8455340 feat(ClubTeam): add planned league name field and localization updates
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 34s
- Introduced a new field for planned league name in the ClubTeam model, allowing for better team categorization.
- Updated create and update club team endpoints to handle the new planned league name field.
- Enhanced the PDF generation feature to include planned league name in the output.
- Improved localization files across multiple languages to incorporate new terms related to the planned league, ensuring a consistent user experience.
- Updated the TeamManagementView to display and edit the planned league name, enhancing user interaction.
2026-04-02 08:17:13 +02:00
Torsten Schulz (local)
9454761e34 feat(PDFGenerator): add team lineup PDF generation and localization updates
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 35s
- Implemented a new feature to generate a PDF for team lineups, including detailed information such as club name, team name, league, season, gender, and age group.
- Enhanced localization files across multiple languages to include new terms related to the PDF generation feature, ensuring a consistent user experience.
- Updated the TeamManagementView to include a button for downloading the lineup as a PDF, improving accessibility for users.
2026-04-02 08:07:38 +02:00
Torsten Schulz (local)
fd02655be4 style(MembersView): enhance age class line styling
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 36s
- Added white-space nowrap property to .tt-age-class-line and .tt-age-class-line--next classes to prevent text wrapping, improving the visual layout of age class lines in the MembersView component.
2026-04-02 07:25:54 +02:00
Torsten Schulz (local)
c76b5f32e2 fix(i18n): update last training filter hint across multiple languages
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 35s
- Revised the lastTrainingFilterHint in localization files for German, English (AU, GB, US), and Swiss German to enhance clarity regarding the age-class column and details available on row hover.
- Improved consistency in terminology across different language files to ensure a better user experience.
2026-04-01 16:46:32 +02:00
Torsten Schulz (local)
2d43967c81 feat(MembersOverview): add last training filter and localization updates
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 35s
- Introduced a new filter for last training in the MembersOverviewSection, allowing users to filter members based on their last training date.
- Updated the MembersView to handle the new last training filter state and integrate it into the member sorting and display logic.
- Enhanced localization files across multiple languages to include new terms related to the last training filter and its options.
- Improved member row tooltip to display last training information, enhancing user experience and clarity.
2026-04-01 16:41:54 +02:00
Torsten Schulz (local)
59034ff397 feat(MembersOverview): add training participations column toggle and localization updates
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 35s
- Introduced a checkbox in the MembersOverviewSection to toggle the visibility of the training participations column.
- Updated the MembersView to handle the new toggle state and display training participations accordingly.
- Enhanced localization files across multiple languages to include the new term for the training participations column.
- Refactored member age class display logic to improve clarity and maintainability.
2026-04-01 16:28:00 +02:00
Torsten Schulz (local)
8b9a4b7bca feat(MembersOverview): add season filter and enhance age group selection
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 37s
- Introduced a new season filter dropdown in the MembersOverviewSection for selecting the season start year.
- Enhanced age group selection by organizing options into groups for better clarity and added new age categories.
- Updated localization files to include new terms related to the season filter and age classifications across multiple languages.
- Improved the overall layout and styling of the filter components for a better user experience.
2026-04-01 15:26:08 +02:00
Torsten Schulz (local)
b62b61505c fix(deploy): update SSH key handling in deployment workflow
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 39s
- Changed the SSH key handling in the Gitea deployment workflow to use a base64 encoded key, improving security and compatibility.
- Updated the SSH connection commands to reference the new key file, ensuring successful deployment connections.
2026-03-31 15:50:13 +02:00
Torsten Schulz (local)
49df0cc381 chore(deploy): remove obsolete deployment script from Gitea workflows
Some checks failed
Deploy tt-tagebuch / deploy (push) Failing after 0s
- Deleted the deploy.sh script as it is no longer needed for the deployment process.
2026-03-31 13:49:38 +02:00
Torsten Schulz (local)
5eff1d63aa feat(ClubTeam): enhance club team management with lineup features and member eligibility
- Added teamGender and teamAgeGroup fields to ClubTeam model for better categorization.
- Updated create and update club team endpoints to handle new fields and default values.
- Implemented getClubTeamLineup and updateClubTeamLineup functions for managing team lineups.
- Enhanced member management with adultReleaseApproved and adultReserveApproved fields in Member model.
- Updated frontend views to support new lineup features and member eligibility flags.
- Improved localization for new terms related to team management and member eligibility across multiple languages.
2026-03-31 13:44:28 +02:00
Torsten Schulz (local)
cb7830571b feat(TrainingStats): enhance training statistics view and participant details
- Updated TrainingStatsService to include member details (first name, last name) in participant data.
- Modified TrainingDetailsDialog to remove unnecessary time display for training sessions.
- Added new filters for training days in TrainingStatsView, allowing users to select specific training days and view attending members.
- Enhanced localization files to support new training day filter and participant-related strings across multiple languages.
2026-03-28 13:35:34 +01:00
Torsten Schulz (local)
0df8674353 feat(TournamentService): implement seeded knockout match generation and enhance qualifier handling
- Added a new function to build seeded legacy knockout matches, improving match pairing logic based on group affiliations.
- Refactored qualifier selection process to utilize a ranked participants map, ensuring better handling of advancing participants and unused qualifiers.
- Updated knockout match handling to incorporate seeded results, enhancing the overall tournament flow and participant management.
2026-03-28 12:15:40 +01:00
Torsten Schulz (local)
2043942e02 feat(TournamentWorkspaceHeader): enhance UI layout and tab functionality
- Updated the TournamentWorkspaceHeader component to improve the layout with a new class for the workspace header.
- Introduced a tournament tabs panel with buttons for navigating between configuration, participants, groups, and results, enhancing user interaction.
- Added computed properties and methods for managing tab states and results sub-tabs, streamlining tournament management.
- Improved styling for better visual hierarchy and user experience.
2026-03-28 12:05:04 +01:00
Torsten Schulz (local)
e4be66b469 fix(i18n): update problem configuration description across multiple languages
- Simplified the problemConfigDescription in Spanish, Filipino, French, Italian, Japanese, Polish, Thai, Tagalog, and Chinese to remove the mention of "at least one class," ensuring clarity and consistency in translations.
2026-03-28 11:54:19 +01:00
Torsten Schulz (local)
0554a68eb7 feat(TournamentService, TournamentResultsTab): enhance knockout match handling and UI interactions
- Introduced new functions for determining knockout round order and building preferred knockout matches based on qualifiers.
- Updated TournamentResultsTab to include collapsible sections for group matches and knockout rounds, improving user experience.
- Added data properties and methods to manage the visibility of match sections and handle tournament class checks.
- Refined UI elements for better interaction, including toggle buttons and improved styling for match sections.
2026-03-28 11:48:10 +01:00
Torsten Schulz (local)
92d29dc64e feat(TournamentTab): improve knockout section handling and data validation
- Refactored stages and advancements data retrieval to ensure proper handling of empty or invalid responses.
- Added scrollToKnockoutSection method to enhance user experience by automatically scrolling to the knockout section after initiating tournament actions.
- Updated knockout operation flow to include scrolling behavior, ensuring better visibility of the knockout stage during tournament management.
2026-03-28 11:20:34 +01:00
Torsten Schulz (local)
2c11f6b975 chore(dependencies): update package versions in package-lock.json and package.json
- Upgraded nodemailer from version 7.0.9 to 8.0.4 for improved email handling.
- Updated brace-expansion, flatted, picomatch, and socket.io-parser to their latest versions across frontend and backend for better performance and security.
- Incremented versions of other dependencies like sequelize and jspdf to ensure compatibility and access to new features.
2026-03-28 11:18:16 +01:00
Torsten Schulz (local)
68f14eb5d6 feat(TournamentConfigTab, TournamentResultsTab): enhance stage configuration and knockout actions
- Added ensureStageConfigurationPersisted method to validate and save stage advancements, ensuring proper configuration before advancing stages.
- Updated advanceStage method to call ensureStageConfigurationPersisted, improving the flow of tournament advancement.
- Introduced primary action buttons in TournamentResultsTab for starting and resetting knockout rounds, enhancing user interaction during tournament management.
- Styled results-primary-actions for better visibility and usability in the results tab.
2026-03-28 11:15:38 +01:00
Torsten Schulz (local)
7fdbe85d3c feat(TournamentService, TournamentConfigTab): enhance tournament advancement logic and knockout stage handling
- Introduced a new function to compare advancement candidates based on multiple criteria, improving the selection process for tournament participants.
- Updated participant data structure to include additional metrics for better ranking and comparison.
- Enhanced the TournamentConfigTab to automatically configure knockout stage settings when applicable, ensuring a smoother user experience during tournament setup.
2026-03-28 11:09:40 +01:00
Torsten Schulz (local)
adefb120c0 feat(TournamentResultsTab, TournamentTab): add knockout operation handling
- Introduced knockoutOperationInProgress state to manage button states during knockout operations in TournamentResultsTab.vue.
- Updated button elements to disable during ongoing operations, enhancing user experience and preventing multiple submissions.
- Integrated knockoutOperationInProgress state in TournamentTab.vue to control the flow of knockout-related actions.
2026-03-28 10:57:29 +01:00
Torsten Schulz (local)
9d023b534d feat(SEO, Sitemap, Routing): enhance SEO and sitemap for new features
- Updated update-sitemap.sh to include new URLs for Vereinssoftware, Mitgliederverwaltung, Trainingsplanung, and Turniersoftware with appropriate lastmod dates and change frequencies.
- Enhanced server.js and seo.js with SEO configurations for the new pages, ensuring proper indexing and descriptions.
- Added new routes in router.js for the additional features, improving navigation and user access.
- Updated Home.vue to include links to the new features, enhancing user engagement and visibility.
2026-03-27 11:42:11 +01:00
Torsten Schulz (local)
a7d3e5b094 feat(Sitemap, SEO): update sitemap generation and SEO configurations
- Enhanced update-sitemap.sh to generate a new sitemap structure with lastmod dates for additional URLs.
- Updated SEO configurations in server.js and seo.js to allow indexing for impressum and datenschutz pages.
- Introduced a list of noindex prefixes to manage SEO settings dynamically based on route paths.
- Added structured FAQ schema in index.html to improve search engine visibility and user engagement.
2026-03-27 11:22:55 +01:00
Torsten Schulz (local)
ddb3025b84 feat(App): improve layout responsiveness and overflow handling
- Added min-width and max-width properties to App.vue for better layout control.
- Enhanced main-content and content classes with overflow-x hidden and box-sizing border-box to improve responsiveness and prevent overflow issues.
2026-03-27 10:57:52 +01:00
Torsten Schulz (local)
85cf0d0ddc feat(ScheduleLayoutShell, ScheduleView): refine layout and responsiveness
- Enhanced ScheduleLayoutShell.vue with improved width and box-sizing properties for better layout management.
- Updated ScheduleView.vue to ensure consistent width handling and overflow properties, enhancing mobile responsiveness and user experience.
2026-03-27 10:51:41 +01:00
Torsten Schulz (local)
37f9ba83aa feat(ScheduleLayoutShell, ScheduleView): enhance layout and overflow handling
- Updated ScheduleLayoutShell.vue to improve overflow properties and ensure better layout management for tab panels and workspace components.
- Enhanced ScheduleView.vue with max-width adjustments and touch-action properties for improved responsiveness and user interaction on mobile devices.
2026-03-27 10:23:54 +01:00
Torsten Schulz (local)
32ba433008 feat(AutoFetchMatchResultsService, ScheduleView): enhance date and time parsing for match results
- Implemented a new method in AutoFetchMatchResultsService to parse match date and time from various formats, improving robustness in handling date inputs.
- Updated storeMatchResult method to utilize the new date parsing logic, ensuring accurate match date handling.
- Enhanced ScheduleView to maintain consistent display of match dates and times, improving user experience in the schedule table.
2026-03-26 19:34:46 +01:00
Torsten Schulz (local)
c7d51efb5d feat(MatchReportApiDialog): implement meeting details polling functionality
- Added methods to start and stop polling for meeting details at regular intervals.
- Introduced logic to build a signature for meeting details to detect changes.
- Enhanced data handling to update meeting information based on the latest fetched details.
- Improved error handling during polling to ensure robustness in data retrieval.
2026-03-25 18:43:52 +01:00
Torsten Schulz (local)
c2a31d3b24 feat(Tournament): add doubles tournament support and related UI enhancements
- Updated tournamentController.js and tournamentService.js to include isDoublesTournament parameter for tournament creation and updates.
- Modified Tournament model to add isDoublesTournament field, allowing differentiation between singles and doubles tournaments.
- Enhanced frontend components (TournamentConfigTab, TournamentParticipantsTab, TournamentTab) to support doubles tournament configuration and display.
- Added internationalization keys for doubles tournament labels and hints in multiple languages.
- Improved participant assignment logic for doubles tournaments to ensure proper class assignments.
2026-03-25 11:49:47 +01:00
Torsten Schulz (local)
64090d9ff0 feat(MatchReportApiDialog, ScheduleLayoutShell, ScheduleView): enhance lineup management and layout improvements
- Updated MatchReportApiDialog.vue to improve lineup tab accessibility based on team status.
- Enhanced player selection filtering to ensure only valid players are displayed in lineups.
- Modified ScheduleLayoutShell.vue to adjust overflow properties for better layout handling.
- Improved ScheduleView.vue with new methods for sorting matches by date and combining league matches, enhancing match display and user experience.
- Added sticky header styling for better visibility in match lists.
2026-03-25 10:12:50 +01:00
Torsten Schulz (local)
02f1bed452 feat(MemberOrders): implement member orders feature
- Added new models and routes for managing member orders and order history.
- Updated server.js to include member order routes and sync functionality.
- Enhanced frontend with new components and dialogs for viewing and managing orders.
- Integrated internationalization support for order-related texts across multiple languages.
- Updated navigation and views to include access to the new orders feature, improving user experience.
2026-03-24 17:01:57 +01:00
Torsten Schulz (local)
e55ee0f88a style(App): update navigation icons for improved clarity
- Replaced navigation icons in App.vue to better represent their respective sections, enhancing user experience and visual consistency.
2026-03-20 10:53:58 +01:00
Torsten Schulz (local)
128b13c679 style(App): add padding adjustment for collapsed sidebar
- Introduced a new CSS rule to add top padding to the sidebar content when the sidebar is collapsed, improving layout consistency and visual appearance.
2026-03-20 10:43:23 +01:00
Torsten Schulz (local)
65d464eab9 style(App, ScheduleLayoutShell, ScheduleView): enhance layout for full-height routes
- Updated App.vue to conditionally apply full-height styles based on the current route.
- Modified ScheduleLayoutShell.vue to ensure proper height and overflow handling for better layout consistency.
- Adjusted ScheduleView.vue to support full-height content display, improving user experience on specific routes.
2026-03-20 10:40:42 +01:00
Torsten Schulz (local)
5a9e5913a6 style(DiaryParticipantsPanel, Home): enhance layout and responsiveness
- Added horizontal overflow to the diary sidebar for better content management.
- Updated participant row layout to use CSS grid for improved alignment and spacing.
- Adjusted participant name styling to prevent overflow and ensure text is truncated with ellipsis.
- Introduced new authentication action buttons in Home.vue for better user access to registration and login.
- Enhanced mobile responsiveness of the Home view with updated flex and grid properties.
2026-03-20 10:33:38 +01:00
Torsten Schulz (local)
80f8934bc8 style(ScheduleView): improve table responsiveness and layout
- Added minimum widths to player selection table and player name for better layout consistency.
- Adjusted checkbox cell width and added minimum width for improved alignment.
- Enhanced mobile responsiveness with media queries for table padding and minimum widths, ensuring a better user experience on smaller screens.
2026-03-20 10:27:21 +01:00
Torsten Schulz (local)
36690980b7 feat(ViteConfig, App, Router, DialogManager, MembersView, ScheduleView, ClubView, Home, TournamentsView, TrainingStatsView): enhance performance and responsiveness
- Updated Vite configuration to improve chunking strategy and set a chunk size warning limit.
- Refactored App.vue and DialogManager.vue to utilize async component loading for better performance.
- Modified router.js to implement lazy loading for various views, optimizing initial load times.
- Enhanced MembersView, ScheduleView, ClubView, and TournamentsView with responsive design adjustments for improved mobile usability.
- Improved styling and layout in Home.vue and TrainingStatsView to enhance user experience across different screen sizes.
2026-03-20 10:20:48 +01:00
Torsten Schulz (local)
cc6d1f6ebe feat(App, ScheduleView): implement mobile club picker and location dialog
- Added a mobile club picker dialog to enhance club selection on mobile devices, allowing users to select or create a club easily.
- Introduced a location dialog in ScheduleView to display match location details, improving user experience when viewing schedules.
- Updated internationalization files to include new translation keys for mobile club picker hints and location information.
- Enhanced responsive design in various components to ensure a seamless experience across different viewport sizes.
2026-03-20 10:02:38 +01:00
Torsten Schulz (local)
cbc5054f1f feat(i18n, CourtDrawingTool, MembersOverviewSection): enhance internationalization and UI components
- Added new i18n keys for improved translations in the CourtDrawingTool and MembersOverviewSection components.
- Updated CourtDrawingTool to utilize translation keys for various UI elements, enhancing user experience and accessibility.
- Enhanced MembersOverviewSection with translation support for search and filter functionalities, improving usability for non-German speakers.
- Introduced new scripts in package.json for i18n auditing and status checking, streamlining localization management.
2026-03-20 09:05:15 +01:00
Torsten Schulz (local)
542d741428 feat(MembersOverview, MembersView): add training group filtering and display enhancements
- Introduced a new training group filter in MembersOverviewSection for improved member management.
- Updated MembersView to support the new training group selection and display last training date for members.
- Enhanced computed properties to dynamically generate training group options based on member data.
- Improved filtering logic to include selected training group, refining member search capabilities.
2026-03-19 16:10:39 +01:00
Torsten Schulz (local)
9d01ab6ce1 feat(App): enhance club selection and routing logic
- Updated the club selection dropdown to trigger a method on change, improving user interaction.
- Removed the load club button, streamlining the selection process.
- Introduced a computed property for view reloading based on the current club and route.
- Refactored club selection handling to ensure proper routing when selecting a new club or creating one.
2026-03-18 22:03:45 +01:00
Torsten Schulz (local)
269f648ad7 feat(CourtDrawing): enhance CourtDrawingDialog and CourtDrawingTool components
- Updated CourtDrawingDialog to improve layout and responsiveness, including a new drawing dialog body structure and styling adjustments.
- Enhanced CourtDrawingTool with a comprehensive exercise selection workflow, allowing users to configure exercises with detailed options for start positions, strokes, and targets.
- Introduced new UI elements for better user interaction and streamlined the process of adding additional strokes.
- Improved overall styling for a more cohesive and user-friendly experience.
2026-03-18 21:50:31 +01:00
Torsten Schulz (local)
76cc8d9c30 feat(MembersView, i18n): add 'activeDataIncomplete' scope and update member filtering
- Introduced a new translation key for 'activeDataIncomplete' in the German locale.
- Updated MembersView to include filtering for members with active status but incomplete data, enhancing member management capabilities.
2026-03-18 21:17:25 +01:00
Torsten Schulz (local)
b13d33c72c feat(TrainingStatsService, MembersView, TrainingStatsView): enhance training statistics and member management features
- Added new functions in TrainingStatsService to calculate monthly trends and member distribution based on training participation.
- Updated MembersView to improve the display of training groups and address potential data entry issues with visual hints.
- Enhanced TrainingStatsView with new filters for weekdays and training groups, and improved the layout for displaying training statistics, including average participation and attendance rates.
- Introduced additional statistics panels for better insights into training performance and member engagement.
2026-03-18 21:07:52 +01:00
Torsten Schulz (local)
dc15b48b80 refactor(App, Router, Login): streamline authentication flow and enhance route management
- Replaced manual logout handling in App.vue with a dedicated logout method for cleaner code.
- Updated router.js to add public meta properties to several routes, improving route access management.
- Implemented a global navigation guard to redirect unauthenticated users to the login page when accessing protected routes.
- Modified the login process in Login.vue to support redirecting users back to their intended destination after successful login.
2026-03-18 18:20:28 +01:00
Torsten Schulz (local)
c441d4a049 feat(Home): enhance SEO content and layout for improved user engagement
- Added a new SEO-focused section introducing the Vereinssoftware for Tischtennisvereine, detailing its features and benefits.
- Included multiple search topic articles to highlight key functionalities such as member management, training planning, and tournament organization.
- Updated styles for the new sections to ensure a responsive and visually appealing layout.
- Enhanced the SEO copy to clarify the software's relevance in search results, targeting specific user queries.
2026-03-18 18:16:11 +01:00
Torsten Schulz (local)
f94914703a feat(SEO): implement SEO configuration and meta tag management
- Added SEO defaults and route-specific configurations for improved search engine visibility.
- Introduced functions to normalize paths and retrieve SEO settings based on the current route.
- Enhanced server-side rendering to dynamically update meta tags for title, description, and robots directives.
- Created a utility for managing SEO-related meta and link tags in the frontend, ensuring consistent application of SEO practices across routes.
- Updated sitemap with new last modification dates and removed outdated entries for better search engine indexing.
2026-03-18 18:15:01 +01:00
Torsten Schulz (local)
0bb636b91d style(DiaryParticipantsPanel, DiaryView): enhance responsive design and layout
- Updated styles in DiaryParticipantsPanel to improve participant row layout and ensure proper word breaking for names.
- Adjusted styles in DiaryView for better mobile responsiveness, including changes to column display and sidebar padding.
- Ensured consistent minimum width settings across components to enhance overall UI coherence.
2026-03-18 18:09:59 +01:00
Torsten Schulz (local)
bf40927efb refactor(MembersOverviewSection): simplify layout and enhance filtering options
- Removed unnecessary subtitle and member statistics display for a cleaner interface.
- Introduced a collapsible section for advanced filters, improving user experience by organizing search and filter options.
- Maintained existing functionality while enhancing the overall layout and accessibility of member filtering features.
2026-03-18 18:07:01 +01:00
Torsten Schulz (local)
9340ee3509 feat(MemberService, SchedulerService): implement inactive member cleanup functionality
- Added a new method in MemberService to clean up inactive members who haven't trained in the last specified days, enhancing data management.
- Integrated the cleanup method into SchedulerService to run automatically via a scheduled job, improving system maintenance.
- Updated the MembersView to reflect changes in member data, ensuring a consistent user experience.
2026-03-18 18:03:15 +01:00
Torsten Schulz (local)
eea3372057 feat(MembersOverviewSection, MembersView): add age range filter and enhance member scope display
- Introduced age range inputs in the MembersOverviewSection for filtering members by age, allowing users to specify a minimum and maximum age.
- Updated the MembersView to handle new age range properties and emit corresponding updates.
- Enhanced the display of member scope chips by separating label and count for improved readability and styling.
- Refactored related components to ensure consistent functionality and styling across the application.
2026-03-18 17:10:37 +01:00
Torsten Schulz (local)
563a7e8dde feat(MemberTtrHistory): implement TTR history management and UI enhancements
- Added new endpoints in the member controller for retrieving and refreshing TTR history.
- Integrated TTR history functionality into the member service, allowing for seamless data retrieval and updates.
- Updated the member model to include a field for TTR history player ID, enhancing data tracking.
- Enhanced the MembersView to display TTR history with a dedicated dialog for better user interaction.
- Improved the MyTischtennisClient to support fetching historical player IDs, enriching the data provided to users.
- Refactored various components to ensure consistent styling and functionality across the application.
2026-03-18 15:34:10 +01:00
Torsten Schulz (local)
79adad9564 feat(audit, frontend, backend): introduce audit scripts and enhance error handling
- Added new npm scripts for auditing frontend size and inline TODOs, improving code quality management.
- Enhanced error handling in the `nuscoreApiRoutes` to return specific validation error statuses, improving API response clarity.
- Updated SQL migration documentation to establish a clear process for manual migrations and ensure backward compatibility.
- Refactored various components to align with new design standards, enhancing UI consistency across the application.
2026-03-18 11:23:03 +01:00
Torsten Schulz (local)
b7b40f5a9b refactor(MembersView, TeamManagementView, ScheduleView, OPTIMIZATION_TODO): componentize views and update optimization tasks
- Completed the componentization of `MembersView`, `TeamManagementView`, and `ScheduleView` to improve maintainability and reduce file sizes.
- Extracted specific sections into dedicated components, enhancing the focus on core functionalities within each view.
- Updated the optimization documentation to reflect the completion of these tasks, ensuring clarity on the current state of the project.
2026-03-17 16:46:33 +01:00
Torsten Schulz (local)
414c5ccee5 refactor(autoFetchMatchResultsService, MYTISCHTENNIS_AUTO_FETCH_README.md, Optimization TODO): enhance data processing and documentation
- Refactored the `autoFetchMatchResultsService` to separate single and double player statistics processing into distinct methods, improving code clarity and maintainability.
- Updated the README to reflect the current status of rating updates and match results, marking them as active and detailing their functionalities.
- Closed several TODOs in the optimization documentation, confirming the implementation of previously outlined tasks and ensuring no open inline TODOs remain in the main views.
2026-03-17 16:04:10 +01:00
Torsten Schulz (local)
6320c5ca72 feat(DialogExamples, DiaryParticipantsPanel, ImageViewerDialog, MatchReportApiDialog, MemberGalleryDialog, LogsView, TeamManagementView, TournamentTab, i18n): enhance UI components and localization
- Updated various UI components to improve styling and user experience, including DialogExamples, DiaryParticipantsPanel, ImageViewerDialog, and MatchReportApiDialog.
- Introduced new participant status filter in DiaryParticipantsPanel, allowing for 'excused' status.
- Enhanced image upload section in ImageViewerDialog with improved layout and styling.
- Refactored MatchReportApiDialog to streamline pin input handling and feedback mechanisms.
- Added functionality to filter members in MemberGalleryDialog based on a new `shouldShowMember` prop.
- Improved styling in LogsView for better readability and user interaction.
- Refactored TeamManagementView to utilize a new TeamListCard component for better code organization and maintainability.
- Updated TournamentTab to enhance the tournament workspace header with improved data display and interaction.
- Expanded localization files to include new keys for participant status and other UI elements, enhancing accessibility for users in both English and German.
2026-03-17 16:00:30 +01:00
Torsten Schulz (local)
483d5d2bc7 feat(ParticipantController, ParticipantModel, ParticipantRoutes, DiaryParticipantsPanel, i18n): implement participant status management and UI updates
- Added functionality to update participant attendance status, allowing for 'excused' and 'cancelled' states.
- Enhanced the participant model to include a default attendance status and validation for status values.
- Updated participant routes to support status updates and integrated new status handling in the participant controller.
- Modified the DiaryParticipantsPanel to visually indicate participant status and added a toggle for changing status.
- Expanded localization files to include new keys for participant status, improving accessibility for users in both English and German.
2026-03-17 15:23:35 +01:00
Torsten Schulz (local)
46812a0c14 fix(DiaryView): improve drag-and-drop item reordering logic
- Updated the drag-and-drop functionality to correctly handle item indices during reordering, ensuring accurate updates to the training plan.
- Enhanced error handling by loading the training plan after a successful reorder, improving user experience and stability.
2026-03-17 14:54:47 +01:00
Torsten Schulz (local)
1c1f05400f feat(DiaryView): enhance drag-and-drop functionality for training plan items
- Added a new class for sortable rows in the training plan table, improving the drag-and-drop experience.
- Updated the sortable initialization to target the new class, ensuring proper item handling during reordering.
- Implemented a check in the drag end event to prevent errors when no item is moved, enhancing stability.
2026-03-17 14:47:57 +01:00
Torsten Schulz (local)
c325a5a4d6 feat(CourtDrawingDialog, DiaryView): conditionally display duration fields in diary entries
- Updated the CourtDrawingDialog to conditionally render duration input fields based on the new `showDurationFields` property, enhancing user experience.
- Modified the DiaryView to pass the `show-duration-fields` prop, ensuring proper integration with the updated dialog functionality.
- Improved the overall logic for displaying diary fields, allowing for more dynamic user interactions.
2026-03-17 14:34:10 +01:00
Torsten Schulz (local)
b16743d27d feat(CourtDrawingDialog, DiaryView, i18n): add diary fields for duration and group selection
- Introduced new diary fields in the CourtDrawingDialog for inputting duration as text and minutes, along with a group selection dropdown.
- Implemented logic to calculate duration from text input, enhancing user experience in managing diary entries.
- Updated the DiaryView to support the new diary fields, ensuring proper data handling and integration with existing functionalities.
- Expanded localization files for English and German to include new keys related to the diary fields, improving accessibility for users.
2026-03-17 14:26:47 +01:00
Torsten Schulz (local)
afe51f399c feat(Scheduler, MatchService, PredefinedActivity): enhance scheduling and match fetching features
- Added new scheduler routes to manage scheduling functionalities.
- Updated match fetching logic to include a scope parameter for more flexible data retrieval.
- Introduced a new field `excludeFromStats` in the PredefinedActivity model to manage activity visibility in statistics.
- Enhanced the diary date activity controller to handle predefined activities, improving activity management.
- Refactored various services to support new features and improve overall data handling.
2026-03-17 14:10:35 +01:00
Torsten Schulz (local)
f1cfd1147d feat(TeamManagementView, i18n): enhance team management interface and localization
- Added new summary cards to display team statistics, including total teams, fully configured, partially configured, and teams without leagues.
- Implemented search and filter functionality for teams, improving user navigation and management.
- Expanded German localization file with new keys for team management features, enhancing the experience for German-speaking users.
- Updated the layout for automatic job information, improving clarity and user interaction.
2026-03-17 09:04:45 +01:00
Torsten Schulz (local)
44b2b9fdbf feat(DiaryParticipantsPanel): add gallery button and loading state
- Introduced a new button in the DiaryParticipantsPanel for opening the gallery, with a loading state to indicate when the gallery is being created.
- Added a new `galleryLoading` property to manage the button's disabled state and text dynamically.
- Enhanced the layout of the participant toolbar for better user experience.
2026-03-17 00:30:42 +01:00
Torsten Schulz (local)
d0b6e6f0ac feat(DiaryView, i18n): improve diary overview with new readiness indicators and localization updates
- Refactored the DiaryView to implement a tabbed overview for training day, details, and groups, enhancing user navigation.
- Added a readiness panel to display the status of training day elements, including training window, participants, and open plan items.
- Expanded the German localization file with new keys for readiness indicators and checklist items, improving the experience for German-speaking users.
2026-03-17 00:28:36 +01:00
Torsten Schulz (local)
bf770291f6 feat(DiaryView, i18n): enhance diary view with new training day features and localization updates
- Added new sections in the DiaryView for displaying active training day details, including training window, participants, and free activities.
- Implemented toggle functionality for displaying training day and group details, improving user interaction.
- Expanded German localization file with new keys and translations related to training days, groups, and activities, enhancing the user experience for German-speaking users.
2026-03-17 00:16:19 +01:00
Torsten Schulz (local)
2347dccafe feat(memberTransferService, trainingStatsService, i18n): enhance member transfer logic and update localization files
- Updated the member transfer service to only load active members with `testMembership = false`, improving data accuracy during transfers.
- Introduced new functions in the training stats service to count missed training weeks, enhancing training participation tracking.
- Expanded localization files for multiple languages, adding new keys and translations for member management features, improving user experience across the application.
2026-03-16 23:42:28 +01:00
Torsten Schulz (local)
43f96b2491 feat(tournamentService): add validation for stage advancements and enhance tournament configuration
- Implemented a new function `validateStageAdvancements` to ensure the integrity of stage advancements in tournaments, including checks for valid indices, types, and configurations.
- Enhanced the `isGroupAdvancementReady` function to verify group readiness based on match completion and participant positions.
- Updated the tournament management interface to include new validation logic, improving the overall robustness of tournament setup.
- Refactored tournament-related components to improve user experience and ensure accurate data handling across the application.
2026-03-16 22:17:37 +01:00
Torsten Schulz (local)
e1dccb6ff0 feat(OfficialTournaments): redesign tournament management interface
- Introduced a new admin panel for importing tournaments via PDF upload, enhancing user experience for tournament management.
- Reorganized the layout to include tabs for events and participations, improving navigation and accessibility of tournament data.
- Updated event listing with options to edit and delete tournaments, streamlining tournament management tasks.
- Enhanced the visual presentation with clearer headers and descriptions for better user guidance.
2026-03-16 14:12:20 +01:00
Torsten Schulz (local)
adc8857e29 refactor(DiaryView): optimize time checking logic and sound playback
- Simplified the time checking logic for training start and end times, reducing redundancy.
- Introduced a mechanism to prevent repeated sound playback for the same activity marks.
- Enhanced sound playback functions to reset and play sounds more reliably, improving user experience during training sessions.
2026-03-13 17:04:48 +01:00
Torsten Schulz (local)
a030e07b46 refactor(clubSettings): remove myTischtennis club ID and update related logic
- Removed the myTischtennis club ID field from club settings and related backend logic.
- Updated the database migration to eliminate the my_tischtennis_club_id column.
- Adjusted frontend components and services to reflect the removal of the club ID, ensuring the use of association member number instead.
- Enhanced user interface hints to clarify the use of association member number for rankings.
2026-03-12 10:34:10 +01:00
Torsten Schulz (local)
ad09a45b17 feat(clubSettings): enhance club settings with myTischtennis integration
- Added new fields for myTischtennis club ID, federation nickname, and auto-fetch rankings in the club settings.
- Updated the backend to handle the new settings in the updateClubSettings method.
- Implemented automatic ranking updates for clubs based on the new settings in the autoUpdateRatingsService.
- Enhanced the frontend to support the new settings, including validation and user interface updates for better user experience.
2026-03-12 10:25:49 +01:00
Torsten Schulz (local)
595e2eb141 style(LogsView): update success log background color for scheduler runs
- Changed the background color for successful scheduler runs (status 200) from yellow to green for better visibility and user experience.
2026-03-12 10:15:59 +01:00
Torsten Schulz (local)
4251dd6989 feat(clickTtTournamentRegistrationService): add participant registration navigation steps
- Introduced additional steps to navigate to the participant registration section by clicking on 'Teilnehmeranmeldung'.
- Ensured the page waits for the DOM to load and dismisses consent overlays, improving user experience during the registration process.
2026-03-11 21:22:49 +01:00
Torsten Schulz (local)
dba290c1d4 feat(clickTtTournamentRegistrationService): enhance error handling and diagnostics for tournament registration
- Added detailed diagnostics to capture page content and URLs during registration failures, improving error reporting.
- Implemented a function to build a debug HTML path for saving page content, aiding in troubleshooting.
- Updated error handling to include relevant diagnostic information in error messages, enhancing clarity for users.
2026-03-11 21:16:23 +01:00
Torsten Schulz (local)
8776c01e47 refactor(clickTtTournamentRegistrationService): optimize tournament search and link retrieval logic
- Streamlined the tournament search process by caching the results page URL to avoid redundant searches.
- Enhanced the link retrieval mechanism to focus on table rows, improving accuracy in finding relevant tournament links.
- Updated scoring criteria for link selection to better prioritize relevant entries based on tournament and competition context.
2026-03-11 21:10:52 +01:00
Torsten Schulz (local)
fb39aa0e8b feat(clickTtTournamentRegistrationService): implement competition registration saving logic
- Added a new method `_saveCompetitionRegistration` to handle the saving of competition registrations, improving code organization and readability.
- Replaced direct calls to clickTtPlayerRegistrationService for saving with the new method, enhancing maintainability.
- Implemented error handling to verify successful registration and provide feedback if the registration confirmation is not displayed.
2026-03-11 21:00:52 +01:00
Torsten Schulz (local)
f49250e988 refactor(clickTtTournamentRegistrationService): improve link retrieval and scoring logic
- Updated link retrieval to target specific content areas, enhancing accuracy in finding tournament links.
- Added filters to exclude irrelevant links based on href patterns, improving the relevance of selected links.
- Enhanced scoring mechanism for link selection by incorporating additional criteria, ensuring better matching of tournament entries.
2026-03-11 20:58:59 +01:00
Torsten Schulz (local)
555e36ea39 feat(clickTtTournamentRegistrationService): enhance tournament title processing and link retrieval
- Added functions to tokenize tournament titles and generate a search profile, improving the accuracy of title matching.
- Updated the link retrieval logic to utilize the new title processing methods, enhancing the selection of tournament links based on normalized and tokenized title data.
- Improved scoring mechanism for link selection to prioritize relevant tournament entries, ensuring better user experience during registration.
2026-03-11 20:55:14 +01:00
Torsten Schulz (local)
2f82886ad6 feat(officialTournament): add auto-registration for tournament participants
- Implemented a new endpoint to automatically register participants for official tournaments using the Click-TT service.
- Added a corresponding method in the frontend to trigger the auto-registration process, enhancing user experience by simplifying participant management.
- Updated the official tournament controller and routes to support the new functionality.
2026-03-11 20:47:44 +01:00
Torsten Schulz (local)
36ed320893 feat(MembersView): add HTML support for info dialog details
- Introduced a new property `detailsHtml` to the info dialog for rendering HTML content.
- Added a new method `showInfoHtml` to display messages with HTML details.
- Updated the Click-TT application submission flow to utilize the new HTML details feature for improved user feedback.
2026-03-11 20:19:16 +01:00
Torsten Schulz (local)
9d13a2e211 refactor(clickTtPlayerRegistrationService): improve nationality selection logic
- Updated nationality selection to handle multiple selectors, enhancing flexibility in form filling.
- Added checks for 'WONoSelectionString' and specific values to improve validation during player registration.
- Streamlined the selection process by breaking out of the loop once a valid selection is made, improving efficiency.
2026-03-11 17:32:02 +01:00
Torsten Schulz (local)
7cb6b66971 refactor(clickTtPlayerRegistrationService, memberController): improve error handling and diagnostics
- Updated error response structure in memberController to include detailed information instead of a trace array, enhancing clarity for the client.
- Enhanced ClickTtPlayerRegistrationService to capture and return detailed information about the selected search result and last submission attempt, improving error diagnostics.
- Modified frontend to format and display the new detailed error information, providing better context for users during registration failures.
2026-03-11 17:25:15 +01:00
Torsten Schulz (local)
312a1d9d8a refactor(clickTtPlayerRegistrationService): enhance application search and link handling
- Introduced a new function to normalize comparable text for improved matching during application searches.
- Updated the _openApplicationAfterSearch method to accept member data, enhancing the accuracy of application link retrieval based on expected name and birth date.
- Improved error handling to provide clearer feedback when no matching application entry is found, ensuring better user guidance during the registration process.
2026-03-11 17:21:29 +01:00
Torsten Schulz (local)
bfd6068c5c refactor(clickTtPlayerRegistrationService): update nationality selection and gender handling
- Modified nationality selection logic to include an additional condition for 'WONoSelectionString' and a specific value '209', enhancing form validation.
- Updated gender mapping to exclude disabled options, improving the accuracy of gender selection during player registration.
2026-03-11 17:12:09 +01:00
Torsten Schulz (local)
ab466adde7 refactor(clickTtPlayerRegistrationService): enhance error diagnostics and HTML file handling
- Added directory creation for debug HTML file paths to ensure proper file generation during error handling.
- Improved diagnostics object to capture additional error context, including URL and sanitized body text, enhancing troubleshooting capabilities.
- Streamlined error handling to maintain partial diagnostics even when certain data retrieval fails, improving robustness in error reporting.
2026-03-11 17:04:49 +01:00
Torsten Schulz (local)
a77926838b refactor(clickTtPlayerRegistrationService): update debug HTML file path generation
- Modified the buildDebugHtmlPath function to use path.join for constructing the file path, ensuring compatibility across different operating systems.
- This change enhances the maintainability of the code by standardizing file path handling.
2026-03-11 16:42:23 +01:00
Torsten Schulz (local)
c0efd56c9c feat(clickTtPlayerRegistrationService): add debug HTML file generation for error diagnostics
- Introduced a new function to generate a timestamped HTML file containing the page content during error handling, improving diagnostics for failed registration attempts.
- Enhanced error reporting to include the path to the generated HTML file, providing better context for troubleshooting issues in the registration flow.
2026-03-11 16:37:08 +01:00
Torsten Schulz (local)
79cec02c1a refactor(clickTtPlayerRegistrationService): improve nationality and country selection logic
- Replaced direct option selection with a new method that selects options based on partial label matching, enhancing flexibility in form filling.
- Introduced the _selectOptionByLabelContains method to streamline the selection process for nationality and country fields, improving code maintainability.
2026-03-11 16:34:11 +01:00
Torsten Schulz (local)
c1cf903196 refactor(clickTtPlayerRegistrationService): enhance application form filling and consent handling
- Updated the _fillApplicationForm method to include trace logging and improved handling of form fields, ensuring all necessary data is filled correctly.
- Introduced a new method to check and fill empty fields, streamlining the form completion process.
- Added consent overlay dismissal to improve user experience during the registration flow.
2026-03-11 16:31:23 +01:00
Torsten Schulz (local)
6a18d4ce0f refactor(clickTtPlayerRegistrationService): enhance search form handling and error reporting
- Updated the _fillSearchForm method to include trace logging and improved error handling for missing search forms, ensuring better feedback during the registration process.
- Enhanced the _openApplicationEntry method to streamline application entry checks and improve error messaging for missing elements, contributing to a more robust registration flow.
2026-03-11 16:25:34 +01:00
Torsten Schulz (local)
cf376a8f68 refactor(clickTtPlayerRegistrationService): streamline application entry handling
- Replaced the method for ensuring application entry visibility with a more direct approach to open the application entry, improving code clarity and reducing complexity.
- Enhanced error handling to provide clearer feedback when the application entry cannot be found, ensuring better user guidance during the registration process.
2026-03-11 16:14:25 +01:00
Torsten Schulz (local)
5c9901209c feat(clubSettings): enhance club settings UI with loading and error handling
- Added loading and error states to the club settings view, improving user feedback during data retrieval.
- Introduced new translations for error messages in the German locale, enhancing localization support.
- Updated the save method to alert users when no club is selected, ensuring clarity in user actions.
2026-03-11 16:08:47 +01:00
Torsten Schulz (local)
8750ac6d65 fix(clickTtPlayerRegistrationService): improve error messaging for missing association member number
- Updated the club retrieval logic to include specific attributes, enhancing data accuracy.
- Enhanced error handling to provide detailed feedback when the association member number is missing, including the club name for better context.
2026-03-11 15:58:34 +01:00
Torsten Schulz (local)
2919ee3764 feat(clickTtPlayerRegistrationService): enhance registration flow with club context and error handling
- Integrated Club model to retrieve association member number during player registration, ensuring necessary data is available.
- Added methods to select club context and ensure application entry visibility, improving navigation within the Click-TT interface.
- Enhanced error handling for missing association member numbers, providing clearer feedback to users during the registration process.
- Updated consent overlay handling to streamline user interactions and improve automation in the registration flow.
2026-03-11 15:55:21 +01:00
Torsten Schulz (local)
7196fae28e feat(server, models, services, frontend): integrate Click-TT account functionality
- Added ClickTtAccount model and integrated it into the server and database synchronization processes.
- Updated ClickTtPlayerRegistrationService to utilize ClickTtAccount for user account management, enhancing the registration flow.
- Modified frontend components to include navigation to Click-TT account settings and updated routing to support the new account view.
- Improved error handling and user feedback in the registration process, ensuring clarity in account-related operations.
2026-03-11 15:47:58 +01:00
Torsten Schulz (local)
2ddb63b932 feat(clickTtPlayerRegistrationService): enhance login flow and consent handling
- Added a new constant for the Click-TT login URL to streamline navigation during the login process.
- Improved the login flow by directly navigating to the login URL if no direct login fields are found, enhancing user experience.
- Introduced a method to dismiss consent overlays, improving the automation of the registration process by handling consent banners effectively.
2026-03-11 15:36:24 +01:00
Torsten Schulz (local)
8fc754c235 feat(clickTtPlayerRegistrationService, MembersView): enhance Click-TT registration flow and UI feedback
- Replaced the abort confirmation flow with a new function to open the application after a search, improving user experience by directly navigating to the application.
- Updated error handling to provide clearer messages when a confirm dialog is encountered, enhancing traceability.
- Modified the MembersView to track application submission status and display detailed success/error messages, improving user feedback during the registration process.
- Introduced a new method to format and display trace details, aiding in debugging and user communication.
2026-03-11 15:31:37 +01:00
Torsten Schulz (local)
139d169fcc fix(clickTtHttpPageRoutes): improve inline confirm element handling and logging
- Updated the regex for matching inline confirm elements to enhance accuracy in capturing onclick attributes.
- Improved logging to include the full onclick value during inline confirm inspections, aiding in debugging.
- Adjusted the output format for inline confirm elements in logs to JSON for better readability and traceability.
2026-03-11 13:28:58 +01:00
Torsten Schulz (local)
de1382b57e feat(clickTtHttpPageRoutes): add inline confirm elements summarization and logging
- Introduced a new function to summarize inline confirm elements in HTML, capturing relevant attributes for better tracking of user interactions.
- Enhanced the proxy navigation script to log details of inline confirm elements, improving traceability during proxy interactions.
- Updated the existing functionality to ensure inline confirm elements are logged when present, contributing to better debugging and user experience.
2026-03-11 13:25:18 +01:00
Torsten Schulz (local)
08095ce22e feat(memberController, memberRoutes, MembersView): implement Click-TT player registration feature
- Added a new endpoint for Click-TT player registration in memberController, allowing submission of existing player applications.
- Integrated the new endpoint into memberRoutes for handling requests.
- Updated MembersView to include a button for initiating Click-TT registration, with user confirmation and loading state management.
- Enhanced UI feedback for registration status, improving user experience during the application process.
2026-03-11 13:17:59 +01:00
Torsten Schulz (local)
9c30cd181c feat(clickTtHttpPageRoutes): add inline confirm handling to proxy navigation script
- Introduced a new function to manage inline confirm dialogs, allowing user confirmation before proceeding with actions triggered by inline event handlers.
- Enhanced the proxy navigation script to prevent default actions if the inline confirm is not accepted, improving control over user interactions.
- Updated logging to capture confirmation dialog details for better traceability during proxy interactions.
2026-03-11 13:04:37 +01:00
Torsten Schulz (local)
c1f45b2b98 feat(server): add raw body parsing for multipart/form-data and application/octet-stream
- Implemented a new middleware to handle raw body parsing for specific content types, allowing for larger payloads up to 5mb.
- Enhanced form body serialization to support Buffer input, improving compatibility with various data formats during proxy interactions.
2026-03-11 12:45:25 +01:00
Torsten Schulz (local)
e5e1ccba82 feat(clickTtHttpPageRoutes): enhance form submission handling and logging
- Added logic to track the last submitter for forms, improving the accuracy of form data submissions.
- Implemented checks to append hidden fields for submitters without existing form fields, ensuring all relevant data is captured.
- Enhanced logging for form submissions to include submitter details, improving traceability and debugging capabilities during proxy interactions.
2026-03-11 12:37:01 +01:00
Torsten Schulz (local)
3ea9cdd611 feat(clickTtHttpPageRoutes): add sensitive data masking and form body summarization
- Introduced functions to mask sensitive values in form submissions and summarize form body data, enhancing security and logging capabilities.
- Updated the proxy POST route to log detailed information about submitted forms, including field counts and redacted values for sensitive keys.
- Enhanced error handling for URL-encoded body parsing, improving robustness in form data processing.
2026-03-11 12:25:45 +01:00
Torsten Schulz (local)
1398e8911a feat(clickTtHttpPageRoutes, ClickTtView): enhance script injection for improved HTML handling
- Added checks to inject the navigation script into the <head> or <body> of HTML responses, ensuring proper script placement for enhanced functionality.
- Updated ClickTtView to include a reference for the iframe, improving access to the iframe element for future interactions.
- Enhanced overall HTML processing during proxy interactions, contributing to a more robust user experience.
2026-03-11 00:06:42 +01:00
Torsten Schulz (local)
c40ee04e9e feat(clickTtHttpPageRoutes): add inline script normalization for improved HTML processing
- Introduced a new function, normalizeInlineScriptStrings, to standardize inline script content in HTML, ensuring proper handling of escape characters and line breaks.
- Updated the proxy GET and POST routes to utilize the new normalization function, enhancing the robustness of HTML processing during proxy interactions.
- Enhanced existing URL validation logic to include additional allowed domains, improving overall proxy functionality.
2026-03-10 23:33:40 +01:00
Torsten Schulz (local)
dee96a9445 feat(clickTtHttpPageRoutes): enhance HTML processing for proxy handling
- Introduced a new function, applyOutsideScriptTags, to safely transform HTML while preserving script tags, improving the handling of links, meta-refresh, and form actions.
- Updated existing functions to utilize the new transformation method, ensuring that proxy URLs are correctly applied without disrupting script execution.
- Enhanced error handling in HTML processing functions to maintain robustness during URL rewriting operations.
2026-03-10 23:18:45 +01:00
Torsten Schulz (local)
4484f122d2 feat(clickTtHttpPageRoutes): extend proxy URL validation to include liga.nu
- Added a new function, isAllowedProxyUrl, to validate URLs against an updated list of allowed domains, including liga.nu.
- Updated existing checks in the proxy GET and POST routes to utilize the new validation function, ensuring consistent error messaging for unsupported URLs.
- Enhanced error responses to reflect the inclusion of liga.nu in the allowed domains, improving user guidance during proxy interactions.
2026-03-10 23:06:46 +01:00
Torsten Schulz (local)
df95753f4d refactor(clickTtHttpPageRoutes): optimize proxy navigation script for improved readability and performance
- Refactored the proxy navigation script by restructuring the code into a more concise format using an array to build the script content, enhancing readability.
- Maintained the core functionality while ensuring compatibility with existing event handling and URL management logic.
2026-03-10 23:02:46 +01:00
Torsten Schulz (local)
79ce79db8c fix(clickTtHttpPageRoutes): improve event handling in proxy navigation script
- Added a check to prevent default actions if the event has already been prevented, ensuring better control over link navigation.
- Changed the event listener from capturing to bubbling phase to enhance compatibility with other event handlers in the document.
2026-03-10 22:57:06 +01:00
Torsten Schulz (local)
59d7c3559c feat(clickTtHttpPageRoutes, ClickTtView): implement proxy navigation script and enhance URL selection
- Added a new function to inject a navigation script into HTML responses, enabling seamless proxy navigation for links and form submissions.
- Updated the ClickTtView component to include new preset URL options for HTTV and TTDE login, improving user experience by providing quick access to common links.
- Adjusted form handling logic to support the new preset URLs, ensuring proper URL management during proxy interactions.
2026-03-10 22:48:54 +01:00
Torsten Schulz (local)
71ac054d48 feat(ClickTtView): add URL template selection for 'url' page type
- Introduced a new dropdown for selecting predefined URLs when the page type is set to 'url', enhancing user experience by providing quick access to common links.
- Included options for HTTV and TTDE login URLs to streamline navigation for users.
2026-03-10 22:39:26 +01:00
Torsten Schulz (local)
c472bb1fdc fix(clickTtHttpPageRoutes, clickTtHttpPageService): update effective page URL handling in proxy routes
- Introduced a new variable, effectivePageUrl, to ensure the correct URL is used for rewriting links and form actions in both GET and POST proxy requests.
- Updated the logic to derive the page origin from effectivePageUrl, enhancing the accuracy of base tag handling for relative URLs.
- Enhanced logging in the fetchWithLogging function to include the response URL, improving traceability in HTTP requests.
2026-03-10 22:37:30 +01:00
Torsten Schulz (local)
0e4d1707fd feat(logging): enhance HTTP request logging with additional payload details
- Introduced new fields in the HttpPageFetchLog model to capture request and response headers, bodies, and method for improved logging granularity.
- Updated the logging service to serialize and store these new details during HTTP fetch operations, enhancing traceability and debugging capabilities.
- Modified the clickTtHttpPageRoutes to include the new logging features, allowing for optional payload inclusion in log queries.
2026-03-10 22:26:37 +01:00
Torsten Schulz (local)
4f3a1829ca feat(clickTtHttpPageRoutes): add meta-refresh URL rewriting for proxy handling
- Implemented a new function to rewrite meta-refresh URLs in HTML, ensuring redirects after login are routed through the proxy while maintaining session integrity.
- Updated the proxy GET and POST endpoints to include the new meta-refresh handling, enhancing the overall functionality of the proxy interactions.
- Improved error handling in the new function to ensure robustness in processing HTML content.
2026-03-10 22:15:37 +01:00
Torsten Schulz (local)
bacc6b994d refactor(clickTtHttpPageRoutes): standardize variable names for clarity in proxy response handling
- Renamed variables for the response body and content type to improve code readability and maintainability.
- Ensured consistent usage of the new variable names throughout the proxy response processing logic, enhancing overall code clarity.
2026-03-10 22:10:16 +01:00
Torsten Schulz (local)
c5a88324c3 feat(clickTtHttpPageRoutes): enhance proxy functionality with session management and cookie handling
- Introduced session ID management to maintain user sessions across proxy requests, improving tracking and logging capabilities.
- Added functions to extract and format cookies, enabling proper cookie handling for requests and responses.
- Updated link and form action rewriting functions to include session IDs, ensuring seamless integration with the proxy.
- Enhanced the proxy GET and POST endpoints to manage cookies effectively, improving the overall robustness of the proxy interactions.
2026-03-10 22:08:20 +01:00
Torsten Schulz (local)
cab06f9ad6 feat(clickTtHttpPageRoutes): implement form action rewriting and proxy for POST requests
- Added a new function to rewrite form actions in HTML to route submissions through the proxy, enhancing logging for login actions.
- Implemented a POST endpoint for the proxy to handle form submissions, including validation for target URLs and forwarding requests with appropriate headers.
- Enhanced error handling and response management for the new proxy functionality, ensuring robust interaction with external services.
2026-03-10 22:03:32 +01:00
Torsten Schulz (local)
c87cebba36 feat(clickTtHttpPageRoutes): add base tag handling for relative URLs in proxy response
- Implemented logic to dynamically insert a base tag in the HTML response for relative URLs, ensuring proper resolution of form actions during login.
- Enhanced the existing proxy route to improve handling of HTML content fetched from target URLs.
2026-03-10 22:01:39 +01:00
Torsten Schulz (local)
a3148a3781 feat(readme): add configuration details for link rewriting and update proxy URL handling
- Introduced a new section in the README for environment variable configuration related to link rewriting, specifying BACKEND_BASE_URL and BASE_URL usage.
- Updated the clickTtHttpPageRoutes to dynamically construct the proxy base URL from environment variables, enhancing flexibility for different deployment environments.
2026-03-10 21:57:43 +01:00
Torsten Schulz (local)
c13f426b3d feat(tournaments): add update functionality for official tournaments
- Implemented an API endpoint to update the title of official tournaments, including error handling for non-existent tournaments.
- Enhanced the frontend to allow users to edit tournament titles directly, with input validation and feedback for successful updates or errors.
- Updated the German localization file to include new strings for editing titles and error messages.
2026-03-10 21:54:03 +01:00
Torsten Schulz (local)
ea6acd8c6c chore(dependencies): update package-lock.json for dottie, dompurify, and immutable
- Bumped dottie version from 2.0.6 to 2.0.7.
- Updated dompurify from 3.3.1 to 3.3.2, adding node engine compatibility.
- Upgraded immutable from 5.1.3 to 5.1.5.
2026-03-10 21:43:50 +01:00
Torsten Schulz (local)
c324da3938 fix(App.vue): update user permissions check and enhance dropdown behavior
- Changed the condition for displaying the click-TT link from admin status to a more specific permission check (canManagePermissions).
- Adjusted dropdown styling to improve usability by setting a maximum height and enabling vertical scrolling while hiding horizontal overflow.
2026-03-10 21:41:43 +01:00
Torsten Schulz (local)
f35e0510e7 feat(tournaments): enhance age eligibility logic and date parsing
- Improved the getCutoffDate method to derive cutoff dates from performance class if not explicitly provided.
- Updated getAgeLimitFromText to handle various age class formats, returning structured age limit objects.
- Introduced getBirthYearFromPerformanceClass to extract birth year information from performance class strings.
- Refined isEligibleByAge to incorporate new eligibility rules based on performance class and age limits, ensuring accurate age validation for tournament participation.
2026-03-10 21:39:50 +01:00
Torsten Schulz (local)
13379d6b24 feat(logging): add HTTP page fetch logging and enhance click-TT proxy functionality
- Introduced a new logging mechanism for HTTP requests to click-TT/HTTV pages, improving traceability and debugging capabilities.
- Implemented a proxy endpoint for iframe embedding, allowing direct HTML retrieval with enhanced error handling and validation for input URLs.
- Updated the frontend to include a new navigation link for the click-TT feature, accessible to admin users.
- Added a new route for the click-TT view in the router configuration.
2026-03-10 21:27:40 +01:00
Torsten Schulz (local)
055dbf115c feat(diary): improve predefined activities UI and enhance socket event integration
- Updated UI components for managing predefined activities to enhance user experience and accessibility.
- Improved socket event integration for real-time updates related to predefined activities, ensuring seamless interaction with the diary service.
- Refactored related mappers to support new UI changes and maintain data consistency across the application.
2026-03-10 21:24:45 +01:00
Torsten Schulz (local)
78f1196f0a feat(android): configure API and socket endpoints for debug and release builds
- Added API and socket base URLs for both debug and release configurations in gradle.properties.
- Enhanced build.gradle.kts to utilize environment variables for dynamic endpoint configuration.
- Updated debug build type with specific application ID suffix and version name suffix for better differentiation.
- Included necessary dependencies for improved functionality and compatibility.
2026-03-10 21:18:29 +01:00
Torsten Schulz (local)
ee2b12f6d0 feat(diary): enhance predefined activities management and socket event handling
- Added new API endpoints for managing predefined activities, including retrieval, creation, updating, merging, and deduplication.
- Updated socket event handling to improve the mapping of socket events to domain events, ensuring better integration with the diary service.
- Enhanced repository mappers to support detailed mapping of predefined activities and training statistics, including images and member participation data.
- Introduced new UI strings for managing predefined activities, improving user experience in the diary section.
2026-03-06 10:02:50 +01:00
Torsten Schulz (local)
436973e47e Diary: verbessere Socket-getriggerte Reloads für Gruppen/Unfälle 2026-03-05 23:54:22 +01:00
Torsten Schulz (local)
05ab872f77 Diary: ergänze Mapper-Tests für Assignments und Predefined Activities 2026-03-05 23:54:00 +01:00
Torsten Schulz (local)
e4e7f521e2 Diary: erweitere Plan/Assignments, QuickAdd, Accident und Stats 2026-03-05 23:50:10 +01:00
Torsten Schulz (local)
dd93755e6b refactor(myTischtennis): streamline session management and enhance login flow
- Updated AutoFetchMatchResultsService to utilize verifyLogin for session re-establishment, improving reliability and handling of CAPTCHA challenges.
- Refactored MyTischtennisService to save Playwright storage state separately, ensuring robustness in session persistence and preventing failures due to missing DB columns.
- Minor adjustments in TeamManagementView to enhance async data fetching logic.
2026-03-05 10:02:43 +01:00
Torsten Schulz (local)
27665a45df feat(myTischtennis): implement session restoration for browser automation login
- Enhanced the loginWithBrowserAutomation method to accept an options parameter for restoring a saved Playwright session, allowing users to bypass CAPTCHA if the session is still valid.
- Added a new playwrightStorageState field in the MyTischtennis model to store the encrypted browser storage state, facilitating session persistence.
- Updated myTischtennisService to utilize the saved storage state during login attempts, improving user experience by reducing unnecessary CAPTCHA challenges.
2026-03-04 14:26:27 +01:00
Torsten Schulz (local)
637bacf70f fix(myTischtennis): improve consent dialog handling during login flow
- Refactored consent dialog handling to first visit the homepage, ensuring correct cookie storage for CMP consent.
- Enhanced the acceptConsentDialog function to handle multiple selectors and added logging for better visibility.
- Implemented a second consent attempt on the login page to address potential reappearance of the consent banner.
2026-03-04 08:39:14 +01:00
Torsten Schulz (local)
d5fe531664 fix(myTischtennis): enhance consent dialog handling during login process
- Refactored consent dialog handling to improve reliability by implementing a dedicated function that attempts to dismiss the dialog with multiple selectors.
- Added a delay mechanism to account for asynchronous rendering of the consent dialog, ensuring it is accepted promptly.
- Improved logging to provide clearer feedback on which selector was used to accept the consent dialog.
2026-03-04 08:29:18 +01:00
Torsten Schulz (local)
3df8f6fd81 feat(myTischtennis): implement asynchronous team data fetching and job status tracking
- Added a new endpoint to start an asynchronous job for fetching team data, allowing for non-blocking operations.
- Implemented job status tracking to retrieve the status of ongoing fetch jobs, enhancing user experience with real-time updates.
- Updated the frontend to initiate async fetch requests and poll for job completion, improving data retrieval efficiency and user feedback.
2026-03-02 13:32:57 +01:00
Torsten Schulz (local)
e26bc22e19 fix(myTischtennis): ensure login intent handling and improve form submission logic
- Added logic to ensure the presence of a login intent field in the login form, enhancing the reliability of the login process.
- Updated the form submission mechanism to prioritize the explicit login submit button, falling back to a generic submit button if necessary.
- Improved overall interaction flow during the login process, ensuring a smoother user experience.
2026-03-02 11:55:15 +01:00
Torsten Schulz (local)
985c9074bd fix(myTischtennis): add detailed diagnostics for login failures
- Introduced failure diagnostics logging during the login process to capture URL, cookie names, and body preview on errors.
- Enhanced error handling to provide clearer insights into login issues, particularly related to CAPTCHA and password failures.
- Improved console warnings for better visibility into authentication problems encountered during login attempts.
2026-03-02 11:46:19 +01:00
Torsten Schulz (local)
d33e9a94cf fix(myTischtennis): improve error detection during login process
- Introduced enhanced error detection for CAPTCHA and login failures by probing page text during cookie retrieval attempts.
- Reduced maximum polling attempts and adjusted polling interval for better performance and responsiveness.
- Updated error handling to provide clearer feedback on specific login issues encountered during the authentication process.
2026-03-02 11:40:49 +01:00
Torsten Schulz (local)
6ab6319256 fix(myTischtennis): refine CAPTCHA readiness checks and improve interaction flow
- Introduced a new mechanism to detect CAPTCHA readiness before form submission, enhancing login reliability.
- Adjusted timeout settings for CAPTCHA field checks to optimize performance during login attempts.
- Added diagnostic logging for better visibility into CAPTCHA state changes and interaction outcomes.
2026-03-02 11:17:26 +01:00
Torsten Schulz (local)
e1e8b5f4a4 fix(myTischtennis): enhance CAPTCHA handling and login reliability
- Improved CAPTCHA interaction by adding checks for readiness before form submission, ensuring smoother login processes.
- Increased the maximum attempts for cookie retrieval to enhance reliability in detecting authentication tokens.
- Updated error messages to provide clearer feedback on login failures related to CAPTCHA and password issues.
2026-03-02 10:40:50 +01:00
Torsten Schulz (local)
cf8cf17dc7 feat(myTischtennis): enhance CAPTCHA handling and refactor controller logic
- Added visual state tracking for CAPTCHA elements in MyTischtennisClient to improve interaction reliability.
- Increased timeout for CAPTCHA field population to ensure proper handling during login.
- Refactored MyTischtennisController to utilize myTischtennisProxyService for content rewriting and session management, streamlining the login process.
- Removed deprecated content rewriting logic, enhancing code maintainability and clarity.
2026-03-02 09:05:43 +01:00
Torsten Schulz (local)
12bba26ff1 fix(myTischtennis): improve error handling for Playwright login and account verification
- Enhanced error handling in MyTischtennisClient and MyTischtennisService to provide clearer feedback when browser executables are missing.
- Updated responses to include specific error messages and status codes, improving user guidance for setup requirements.
- Refactored MyTischtennisDialog and MyTischtennisAccount components to handle API response errors more effectively, ensuring robust login and account management processes.
2026-02-27 17:23:03 +01:00
Torsten Schulz (local)
4e81a1c4a7 feat(myTischtennis): integrate Playwright for CAPTCHA handling and enhance login form functionality
- Added Playwright as a dependency to handle CAPTCHA challenges during login attempts.
- Implemented a new endpoint to retrieve the login form from myTischtennis, parsing necessary fields for user input.
- Enhanced the login process to utilize Playwright for browser automation when CAPTCHA is required.
- Updated the MyTischtennisDialog component to support local login form submission instead of using an iframe.
- Refactored the MyTischtennisController to include proxy functionality for serving resources and handling login submissions.
- Improved error handling and user feedback during login attempts, ensuring a smoother user experience.
2026-02-27 17:15:20 +01:00
Torsten Schulz (local)
b2017b7365 fix(matchService): handle missing match after update with HttpError
- Added error handling in MatchService to throw an HttpError if a match is not found after an update, improving robustness.
- Enhanced sorting logic in DiaryView to ensure case-insensitive comparison for first and last names, with a stable fallback using IDs.
- Refactored currentClub watcher in ScheduleView to use an object syntax for better clarity and immediate execution on initialization.
2026-02-27 12:00:23 +01:00
Torsten Schulz (local)
b3bbca3887 feat(socket): implement match report submission and schedule update events
- Added WebSocket events for match report submission and schedule updates, enhancing real-time communication between clients and the server.
- Updated matchController to emit schedule updates when match players are modified.
- Enhanced nuscoreApiRoutes to emit match report submissions with relevant data for other clients.
- Implemented socket service methods for handling incoming match report submissions and schedule updates in the frontend.
- Updated MatchReportApiDialog and ScheduleView components to handle new WebSocket events, ensuring data synchronization across clients.
2026-02-26 17:07:54 +01:00
Torsten Schulz (local)
0ee9e486b5 feat(match-report): enhance score input validation and parsing in MatchReportApiDialog
- Improved score input handling by allowing whitespace in valid patterns and ensuring robust parsing of various score formats.
- Updated logic to handle edge cases for score entry, including explicit handling of negative scores and single positive numbers.
- Enhanced overall user experience by ensuring cleaner input processing and validation, maintaining data integrity during score entry.
2026-02-26 16:52:52 +01:00
Torsten Schulz (local)
00e058a665 feat(match-report): add clear button functionality to floating keyboard in MatchReportApiDialog
- Introduced a new button for clearing the current set input, enhancing user control during score entry.
- Updated the keyboard layout to include the clear button, improving the overall usability of the floating keyboard.
- Adjusted the logic to ensure proper handling of set clearing, maintaining data integrity in the match results.
2026-02-26 16:42:53 +01:00
Torsten Schulz (local)
e5a0dfdddc feat(match-report): update MatchReportApiDialog with enhanced keyboard input and styling
- Improved the floating keyboard layout by adding individual buttons for numbers 1-9, enhancing user interaction.
- Adjusted styles for the keyboard and input fields, including font size, padding, and grid layout, to improve usability.
- Ensured consistent styling for keyboard keys, enhancing the overall user experience during score entry.
2026-02-26 16:36:13 +01:00
Torsten Schulz (local)
83f4e1c45e feat(match-report): add lineup certification logic in MatchReportApiDialog
- Implemented applyLineupCertificationFromMeetingDetails method to automatically set lineup certification based on meeting details.
- Enhanced the initialization process to include lineup confirmation when PINs are already signed, improving user experience and data accuracy.
2026-02-26 16:27:58 +01:00
Torsten Schulz (local)
f0477b1023 feat(match-report): improve result initialization and data synchronization in MatchReportApiDialog
- Enhanced the initializeResults method to retain existing match results when available, improving data consistency.
- Added logic to set start and end dates based on available meeting data, ensuring accurate match timing.
- Implemented populateResultsFromMeetingDetails to transfer existing set results from meeting details, enhancing data accuracy.
- Improved fallback mechanisms for match results to ensure defaults are only created when necessary.
2026-02-26 16:27:00 +01:00
Torsten Schulz (local)
07370bfcef feat(match-report): implement floating keyboard for set input in MatchReportApiDialog
- Added a floating keyboard overlay for set input, allowing users to enter scores without using the system keyboard.
- Updated input fields to be read-only and disabled system keyboard interactions, enhancing user experience.
- Implemented methods to manage keyboard interactions, including key input, backspace, and confirmation actions.
- Improved styling for the floating keyboard to ensure clarity and usability during score entry.
2026-02-26 16:18:55 +01:00
Torsten Schulz (local)
f031485bd4 feat(match-report): enhance score input with additional buttons and auto-completion logic
- Added extra buttons for appending ':' and '-' to score inputs in the MatchReportApiDialog component, improving user interaction.
- Implemented a method to automatically complete matches when a player wins 3 sets, enhancing match management.
- Updated styles for input elements and buttons to improve layout and usability in the ScheduleView component.
2026-02-26 16:11:33 +01:00
Torsten Schulz (local)
e22e3257ef feat(auth): implement password reset functionality
- Added new endpoints for requesting and resetting passwords in the authController.
- Updated User model to include resetToken and resetTokenExpires fields for managing password reset requests.
- Enhanced emailService to send password reset emails with secure links.
- Updated frontend routes and views to support password reset flow, including new ForgotPassword and ResetPassword components.
- Improved internationalization files with new translation keys for password reset messages across multiple languages.
2026-02-09 08:40:27 +01:00
Torsten Schulz (local)
76f1b1a12f refactor(tournament): improve top 3 participant identification in PDF generation
- Updated the logic in the TournamentPlacementsTab component to filter and identify top 3 participants from K.O. rounds more accurately.
- Replaced the previous method of using final placements with a check for filtered K.O. rounds, enhancing the reliability of the data used in PDF generation.
- Ensured that the PDF generation process correctly handles cases where no K.O. rounds are present, maintaining clarity in the output.
2026-02-06 16:41:42 +01:00
Torsten Schulz (local)
6007e70b9d refactor(tournament): clean up placeholder handling in PDF generation
- Removed placeholder variables for missing participant data in the TournamentPlacementsTab component.
- Updated the PDF generation logic to use empty strings instead of placeholders for missing data fields, improving the clarity of the generated reports.
- Adjusted cell styles to ensure consistent formatting in the PDF output.
2026-02-06 16:36:25 +01:00
Torsten Schulz (local)
d7935cc1e2 fix(tournament): update PDF title for missing data report
- Changed the PDF title in the TournamentPlacementsTab component to reflect the top 3 participants instead of a generic missing data title.
- This update enhances clarity in the generated PDF reports for users.
2026-02-06 16:33:52 +01:00
Torsten Schulz (local)
b470e728ed feat(tournament): update PDF generation for top 3 participants
- Enhanced the TournamentPlacementsTab component to identify and process only the top 3 participants for PDF generation.
- Added logic to handle cases where no top 3 placements are available, emitting appropriate messages to the user.
- Updated internationalization files to include new translation keys for top 3 related messages and PDF titles across multiple languages.
2026-02-06 16:33:39 +01:00
Torsten Schulz (local)
d09de49018 feat(tournament): enhance PDF generation for missing participant data
- Updated the TournamentPlacementsTab component to include phone numbers in the PDF generation for participants with missing data.
- Improved the layout of the PDF by adjusting column widths and changing the orientation to landscape.
- Enhanced internationalization by adding new translation keys for "phone", "generatingPDF", and "page" across multiple languages.
- Updated the button text to reflect the PDF generation status more accurately.
2026-02-06 16:24:08 +01:00
Torsten Schulz (local)
8892392bf2 feat(tournament): add PDF generation for missing participant data
- Implemented a new feature to generate a PDF report for participants with missing data in mini championships.
- Added a button in the TournamentPlacementsTab component to trigger the PDF generation, which is disabled while loading.
- Enhanced internationalization by adding translation keys for the new PDF feature across multiple languages.
- Updated the TournamentTab component to pass the `isMiniChampionship` prop and handle the new `show-info` event.
2026-02-06 16:11:17 +01:00
Torsten Schulz (local)
26acb588e1 feat(player-details): enhance player data display with missing data indicators
- Updated the PlayerDetailsDialog component to show a placeholder message when player data is not recorded, improving user experience and clarity.
- Added a new CSS class for missing data to visually differentiate it from available information.
- Enhanced internationalization by adding translation keys for the "data not recorded" message across multiple languages.
2026-02-06 15:28:13 +01:00
Torsten Schulz (local)
566361e46a feat(tournament): add number of tables feature and update related logic
- Introduced a new field `numberOfTables` in the Tournament model to track the number of tables for tournaments.
- Updated the tournament update logic to include `numberOfTables` when modifying tournament details.
- Added a new endpoint to set the table number for matches, enhancing match management.
- Updated frontend components to support the new `numberOfTables` feature, including input fields and table distribution logic.
- Enhanced internationalization with new translation keys for table-related features.
2026-02-06 15:12:05 +01:00
Torsten Schulz (local)
1191636d92 chore(dependencies): remove deprecated node-pre-gyp package and related binaries
- Deleted the entire @mapbox/node-pre-gyp package along with its binaries and documentation files, as it is no longer maintained.
- Removed symlinks for various binaries in the node_modules/.bin directory to clean up unused references.
- This cleanup helps streamline the project by eliminating unnecessary dependencies and files.
2026-02-05 23:29:46 +01:00
Torsten Schulz (local)
526eca8b97 feat(schedule): normalize player lists for match updates
- Introduced a `normalizePlayersList` function to handle player data from match updates, ensuring valid and consistent player arrays for `playersReady`, `playersPlanned`, and `playersPlayed`.
- Updated the logic in ScheduleView to utilize normalized player lists when setting member statuses, improving data integrity and error handling.
2026-02-05 23:16:15 +01:00
Torsten Schulz (local)
af6048b289 feat(match): normalize player lists before updating match data
- Added a `normalizeList` function to filter out duplicates and invalid entries from player arrays.
- Updated the match update logic to use normalized player lists for `playersReady`, `playersPlanned`, and `playersPlayed`.
- Enhanced error handling in the ScheduleView to throw an error for failed match updates based on response status.
2026-02-05 23:07:41 +01:00
Torsten Schulz (local)
5605cd6189 feat(match): add endpoint to retrieve active players for a club
- Implemented a new controller method `getMatchPlayers` to fetch active members of a specified club, returning their details.
- Updated the match routes to include a new GET route for retrieving players by club ID.
- Modified the ScheduleView to include the current club ID when updating player information.
2026-02-05 22:58:44 +01:00
Torsten Schulz (local)
84bbcb0f87 fix(club): adjust access request button placement in ClubView
- Moved the request access button inside the no access message block for better visual coherence.
- Ensured that the button remains disabled when an access request is pending, maintaining user experience consistency.
2026-02-04 13:44:09 +01:00
Torsten Schulz (local)
f9a63a13ce fix(club): enhance error handling in loadClub method
- Improved error handling in the loadClub method to check response status directly, ensuring proper fallback club data is used when access is denied.
- Updated logic to throw an error for unexpected response statuses, enhancing user feedback and robustness in access management.
2026-02-04 13:42:33 +01:00
Torsten Schulz (local)
2a7694617b feat(i18n): add access request pending message to multiple languages
- Introduced a new translation key "accessRequestPending" across various language files to inform users that access to a club has been requested and to ask for their patience.
- Updated the ClubView component to utilize this new message for better user feedback when access is pending.
2026-02-04 13:39:52 +01:00
Torsten Schulz (local)
6ff672c5f1 fix(club): improve error handling and access logic in loadClub method
- Refactored the loadClub method to handle access checks more effectively, ensuring fallback club data is used when access is denied.
- Enhanced error handling to manage access request status and provide appropriate user feedback based on backend responses.
2026-02-04 13:32:41 +01:00
Torsten Schulz (local)
a2e9e5e510 feat(club): enhance access request functionality in ClubView
- Added a visual indicator for access requests with a new message when access is requested.
- Disabled the request access button once the request has been made to prevent duplicate submissions.
- Improved error handling in the requestAccess method to manage access request status more effectively.
2026-02-04 13:30:23 +01:00
Torsten Schulz (local)
5b0a3baa21 feat(club): enhance club access routes and permissions handling
- Reorganized club-related routes for better clarity and access control, ensuring specific routes are prioritized.
- Updated the store to reset user-specific permissions upon token setting, improving security.
- Modified the ClubView component to handle access checks more effectively, allowing for fallback club data when access is denied.
2026-02-04 13:28:02 +01:00
Torsten Schulz (local)
9cb9ff511c feat(club): refine access control in loadOpenRequests method
- Added a check for access permissions before loading open requests to enhance security and prevent unauthorized access.
- Updated the mounted lifecycle hook to conditionally call loadOpenRequests based on access permissions, improving user experience and performance.
2026-02-04 13:22:22 +01:00
Torsten Schulz (local)
e079fe4827 feat(club): improve club access logic and refactor API calls
- Added methods to retrieve club ID and check access permissions based on user roles and permissions.
- Refactored API calls in loadClub, loadOpenRequests, and requestAccess methods to utilize the new club ID retrieval logic.
- Enhanced error handling in loadClub to manage access denial more effectively.
2026-02-04 13:19:42 +01:00
Torsten Schulz (local)
2c8cad52a7 feat(router): enhance club loading logic and update route parameter naming
- Refactored the loadClub method to handle club creation and permission checks, improving navigation based on user roles.
- Updated the route parameter from ':1' to ':clubId' in the router configuration for better clarity and consistency.
2026-02-04 13:15:09 +01:00
Torsten Schulz (local)
12184c2f72 feat(store): normalize permissions data structure in loadPermissions action
- Updated the loadPermissions action to normalize the permissions data structure, ensuring default values for role, isOwner, and permissions.
- Enhanced resource permission checks by using a fallback for undefined permissions, improving robustness in access control logic.
2026-02-04 12:14:29 +01:00
Torsten Schulz (local)
1f94c273ae feat(activate): add dialog state management and auto-activation on mount
- Introduced data properties for managing info and confirmation dialog states, enhancing user interaction.
- Implemented auto-activation of the component upon mounting, streamlining the activation process for users.
2026-02-04 12:09:22 +01:00
Torsten Schulz (local)
e333a54025 feat(api): refactor API client usage across frontend components
- Replaced direct axios calls with a centralized apiClient in Register, Login, and Activate components for improved maintainability and consistency.
- Updated backend base URL logic to support different environments, enhancing flexibility in API interactions.
- Added console logging in the authController for better tracking of user registration flow.
2026-02-04 12:02:14 +01:00
Torsten Schulz (local)
a86c05eb66 feat(auth): add logging for user registration and activation email process
- Introduced console logging to track the registration flow, including the generated activation code and confirmation of user creation and email sending.
- This enhancement aids in debugging and monitoring the registration process without altering the existing functionality.
2026-02-04 11:53:28 +01:00
Torsten Schulz (local)
c2dbf0a12d refactor(auth): simplify user registration process by removing rollback logic
- Eliminated the rollback logic for user creation in the registration process, streamlining the function and reducing complexity.
- Maintained error handling for existing email addresses while ensuring activation emails are sent without exposing user details.
2026-02-04 11:50:16 +01:00
Torsten Schulz (local)
a8470145a0 refactor(tests): remove obsolete test files and clean up package.json
- Deleted outdated test files for activity, API log, authentication, authorization, and club functionalities to streamline the test suite.
- Retained the cleanup script in package.json while removing unnecessary test dependencies, optimizing the development environment.
2026-02-04 11:44:23 +01:00
Torsten Schulz (local)
2871b79b04 chore(dependencies): update package-lock.json and package.json for dependency versions
- Updated lodash from version 4.17.21 to 4.17.23 in package-lock.json for improved performance and security.
- Downgraded sqlite3 from version 5.1.7 to 5.0.2 in both package.json and package-lock.json to maintain compatibility.
- Upgraded @babel/runtime from version 7.28.4 to 7.28.6 in package-lock.json to incorporate the latest features and fixes.
- Added new esbuild dependencies for various architectures (aix, android, darwin, freebsd, linux) to enhance cross-platform support.
- Updated TypeScript definitions for ms and node modules to align with the latest API changes and improve type safety.
2026-02-04 11:26:25 +01:00
Torsten Schulz (local)
503ff90dfa refactor(auth): update user registration and activation responses for security
- Modified the registerUser and activate functions to return a success status instead of user data, enhancing security by not exposing sensitive information.
- Improved error handling in the registration process, including user cleanup on failure and clearer error messages for email-related issues.
- Ensured that activation emails are sent without returning user details, maintaining user privacy.
2026-02-04 11:21:55 +01:00
Torsten Schulz (local)
673a3afbb5 feat(tournament): enhance external participant management with email and address fields
- Added email and address fields to the external participant model, allowing for more comprehensive participant information.
- Updated the tournament service and controller to handle the new fields when adding external participants.
- Modified frontend components to include input fields for email and address, improving user experience and data collection.
- Updated localization strings to support the new fields, ensuring clarity in the user interface.
2026-02-04 11:12:37 +01:00
Torsten Schulz (local)
10e6d74d93 feat(tournament): add cleanup logic for orphaned matches
- Implemented a new method to clean up orphaned matches where at least one player no longer exists, enhancing data integrity in tournament management.
- Added a corresponding route and frontend functionality to trigger the cleanup process, allowing users to easily remove invalid match records.
- Updated localization strings to support the new feature, ensuring clarity in the user interface.
2026-01-31 00:16:23 +01:00
Torsten Schulz (local)
3fc1760b2c feat(tournament): add logic for creating missing group matches for new participants
- Implemented a new method to generate missing group matches when a new participant is added to a group that already has matches, specifically for singles classes.
- Enhanced the participant assignment process to ensure all necessary matches are created, improving tournament flow and participant engagement.
2026-01-31 00:13:01 +01:00
Torsten Schulz (local)
d12b9daf87 fix(tournament): remove matches for participants before deletion
- Added logic to delete all matches associated with a participant before their removal from the tournament, ensuring data integrity and preventing orphaned match records.
2026-01-31 00:08:25 +01:00
Torsten Schulz (local)
75cc2df06b fix(tournament): filter final placements by class group count in TournamentPlacementsTab
- Introduced a new computed property to filter final placements, ensuring only classes with more than one group are displayed.
- Updated the template to reference the filtered placements, improving the clarity of the displayed tournament results.
- Adjusted logic for handling class IDs to ensure proper grouping and display of placements.
2026-01-31 00:05:20 +01:00
Torsten Schulz (local)
7454a274a1 fix(tournament): refine birth date handling and eligibility checks in TournamentTab
- Updated birth date parsing to support both camelCase and snake_case formats, ensuring accurate eligibility checks for age-restricted classes.
- Enhanced minimum and maximum birth year comparisons to handle edge cases, improving the robustness of member eligibility filtering.
2026-01-30 23:56:03 +01:00
Torsten Schulz (local)
380709c29c fix(tournament): improve birth year eligibility checks in TournamentTab
- Updated the logic for determining minimum and maximum birth years to support both camelCase and snake_case formats from the API.
- Enhanced member eligibility checks to ensure accurate filtering based on birth year restrictions, improving clarity and functionality in the tournament participant selection process.
2026-01-30 23:48:51 +01:00
Torsten Schulz (local)
c6f8b4dd74 fix(tournament): update age eligibility descriptions for mini championships
- Revised comments and localization strings to accurately reflect the age eligibility criteria for mini championships, ensuring clarity in the age classifications for participants.
- Adjusted birth year calculations in the tournamentService to align with the updated descriptions.
2026-01-30 23:36:57 +01:00
Torsten Schulz (local)
02c947b0e3 fix(tournament): update birth year eligibility checks in TournamentTab
- Adjusted the logic for member eligibility based on birth year to ensure that members without a valid birth date are not allowed in age-restricted classes.
- Enhanced comments for clarity regarding age restrictions and eligibility criteria, improving the understanding of the filtering logic.
2026-01-30 23:32:52 +01:00
Torsten Schulz (local)
c3366313d6 feat(tournament): add debug logging for member eligibility checks in TournamentTab
- Introduced debug logging to track member eligibility filtering in the TournamentTab component, providing insights into birth year comparisons and class item constraints for a subset of members.
- This enhancement aids in troubleshooting and ensures clarity in the filtering logic for tournament participants.
2026-01-30 23:28:28 +01:00
Torsten Schulz (local)
b1e184c4c2 refactor(tournament): streamline participant filtering logic in TournamentParticipantsTab and TournamentTab
- Simplified the filtering logic for club members in TournamentParticipantsTab by removing redundant checks, as the parent component already handles class-based filtering.
- Updated the clubMembersForParticipantAdd method in TournamentTab to clarify conditions for returning all members, ensuring consistency in participant eligibility checks.
2026-01-30 23:23:31 +01:00
Torsten Schulz (local)
3e05bdab51 fix(tournament): correct comment typo in addTournament method
- Fixed a typo in the comment for the addTournament method, ensuring clarity in the code documentation.
2026-01-30 23:22:35 +01:00
Torsten Schulz (local)
fde6ba55d2 feat(tournament): enable external participation in tournaments
- Updated tournamentService to allow external participants by setting allowsExternal to true.
- Adjusted frontend TournamentTab component to reflect the change, enabling external participation for mini championships.
2026-01-30 23:21:12 +01:00
Torsten Schulz (local)
19410a0ee2 feat(tournament): enhance age class display in TournamentClassList
- Updated the age class badge logic to display both minimum and maximum birth years when applicable, improving clarity for tournament participants.
- Added conditional rendering for cases where only minimum or maximum birth years are defined, ensuring comprehensive age class information is presented.
2026-01-30 23:18:36 +01:00
Torsten Schulz (local)
28db204aba refactor(tournament): simplify tournament creation by removing duplicate checks
- Removed duplicate tournament existence checks from the addTournament method, streamlining the tournament creation process.
- Enhanced error handling to provide clearer messages related to database migration requirements for mini championships.
2026-01-30 23:16:00 +01:00
Torsten Schulz (local)
47a815dd71 feat(tournament): enhance tournament creation with group settings and winning sets logic
- Updated the tournamentService to set default values for tournament type, number of groups, and advancing participants for group tournaments.
- Adjusted the frontend logic to dynamically determine the number of winning sets based on the mini championship year, ensuring proper configuration for tournaments.
2026-01-30 23:12:44 +01:00
Torsten Schulz (local)
14dc654145 feat(tournament): refine mini championship creation and UI integration
- Updated the addMiniChampionship method to default winning sets to 1 and added transaction handling for improved reliability.
- Enhanced frontend components to include a new property for mini championship identification, ensuring proper configuration in the UI.
- Adjusted the display logic in TournamentConfigTab to conditionally render stage configuration based on the mini championship type.
2026-01-30 23:09:21 +01:00
Torsten Schulz (local)
025ad68cf3 feat(tournament): adjust mini championship year label and winning sets logic
- Updated the German localization for the mini championship year label to simplify the text.
- Introduced a new reactive property to dynamically set the number of winning sets based on the mini championship type, defaulting to 1 for mini championships.
2026-01-30 23:03:50 +01:00
Torsten Schulz (local)
89f30f76f5 feat(tournament): update mini championship creation to include location
- Modified addMiniChampionship method to accept location (ort) instead of tournament name.
- Updated frontend components to reflect the change, including new input for location and localization updates for German language support.
- Enhanced validation to ensure location is provided during mini championship creation.
2026-01-30 23:01:41 +01:00
Torsten Schulz (local)
85c26bc80d feat(tournament): add mini championship functionality and enhance tournament class handling
- Introduced addMiniChampionship method in tournamentService to create tournaments with predefined classes for mini championships.
- Updated getTournaments method to filter tournaments based on type, including support for mini championships.
- Enhanced TournamentClass model to include maxBirthYear for age class restrictions.
- Modified tournamentController and tournamentRoutes to support new mini championship endpoint.
- Updated frontend components to manage mini championship creation and display, including localization for new terms.
2026-01-30 22:58:41 +01:00
Torsten Schulz (local)
6cdcbfe0db feat(tournament): enhance tournament group handling for pools and classes
- Updated TournamentService to manage participant class IDs more effectively in pooled groups, ensuring accurate statistics and match handling.
- Refactored TournamentGroupsTab and TournamentPlacementsTab components to utilize a new method for retrieving group rankings based on class ID, improving data organization.
- Adjusted getLivePosition method to accommodate group objects, enhancing flexibility in live match updates.
- Improved group ranking logic to support multiple entries per group and class, ensuring accurate display of tournament standings.
2026-01-30 22:51:04 +01:00
Torsten Schulz (local)
7e1b09fa97 feat(tournament): add participant gave-up functionality and UI updates
- Implemented setParticipantGaveUp and setExternalParticipantGaveUp methods in tournamentController to handle participant resignation.
- Updated ExternalTournamentParticipant and TournamentMember models to include a gaveUp field for tracking resignation status.
- Enhanced tournamentRoutes to include new endpoints for updating gave-up status.
- Modified TournamentGroupsTab and TournamentParticipantsTab components to display and manage gave-up status visually.
- Added localization strings for "gave up" and related hints in German.
- Updated TournamentResultsTab to reflect gave-up status in match results.
2026-01-30 22:45:54 +01:00
Torsten Schulz (local)
18a191f686 fix(tournament): improve match identification logic for player IDs
- Updated the getMatchLiveResult and handleMatchClick methods to handle potential null values for player IDs, ensuring robust match identification.
- Refactored ID retrieval logic to use optional chaining and fallback values, enhancing code readability and preventing errors when player data is incomplete.
2026-01-30 22:32:47 +01:00
Torsten Schulz (local)
e21b50fc38 fix(accidentForm): update sorting logic to prioritize first name over last name
- Modified the availableMembers computed property to sort filtered members alphabetically by first name first, followed by last name, ensuring a more intuitive display order.
- Updated logging to reflect the new sorting format in the output.
2026-01-14 14:27:27 +01:00
Torsten Schulz (local)
23caeddf9e fix(accidentForm): improve member sorting and add key for reactivity
- Added a unique key to the member selection dropdown to enhance reactivity.
- Updated the sorting logic in availableMembers to trim whitespace and use locale-sensitive comparison, ensuring accurate alphabetical ordering of members.
2026-01-14 14:10:50 +01:00
Torsten Schulz (local)
663125670e fix(accidentForm): prevent reactivity issues by creating a new sorted array
- Modified the sorting logic in AccidentFormDialog to create a new array from the filtered members, addressing potential reactivity problems during sorting.
2026-01-14 14:07:27 +01:00
Torsten Schulz (local)
515e04d1e3 feat(accident): update accident field type and enhance textarea styling
- Changed the accident field type from STRING to TEXT in the Accident model to accommodate larger input.
- Increased the minimum height of the textarea in AccidentFormDialog and added font styling for improved readability.
2026-01-14 14:00:42 +01:00
Torsten Schulz (local)
bf082ea995 chore(deps): update dependencies and remove unused files
- Updated package-lock.json and .package-lock.json to reflect new dependency versions, including body-parser and express.
- Removed deprecated SECURITY.md files from body-parser and raw-body.
- Cleaned up unused files from call-bind and define-data-property modules.
- Enhanced the qs package with fixes and improvements in parsing and stringifying functionality.
- Updated nodemailer to improve handling of SMTP connections and added support for new TLS extensions.
2026-01-14 13:56:19 +01:00
Torsten Schulz (local)
67fc5d45e1 feat(accidentForm): enhance member selection and update handling
- Updated the member selection dropdown to use v-model for better data binding and added a change handler for memberId updates.
- Improved the availableMembers computed property with additional logging for debugging and ensured it returns sorted members.
- Enhanced watchers for localAccident to emit updates only on relevant changes, optimizing performance and preventing unnecessary emissions.
- Added logging to various methods for better traceability during form interactions.
2026-01-14 13:46:13 +01:00
Torsten Schulz (local)
30e3f4f321 feat(accidentForm): sort filtered participants by last and first name
- Enhanced the availableMembers computed property to sort filtered members alphabetically by last name and then by first name, improving the organization of participant lists.
2026-01-14 13:42:54 +01:00
Torsten Schulz (local)
c4e237cfca fix(accidentForm): optimize participant filtering and update localAccident handling
- Improved availableMembers computed property to handle participant IDs more robustly, ensuring correct filtering of members.
- Enhanced watch on accident to prevent unnecessary updates to localAccident, avoiding potential infinite loops.
- Refined localAccident watchers to emit updates only on relevant changes to memberId and accident text, improving performance and reducing redundant emissions.
2026-01-14 13:41:48 +01:00
Torsten Schulz (local)
fea84e210a feat(tournament): implement class merging and pool management features
- Added mergeClassesIntoPool and resetPool functions in tournamentService to handle merging classes into a common pool and resetting pool assignments.
- Introduced new API routes for merging and resetting pools in tournamentRoutes.
- Enhanced TournamentGroupsTab component with UI for merging classes, including selection for source and target classes, strategy options, and out-of-competition settings.
- Updated localization files to include new strings related to class merging functionality.
- Modified TournamentTab to handle merge pool events and manage API interactions for merging classes.
2026-01-07 12:10:33 +01:00
Torsten Schulz (local)
e94a12cd20 feat(tournament): enhance match interaction and navigation
- Refactored match highlighting logic in TournamentGroupsTab to use a dedicated handleMatchClick method for better readability and functionality.
- Added a new event emission for navigating to match results in TournamentResultsTab, allowing users to seamlessly transition to the results view.
- Implemented styling for match states in TournamentResultsTab to visually distinguish between finished, live, and active matches, improving user experience.
2025-12-21 11:40:17 +01:00
438029a3a4 Merge pull request 'chore(deps): update dependency eslint to v9.39.2' (#3) from renovate/eslint-monorepo into main
Reviewed-on: #3
2025-12-19 16:15:43 +01:00
c58491c97a Merge pull request 'chore(deps): update dependency @vitejs/plugin-vue to v6.0.3' (#2) from renovate/vitejs-plugin-vue-6.x-lockfile into main
Reviewed-on: #2
2025-12-19 16:15:34 +01:00
1d9b9dbc45 chore(deps): update dependency eslint to v9.39.2 2025-12-19 16:13:52 +01:00
dc791dc33d chore(deps): update dependency @vitejs/plugin-vue to v6.0.3 2025-12-19 16:13:48 +01:00
57fbbff353 Merge pull request 'chore: Configure Renovate' (#1) from renovate/configure into main
Reviewed-on: #1
2025-12-19 16:08:23 +01:00
b00a35af30 Add renovate.json 2025-12-19 15:59:45 +01:00
Torsten Schulz (local)
dd0f29124c feat(tournament): add player details dialog and enhance player name interactions
- Implemented clickable player names in the TournamentPlacementsTab for improved user experience.
- Added a PlayerDetailsDialog component to display detailed player information when names are clicked.
- Updated localization files to include new strings for player details.
- Enhanced data handling for internal and external participants in player dialog logic.
2025-12-17 14:31:36 +01:00
Torsten Schulz (local)
dc084806ab feat(tournament): add group match creation and enhance match handling
- Implemented createGroupMatches function to generate matches for existing groups without altering group assignments.
- Updated resetMatches function to support optional class filtering when resetting group matches.
- Enhanced frontend components to filter and display group matches based on selected class, improving user experience.
- Adjusted tournament results display to reflect accurate match statistics, including wins and losses.
2025-12-17 13:38:40 +01:00
Torsten Schulz (local)
4b4c48a50f feat(tournament): refine external participant handling in match assignments
- Updated logic to replace null player1 and player2 with external participants, ensuring that the assigned objects do not include the member field.
- Created clean player objects for external participants, maintaining essential attributes while omitting sensitive information.
2025-12-17 08:52:38 +01:00
Torsten Schulz (local)
65acc9e0d5 feat(tournament): enhance validation of tournament members and external participants
- Implemented logic to load and validate TournamentMember and ExternalTournamentParticipant IDs for matches.
- Updated checks to set player1 and player2 to null if they belong to external participants or do not match valid tournament members.
2025-12-17 08:50:05 +01:00
Torsten Schulz (local)
13cd55c051 feat(tournament): validate tournament members and load external participants
- Added logic to check if loaded TournamentMembers belong to the current tournament, setting them to null if not.
- Updated filtering for external participant IDs to ensure only valid IDs are processed for matches with null players.
2025-12-17 08:45:52 +01:00
Torsten Schulz (local)
9bf37399d5 feat(tournament): improve result handling and display for matches and participants 2025-12-15 21:08:38 +01:00
Torsten Schulz (local)
047b1801b3 feat(tournament): enhance tournament configuration and results handling
- Updated TournamentConfigTab.vue to conditionally disable target type selection based on final stage type.
- Improved logic for determining target type and group count based on stage configuration.
- Refactored TournamentPlacementsTab.vue to streamline class and group placements display, including better handling of class visibility and player names.
- Enhanced TournamentResultsTab.vue to handle 'BYE' results and limit displayed entries to top three.
- Modified TournamentTab.vue to robustly determine match winners and losers, including handling 'BYE' scenarios and ensuring accurate knockout progression.
- Added logic to reset knockout matches with optional class filtering.
2025-12-15 15:36:18 +01:00
Torsten Schulz (local)
945ec0d48c feat(tournament): implement multi-stage tournament support with intermediate and final stages
- Added backend controller for tournament stages with endpoints to get, upsert, and advance stages.
- Created database migration for new tables: tournament_stage and tournament_stage_advancement.
- Updated models for TournamentStage and TournamentStageAdvancement.
- Enhanced frontend components to manage tournament stages, including configuration for intermediate and final rounds.
- Implemented logic for saving and advancing tournament stages, including handling of pool rules and third place matches.
- Added error handling and loading states in the frontend for better user experience.
2025-12-14 06:46:00 +01:00
Torsten Schulz (local)
e83bc250a8 Erlaube das Hinzufügen von Teilnehmern ohne Klasse und normalisiere die Anzahl der Gruppen auf mindestens 1 in der Turnierverwaltung 2025-12-13 12:25:17 +01:00
Torsten Schulz (local)
0c28b12978 Enhance HTTPS server setup and logging for Socket.IO
- Added detailed logging for SSL certificate loading and server status checks.
- Implemented error handling for port conflicts, providing guidance on resolving issues.
- Introduced a verification step to confirm server activation after startup.
- Improved fallback behavior for Socket.IO when SSL certificates are not found.
2025-12-01 08:43:31 +01:00
4821 changed files with 355962 additions and 502994 deletions

View File

@@ -0,0 +1,53 @@
name: Deploy tt-tagebuch
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
env:
SSH_HOST: ${{ vars.PROD_HOST }}
SSH_PORT: ${{ vars.PROD_PORT }}
SSH_USER: ${{ vars.PROD_USER }}
steps:
- name: Show resolved non-secret config
run: |
echo "SSH_HOST=$SSH_HOST"
echo "SSH_PORT=$SSH_PORT"
echo "SSH_USER=$SSH_USER"
- name: Prepare SSH
run: |
set -e
mkdir -p ~/.ssh
printf '%s' "${{ secrets.PROD_SSH_KEY_B64 }}" | base64 -d > ~/.ssh/id_deploy
chmod 600 ~/.ssh/id_deploy
ssh-keygen -l -f ~/.ssh/id_deploy
ssh-keyscan -p "$SSH_PORT" "$SSH_HOST" >> ~/.ssh/known_hosts
- name: Test SSH connection
run: |
set -e
ssh -i ~/.ssh/id_deploy \
-o StrictHostKeyChecking=no \
-o BatchMode=yes \
-o ConnectTimeout=10 \
-p "$SSH_PORT" \
"$SSH_USER@$SSH_HOST" \
"echo SSH OK"
- name: Run deployment script
run: |
set -e
ssh -i ~/.ssh/id_deploy \
-o StrictHostKeyChecking=no \
-o BatchMode=yes \
-o ConnectTimeout=10 \
-p "$SSH_PORT" \
"$SSH_USER@$SSH_HOST" \
"/usr/local/bin/actualize-tagebuch.sh"

12
.gitignore vendored
View File

@@ -7,4 +7,14 @@ backend/.env
backend/images/*
backend/backend-debug.log
backend/*.log
backend/*.log
backend/.env.local
mobile-app/.gradle/
mobile-app/.idea/
mobile-app/.kotlin/
mobile-app/build/
mobile-app/composeApp/build/
mobile-app/shared/build/
mobile-app/local.properties
mobile-app/signing.properties

0
Languages Normal file
View File

0
System Normal file
View File

View File

@@ -0,0 +1,122 @@
# HTTV / click-TT HTTP-Seiten Integration & Logging
Dieses Modul ermöglicht das Testen und Logging von HTTP-Aufrufen an die click-TT-Seiten verschiedener Tischtennis-Verbände (HTTV, RTTV, WTTV etc.).
## Zweck
- **Logging**: Jeder Aufruf wird in `http_page_fetch_log` protokolliert (URL, HTTP-Status, Response-Snippet, Fehler).
- **Strukturanalyse**: Die Logs helfen zu verstehen, wie die Seiten je nach Verband und Saison aufgebaut sind.
- **URL-Varianten**: Links können je nach Verein, Saison und Verband unterschiedlich sein.
## Verband → Domain
| Verband | Domain |
|---------|--------|
| HeTTV / HTTV | httv.click-tt.de |
| RTTV | rttv.click-tt.de |
| WTTV | wttv.click-tt.de |
| TTVNw | ttvnw.click-tt.de |
| BTTV | battv.click-tt.de |
## URL-Struktur (httv.click-tt.de)
### leaguePage Ligenübersicht
```
https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaTTDE.woa/wa/leaguePage?championship=HTTV+25%2F26
```
- `championship`: Saison/Championship, z.B.:
- `HTTV 25/26` Haupt-HTTV-Saison
- `K43 25/26` Bezirk Frankfurt
- `K16 25/26` Bezirk Werra-Meißner
- `RL-OL West 25/26` Regional-/Oberligen West
### regionMeetingFilter Regionsspielplan
```
https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaTTDE.woa/wa/regionMeetingFilter?championship=HTTV+25%2F26
```
### clubInfoDisplay Vereinsinfo
```
https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaTTDE.woa/wa/clubInfoDisplay?club=1060
```
- `club`: Vereins-ID in der click-TT-Datenbank
## UI-Seite
Unter **/clicktt** (nur für Admins) gibt es eine Vue-Seite, mit der du:
- Seitentyp wählen (Ligenübersicht, Vereinsinfo, Regionsspielplan oder direkte URL)
- Verband, Championship/Saison und ggf. Vereins-ID eingeben
- Die Seite im iframe laden und direkt bedienen (klicken, navigieren)
Alle Aufrufe werden in `http_page_fetch_log` protokolliert.
## API-Endpunkte
Die meisten Endpunkte erfordern Authentifizierung (Token). Der **Proxy** (`/api/clicktt/proxy`) ist ohne Auth nutzbar (für iframe-Einbettung).
### Ligenübersicht abrufen
```
GET /api/clicktt/league-page?association=HeTTV&championship=HTTV+25%2F26
```
### Vereinsinfo abrufen
```
GET /api/clicktt/club-info?association=HeTTV&clubId=1060
```
### Regionsspielplan abrufen
```
GET /api/clicktt/region-meetings?association=HeTTV&championship=HTTV+25%2F26
```
### Beliebige URL abrufen (nur click-tt.de / httv.de)
```
GET /api/clicktt/fetch?url=https%3A%2F%2Fhttv.click-tt.de%2Fcgi-bin%2F...
```
### Logs abrufen
```
GET /api/clicktt/logs?limit=50&fetchType=leaguePage&association=HeTTV
```
### URL-Info (Beispiele, Verband→Domain)
```
GET /api/clicktt/url-info
```
## Konfiguration (.env)
Für das Link-Rewriting (Folge-Klicks im iframe) wird die Backend-URL benötigt:
- **BACKEND_BASE_URL** URL, unter der die API erreichbar ist (z.B. `https://tt-tagebuch.de`)
- **BASE_URL** Fallback, falls BACKEND_BASE_URL nicht gesetzt ist
In Produktion mit Reverse-Proxy (Apache) reicht meist `BASE_URL=https://tt-tagebuch.de`. In der Entwicklung kann `BACKEND_BASE_URL=http://localhost:3005` nötig sein, wenn die API auf einem anderen Port als das Frontend läuft.
## Datenbank-Migration
```bash
mysql -u USER -p DATABASE < backend/migrations/create_http_page_fetch_log.sql
```
## Hinweis: mytischtennis.de vs. click-TT
Die **leaguePage** auf httv.click-tt.de zeigt eine Übersicht mit Links. Die eigentlichen **Tabellen und Spielpläne** verweisen auf **mytischtennis.de**:
```
https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/Hessenliga_Gr._Süd-West/gruppe/496273/tabelle/gesamt
```
Diese mytischtennis.de-URLs werden bereits über den bestehenden MyTischtennis-URL-Parser und Auto-Fetch unterstützt. Die httv.click-tt.de-Seiten dienen vor allem der Navigation und der Ermittlung von Gruppen-IDs für verschiedene Bezirke/Saisonen.

View File

@@ -9,12 +9,12 @@ Dieses System ermöglicht den automatischen Abruf von Spielergebnissen und Stati
### 6:00 Uhr - Rating Updates
- **Service:** `autoUpdateRatingsService.js`
- **Funktion:** Aktualisiert TTR/QTTR-Werte für Spieler
- **TODO:** Implementierung der eigentlichen Rating-Update-Logik
- **Status:** ✅ Aktiv. Nutzt `memberService.updateRatingsFromMyTischtennisByUserId(...)` pro Verein ueber einen freigeschalteten Benutzer mit gespeichertem myTischtennis-Login.
### 6:30 Uhr - Spielergebnisse
- **Service:** `autoFetchMatchResultsService.js`
- **Funktion:** Ruft Spielerbilanzen für konfigurierte Teams ab
- **Status:** ✅ Grundlegende Implementierung fertig
- **Funktion:** Ruft Team-Spielplaene, Liga-Spielplaene, Spielerbilanzen und Ligatabellen fuer konfigurierte Teams ab
- **Status:** ✅ Aktiv. Importiert neue Spiele, aktualisiert Ergebnis- und Termin-Aenderungen und synchronisiert Ligatabellen.
## Benötigte Konfiguration
@@ -107,6 +107,7 @@ Von der myTischtennis API werden folgende Daten abgerufen:
- Player IDs, Namen der beiden Spieler
- Gewonnene/Verlorene Punkte
- Anzahl Spiele
- Zuordnung der beteiligten Mitglieder ueber Player-ID oder Namensabgleich
### Team-Informationen
- Teamname, Liga, Saison
@@ -134,7 +135,8 @@ Von der myTischtennis API werden folgende Daten abgerufen:
- Parst JSON-Response
- Matched Spieler anhand von ID oder Name
- Speichert myTischtennis Player-ID bei Mitgliedern
- Loggt Statistiken
- verarbeitet auch Doppelpartner-Zuordnungen
- speichert/aktualisiert Spiele und Ligatabellen
### Player-Matching-Algorithmus
@@ -148,25 +150,21 @@ Von der myTischtennis API werden folgende Daten abgerufen:
## TODO / Offene Punkte
### Noch zu implementieren:
### Noch offen:
1. **TTR/QTTR Updates** (6:00 Uhr Job):
- Endpoint für TTR/QTTR-Daten identifizieren
- Daten abrufen und in Member-Tabelle speichern
2. **Spielergebnis-Details**:
1. **Spielergebnis-Details**:
- Einzelne Matches mit Satzständen speichern
- Tabelle für Match-Historie erstellen
3. **History-Tabelle für Spielergebnis-Abrufe** (optional):
2. **History-Tabelle für Spielergebnis-Abrufe** (optional):
- Ähnlich zu `my_tischtennis_update_history`
- Speichert Erfolg/Fehler der Abrufe
4. **Benachrichtigungen** (optional):
3. **Benachrichtigungen** (optional):
- Email/Push bei neuen Ergebnissen
- Highlights für besondere Siege
5. **Performance-Optimierung**:
4. **Performance-Optimierung**:
- Caching für Player-Matches
- Incremental Updates (nur neue Daten)
@@ -183,6 +181,14 @@ await schedulerService.triggerRatingUpdates();
await schedulerService.triggerMatchResultsFetch();
```
### Manuelle HTTP-Trigger
```text
POST /api/scheduler/rating_updates
POST /api/scheduler/match_results
GET /api/scheduler/status
```
## API-Dokumentation
### MyTischtennis Spielerbilanzen-Endpoint
@@ -209,4 +215,3 @@ https://www.mytischtennis.de/click-tt/{association}/{season}/ligen/{groupname}/g
- ✅ Passwörter verschlüsselt gespeichert
- ✅ Fehlerbehandlung und Logging
- ✅ Graceful Degradation (einzelne Team-Fehler stoppen nicht den gesamten Prozess)

View File

@@ -1,4 +1,5 @@
import axios from 'axios';
import { chromium } from 'playwright';
const BASE_URL = 'https://www.mytischtennis.de';
@@ -24,36 +25,136 @@ class MyTischtennisClient {
async getLoginPage() {
try {
const response = await this.client.get('/login?next=%2F');
const html = response.data;
const html = typeof response.data === 'string' ? response.data : String(response.data || '');
const extractFirst = (patterns) => {
for (const pattern of patterns) {
const match = html.match(pattern);
if (match && (match[1] || match[2] || match[3])) {
return match[1] || match[2] || match[3];
}
}
return null;
};
// Parse form action and input fields for frontend login-form endpoint
const formMatch = html.match(/<form[^>]*action=(?:"([^"]+)"|'([^']+)')[^>]*>([\s\S]*?)<\/form>/i);
const loginAction = formMatch ? (formMatch[1] || formMatch[2] || '/login') : '/login';
const formHtml = formMatch ? formMatch[3] : html;
const fields = [];
const inputRegex = /<input\b([\s\S]*?)>/gi;
let inputMatch = null;
while ((inputMatch = inputRegex.exec(formHtml)) !== null) {
const rawAttributes = inputMatch[1] || '';
const attributes = {};
// Parses key="value", key='value', key=value and boolean attributes.
const attributeRegex = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:=(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
let attributeMatch = null;
while ((attributeMatch = attributeRegex.exec(rawAttributes)) !== null) {
const key = attributeMatch[1];
const value = attributeMatch[2] ?? attributeMatch[3] ?? attributeMatch[4] ?? true;
attributes[key] = value;
}
fields.push({
name: typeof attributes.name === 'string' ? attributes.name : null,
id: typeof attributes.id === 'string' ? attributes.id : null,
type: typeof attributes.type === 'string' ? attributes.type : 'text',
placeholder: typeof attributes.placeholder === 'string' ? attributes.placeholder : null,
autocomplete: typeof attributes.autocomplete === 'string' ? attributes.autocomplete : null,
minlength: typeof attributes.minlength === 'string' ? attributes.minlength : null,
required: attributes.required === true || attributes.required === 'required',
value: typeof attributes.value === 'string' ? attributes.value : null
});
}
// Fallback: if page is JS-rendered and no input tags are server-rendered, provide usable defaults.
const hasEmailField = fields.some((f) => f?.name === 'email' || f?.type === 'email');
const hasPasswordField = fields.some((f) => f?.name === 'password' || f?.type === 'password');
if (!hasEmailField) {
fields.push({
name: 'email',
id: null,
type: 'email',
placeholder: null,
autocomplete: 'email',
minlength: null,
required: true,
value: null
});
}
if (!hasPasswordField) {
fields.push({
name: 'password',
id: null,
type: 'password',
placeholder: null,
autocomplete: 'current-password',
minlength: null,
required: true,
value: null
});
}
// Extract XSRF token from hidden input
const xsrfMatch = html.match(/<input[^>]*name="xsrf"[^>]*value="([^"]+)"/);
const xsrfToken = xsrfMatch ? xsrfMatch[1] : null;
const xsrfToken = extractFirst([
/<input[^>]*name=(?:"xsrf"|'xsrf')[^>]*value=(?:"([^"]+)"|'([^']+)')/i,
/(?:^|[,{])\s*"xsrf"\s*:\s*"([^"]+)"/i
]);
// Extract CAPTCHA token from hidden input (if present)
const captchaMatch = html.match(/<input[^>]*name="captcha"[^>]*value="([^"]+)"/);
const captchaToken = captchaMatch ? captchaMatch[1] : null;
const captchaToken = extractFirst([
/<input[^>]*name=(?:"captcha"|'captcha')[^>]*value=(?:"([^"]+)"|'([^']+)')/i,
/(?:^|[,{])\s*"captcha"\s*:\s*"([^"]+)"/i
]);
// Check if captcha_clicked is true or false
const captchaClickedMatch = html.match(/<input[^>]*name="captcha_clicked"[^>]*value="([^"]+)"/);
const captchaClicked = captchaClickedMatch ? captchaClickedMatch[1] === 'true' : false;
const captchaClickedRaw = extractFirst([
/<input[^>]*name=(?:"captcha_clicked"|'captcha_clicked')[^>]*value=(?:"([^"]+)"|'([^']+)')/i,
/(?:^|[,{])\s*"captcha_clicked"\s*:\s*"([^"]+)"/i
]);
const captchaClicked = String(captchaClickedRaw || '').toLowerCase() === 'true';
// Check if CAPTCHA is required (look for private-captcha element or captcha input)
const requiresCaptcha = html.includes('private-captcha') || html.includes('name="captcha"');
const requiresCaptcha = html.includes('private-captcha')
|| html.includes('name="captcha"')
|| html.includes("name='captcha'")
|| /captcha/i.test(html);
// Extract CAPTCHA metadata used by frontend
const captchaSiteKey = extractFirst([
/data-sitekey=(?:"([^"]+)"|'([^']+)'|([^\s>]+))/i,
/(?:^|[,{])\s*"sitekey"\s*:\s*"([^"]+)"/i,
/(?:^|[,{])\s*"captchaSiteKey"\s*:\s*"([^"]+)"/i
]);
const captchaPuzzleEndpoint = extractFirst([
/data-puzzle-endpoint=(?:"([^"]+)"|'([^']+)'|([^\s>]+))/i,
/(?:^|[,{])\s*"puzzle_endpoint"\s*:\s*"([^"]+)"/i,
/(?:^|[,{])\s*"captchaPuzzleEndpoint"\s*:\s*"([^"]+)"/i
]);
console.log('[myTischtennisClient.getLoginPage]', {
hasXsrfToken: !!xsrfToken,
hasCaptchaToken: !!captchaToken,
captchaClicked,
requiresCaptcha
requiresCaptcha,
fieldsCount: fields.length,
hasCaptchaSiteKey: !!captchaSiteKey,
hasCaptchaPuzzleEndpoint: !!captchaPuzzleEndpoint
});
return {
success: true,
loginAction,
fields,
xsrfToken,
captchaToken,
captchaClicked,
requiresCaptcha
requiresCaptcha,
captchaSiteKey,
captchaPuzzleEndpoint
};
} catch (error) {
console.error('Error fetching login page:', error.message);
@@ -247,6 +348,443 @@ class MyTischtennisClient {
}
}
/**
* Browser-based fallback login for CAPTCHA flows.
* @param {string} email
* @param {string} password
* @param {Object} [options]
* @param {Object} [options.savedStorageState] - Playwright storage state from a previous session.
* If provided and the stored auth cookie is still valid, returns immediately without a new login.
* @returns {Promise<Object>} Login response with token, session data, and `storageState` for persistence.
*/
async loginWithBrowserAutomation(email, password, options = {}) {
const { savedStorageState } = options;
let browser = null;
let context = null;
// --- Fast path: restore a saved Playwright session ---
if (savedStorageState) {
try {
browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
context = await browser.newContext({ storageState: savedStorageState });
const cookies = await context.cookies('https://www.mytischtennis.de');
const authCookie = cookies.find((c) => c.name === 'sb-10-auth-token' || /^sb-\d+-auth-token$/.test(c.name));
if (authCookie?.value) {
const tokenMatch = String(authCookie.value).match(/^base64-(.+)$/);
if (tokenMatch) {
const tokenData = JSON.parse(Buffer.from(tokenMatch[1], 'base64').toString('utf-8'));
const nowSec = Math.floor(Date.now() / 1000);
// Accept if not expired (with 5-minute safety buffer)
if (tokenData.expires_at && tokenData.expires_at > nowSec + 300) {
console.log('[myTischtennisClient.playwright] Restored session from saved state (no CAPTCHA needed)');
const storageState = await context.storageState();
await context.close();
await browser.close();
browser = null; context = null;
return {
success: true,
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token,
expiresAt: tokenData.expires_at,
expiresIn: tokenData.expires_in,
user: tokenData.user,
cookie: `sb-10-auth-token=${authCookie.value}`,
storageState,
restoredFromCache: true
};
}
}
}
// Cookie absent or expired → close and fall through to full login
console.log('[myTischtennisClient.playwright] Saved session expired or invalid, starting full login');
await context.close();
await browser.close();
browser = null; context = null;
} catch (restoreErr) {
console.warn('[myTischtennisClient.playwright] Session restore failed, starting full login:', restoreErr.message);
try { if (context) await context.close(); } catch (_e) { /* ignore */ }
try { if (browser) await browser.close(); } catch (_e) { /* ignore */ }
browser = null; context = null;
}
}
try {
console.log('[myTischtennisClient.playwright] Start browser login flow');
browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage']
});
context = await browser.newContext();
const page = await context.newPage();
// Helper: click the CMP/consent "Akzeptieren" button if visible.
// Tries multiple selectors to cover different CMP implementations.
const acceptConsentDialog = async (waitMs = 0) => {
if (waitMs > 0) await page.waitForTimeout(waitMs);
const consentSelectors = [
'#onetrust-accept-btn-handler',
'button:has-text("Alle akzeptieren")',
'button:has-text("Akzeptieren")',
'button:has-text("Einverstanden")',
'button:has-text("Zustimmen")',
'[data-testid="accept-button"]',
'.cmp-accept-all',
'.accept-all-btn'
];
for (const selector of consentSelectors) {
try {
const button = page.locator(selector).first();
if (await button.count()) {
await button.click({ timeout: 2500 });
console.log('[myTischtennisClient.playwright] Consent dialog accepted via:', selector);
await page.waitForTimeout(800);
return true;
}
} catch (_e) {
// try next selector
}
}
return false;
};
// Visit the homepage first so the browser receives and stores the correct CMP
// consent cookies (the TCF v2 format cannot be guessed and set manually).
// After accepting consent here, the login page will not show the banner again.
try {
await page.goto(this.baseURL, { waitUntil: 'domcontentloaded', timeout: 30000 });
const acceptedOnHome = await acceptConsentDialog(0);
if (!acceptedOnHome) await acceptConsentDialog(2500);
console.log('[myTischtennisClient.playwright] Homepage visited, consent handled');
} catch (_homeErr) {
console.log('[myTischtennisClient.playwright] Homepage pre-visit failed (continuing):', _homeErr.message);
}
await page.goto(`${this.baseURL}/login?next=%2F`, { waitUntil: 'domcontentloaded', timeout: 45000 });
console.log('[myTischtennisClient.playwright] Login page loaded');
// Second consent attempt in case it re-appears on the login page.
const consentOnLogin = await acceptConsentDialog(0);
if (!consentOnLogin) await acceptConsentDialog(1500);
// Fill credentials
await page.locator('input[name="email"]').first().fill(email, { timeout: 10000 });
await page.locator('input[name="password"]').first().fill(password, { timeout: 10000 });
console.log('[myTischtennisClient.playwright] Credentials filled');
// Try to interact with private-captcha if present (it may render with delay).
try {
await page.waitForSelector('private-captcha', { timeout: 8000 });
} catch (_e) {
// ignore: captcha host might not be present in all flows
}
const captchaHost = page.locator('private-captcha').first();
const hasCaptchaHost = (await captchaHost.count()) > 0;
let captchaReadyDetected = !hasCaptchaHost;
if (hasCaptchaHost) {
try {
await page.waitForTimeout(1200);
const captchaVisualStateBefore = await page.evaluate(() => {
const host = document.querySelector('private-captcha');
const checkbox = host?.shadowRoot?.querySelector('#pc-checkbox');
return {
hostClass: host?.className || null,
hostDataState: host?.getAttribute?.('data-state') || null,
checkboxClass: checkbox?.className || null,
checkboxChecked: !!checkbox?.checked,
checkboxAriaChecked: checkbox?.getAttribute?.('aria-checked') || null
};
});
const interaction = await page.evaluate(() => {
const host = document.querySelector('private-captcha');
const checkbox = host?.shadowRoot?.querySelector('#pc-checkbox');
if (!checkbox) {
return { clicked: false, reason: 'checkbox-missing' };
}
checkbox.click();
checkbox.dispatchEvent(new Event('input', { bubbles: true }));
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
return {
clicked: true,
viaShadowRoot: true,
className: checkbox.className || null,
checked: !!checkbox.checked
};
});
console.log('[myTischtennisClient.playwright] evaluate interaction result:', interaction);
// Wait until hidden captcha fields are populated by site scripts.
try {
await page.waitForFunction(() => {
const captchaField = document.querySelector('input[name="captcha"]');
const clickedField = document.querySelector('input[name="captcha_clicked"]');
const captchaValue = (captchaField && captchaField.value ? captchaField.value.trim() : '');
const clickedValue = (clickedField && clickedField.value ? clickedField.value.toLowerCase() : '');
return captchaValue.length > 80 && (clickedValue === 'true' || clickedValue === '1');
}, { timeout: 20000 });
const captchaState = await page.evaluate(() => {
const captchaField = document.querySelector('input[name="captcha"]');
const clickedField = document.querySelector('input[name="captcha_clicked"]');
return {
captchaLen: captchaField?.value?.length || 0,
captchaClicked: clickedField?.value || null
};
});
console.log('[myTischtennisClient.playwright] Captcha value ready:', captchaState);
captchaReadyDetected = true;
} catch (_waitErr) {
// Keep going; some flows still succeed without explicit hidden field update.
console.warn('[myTischtennisClient.playwright] Captcha value not ready in time');
}
// Optional diagnostic only: visual state change should never block submit.
try {
await page.waitForFunction((beforeState) => {
const host = document.querySelector('private-captcha');
const checkbox = host?.shadowRoot?.querySelector('#pc-checkbox');
if (!host || !checkbox) return false;
const current = {
hostClass: host.className || '',
hostDataState: host.getAttribute?.('data-state') || '',
checkboxClass: checkbox.className || '',
checkboxChecked: !!checkbox.checked,
checkboxAriaChecked: checkbox.getAttribute?.('aria-checked') || ''
};
const visualChanged =
current.hostClass !== (beforeState?.hostClass || '')
|| current.hostDataState !== (beforeState?.hostDataState || '')
|| current.checkboxClass !== (beforeState?.checkboxClass || '')
|| current.checkboxChecked !== !!beforeState?.checkboxChecked
|| current.checkboxAriaChecked !== (beforeState?.checkboxAriaChecked || '');
return visualChanged;
}, captchaVisualStateBefore, { timeout: 1500 });
console.log('[myTischtennisClient.playwright] Captcha visual state changed');
} catch (_visualWaitErr) {
// no-op: widget often keeps "ready" class despite solved token
}
} catch (captchaError) {
console.warn('[myTischtennisClient.playwright] Captcha interaction warning:', captchaError?.message || captchaError);
}
}
// Ensure captcha_clicked field is set if available.
await page.evaluate(() => {
const clickedField = document.querySelector('input[name="captcha_clicked"]');
if (clickedField && !clickedField.value) {
clickedField.value = 'true';
}
});
// Before submit, ensure CAPTCHA fields are actually ready if captcha widget exists.
if (hasCaptchaHost) {
const isCaptchaReadyNow = await page.evaluate(() => {
const captchaField = document.querySelector('input[name="captcha"]');
const clickedField = document.querySelector('input[name="captcha_clicked"]');
const captchaValue = (captchaField && captchaField.value ? captchaField.value.trim() : '');
const clickedValue = (clickedField && clickedField.value ? clickedField.value.toLowerCase() : '');
return captchaValue.length > 80 && (clickedValue === 'true' || clickedValue === '1');
});
captchaReadyDetected = captchaReadyDetected || isCaptchaReadyNow;
if (!isCaptchaReadyNow) {
try {
await page.waitForFunction(() => {
const captchaField = document.querySelector('input[name="captcha"]');
const clickedField = document.querySelector('input[name="captcha_clicked"]');
const captchaValue = (captchaField && captchaField.value ? captchaField.value.trim() : '');
const clickedValue = (clickedField && clickedField.value ? clickedField.value.toLowerCase() : '');
return captchaValue.length > 80 && (clickedValue === 'true' || clickedValue === '1');
}, { timeout: 12000 });
captchaReadyDetected = true;
} catch (_captchaNotReadyErr) {
return {
success: false,
error: 'Playwright-Login fehlgeschlagen: CAPTCHA wurde im Browser nicht als gelöst erkannt'
};
}
}
}
// Human-like pause only after captcha was actually solved (2-6s).
if (captchaReadyDetected) {
const postCaptchaDelayMs = 2000 + Math.floor(Math.random() * 4001);
await page.waitForTimeout(postCaptchaDelayMs);
console.log('[myTischtennisClient.playwright] Waited after solved captcha:', postCaptchaDelayMs);
}
// Ensure login intent is present and click the explicit login submit button.
await page.evaluate(() => {
const form = document.querySelector('form[action*="/login"]');
if (!form) return;
let intentField = form.querySelector('input[name="intent"]');
if (!intentField) {
intentField = document.createElement('input');
intentField.setAttribute('type', 'hidden');
intentField.setAttribute('name', 'intent');
form.appendChild(intentField);
}
intentField.setAttribute('value', 'login');
});
// Submit form
const loginSubmitButton = page.locator('button[type="submit"][name="intent"][value="login"]').first();
const genericSubmitButton = page.locator('button[type="submit"], input[type="submit"]').first();
if (await loginSubmitButton.count()) {
await loginSubmitButton.click({ timeout: 15000, noWaitAfter: true });
} else if (await genericSubmitButton.count()) {
await genericSubmitButton.click({ timeout: 15000, noWaitAfter: true });
} else {
await page.keyboard.press('Enter');
}
console.log('[myTischtennisClient.playwright] Submit clicked');
// Wait for auth cookie after submit (polling avoids timing races).
let authCookieObj = null;
let detectedSubmitError = null;
const pollIntervalMs = 500;
const maxAttempts = 40; // ~20s max wait after submit
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const cookies = await context.cookies();
authCookieObj = cookies.find((c) => c.name === 'sb-10-auth-token')
|| cookies.find((c) => /^sb-\d+-auth-token$/.test(c.name))
|| cookies.find((c) => c.name.includes('auth-token'));
if (authCookieObj?.value) {
console.log('[myTischtennisClient.playwright] Auth cookie detected:', authCookieObj.name);
break;
}
// Periodically: dismiss consent banner (may reappear after submit redirect)
// and probe page text to fail fast on known error strings.
if (attempt % 4 === 0) {
try { await acceptConsentDialog(0); } catch (_e) { /* ignore */ }
try {
const textContent = await page.locator('body').innerText({ timeout: 600 });
if (textContent?.includes('Captcha-Bestätigung fehlgeschlagen')) {
detectedSubmitError = 'Captcha-Bestätigung fehlgeschlagen';
break;
}
if (textContent?.includes('Captcha-Bestätigung ist erforderlich')) {
detectedSubmitError = 'Captcha-Bestätigung ist erforderlich';
break;
}
if (textContent?.includes('Ungültige E-Mail oder Passwort')) {
detectedSubmitError = 'Ungültige E-Mail oder Passwort';
break;
}
} catch (_readBodyErr) {
// ignore text read errors during polling
}
}
await page.waitForTimeout(pollIntervalMs);
}
if (!authCookieObj || !authCookieObj.value) {
let errorText = null;
let failureDiagnostics = null;
try {
const textContent = await page.locator('body').innerText({ timeout: 1000 });
if (textContent?.includes('Captcha-Bestätigung fehlgeschlagen')) {
errorText = 'Captcha-Bestätigung fehlgeschlagen';
}
if (!errorText && textContent?.includes('Passwort')) {
errorText = 'Login vermutlich fehlgeschlagen (Passwort oder CAPTCHA)';
}
const currentUrl = page.url();
const allCookies = await context.cookies();
const cookieNames = allCookies.map((c) => c.name);
failureDiagnostics = {
url: currentUrl,
cookieNames,
bodyPreview: String(textContent || '').slice(0, 320)
};
} catch (_e) {
// ignore text read errors
}
if (!errorText && detectedSubmitError) {
errorText = detectedSubmitError;
}
if (failureDiagnostics) {
console.warn('[myTischtennisClient.playwright] Login failure diagnostics:', failureDiagnostics);
}
return {
success: false,
error: errorText
? `Playwright-Login fehlgeschlagen: ${errorText}`
: 'Playwright-Login fehlgeschlagen: Kein sb-10-auth-token Cookie gefunden'
};
}
// Cookie value is expected as "base64-<tokenData>"
const tokenMatch = String(authCookieObj.value).match(/^base64-(.+)$/);
if (!tokenMatch) {
return {
success: false,
error: 'Playwright-Login fehlgeschlagen: Token-Format ungültig'
};
}
let tokenData;
try {
tokenData = JSON.parse(Buffer.from(tokenMatch[1], 'base64').toString('utf-8'));
} catch (decodeError) {
return {
success: false,
error: `Playwright-Login fehlgeschlagen: Token konnte nicht dekodiert werden (${decodeError.message})`
};
}
const cookie = `sb-10-auth-token=${authCookieObj.value}`;
// Persist the full browser storage state so future calls can skip the CAPTCHA flow.
let storageState = null;
try { storageState = await context.storageState(); } catch (_e) { /* ignore */ }
console.log('[myTischtennisClient.playwright] Browser login successful');
return {
success: true,
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token,
expiresAt: tokenData.expires_at,
expiresIn: tokenData.expires_in,
user: tokenData.user,
cookie,
storageState
};
} catch (error) {
const rawMessage = String(error?.message || error || 'Playwright-Login fehlgeschlagen');
const isMissingBrowserExecutable = /Executable doesn't exist|download new browsers|playwright install/i.test(rawMessage);
const normalizedError = isMissingBrowserExecutable
? 'Playwright-Browser ist auf dem Server nicht installiert. Bitte "npx playwright install chromium" ausführen.'
: rawMessage;
console.error('[myTischtennisClient.playwright] Browser login failed:', normalizedError);
return {
success: false,
error: normalizedError,
requiresSetup: isMissingBrowserExecutable,
status: isMissingBrowserExecutable ? 503 : 400
};
} finally {
if (context) {
try {
await context.close();
} catch (contextCloseError) {
console.warn('[myTischtennisClient.playwright] Context close warning:', contextCloseError?.message || contextCloseError);
}
}
if (browser) {
try {
await browser.close();
} catch (browserCloseError) {
console.warn('[myTischtennisClient.playwright] Browser close warning:', browserCloseError?.message || browserCloseError);
}
console.log('[myTischtennisClient.playwright] Browser closed');
}
}
}
/**
* Verify login credentials
* @param {string} email - myTischtennis email
@@ -331,7 +869,8 @@ class MyTischtennisClient {
* @param {string} fedNickname - Federation nickname (e.g., "HeTTV")
* @returns {Promise<Object>} Rankings with player entries (all pages)
*/
async getClubRankings(cookie, clubId, fedNickname, currentRanking = 'yes') {
async getClubRankings(cookie, clubId, fedNickname, currentRanking = 'yes', options = {}) {
const { includeHistoryPlayerIds = false } = options;
const allEntries = [];
let currentPage = 0;
let hasMorePages = true;
@@ -339,8 +878,6 @@ class MyTischtennisClient {
while (hasMorePages) {
const endpoint = `/rankings/andro-rangliste?all-players=on&clubnr=${clubId}&fednickname=${fedNickname}&current-ranking=${currentRanking}&results-per-page=100&page=${currentPage}&_data=routes%2F%24`;
const result = await this.authenticatedRequest(endpoint, cookie, {
method: 'GET'
});
@@ -379,15 +916,39 @@ class MyTischtennisClient {
error: 'Keine entries in blockLoaderData gefunden'
};
}
let historyPlayerIdsByName = null;
if (includeHistoryPlayerIds) {
const htmlEndpoint = `/rankings/andro-rangliste?clubnr=${clubId}&fednickname=${fedNickname}&all-players=on&continent=all&country=all&current-ranking=${currentRanking}&results-per-page=100&page=${currentPage + 1}`;
const htmlResult = await this.authenticatedRequest(htmlEndpoint, cookie, {
method: 'GET',
headers: {
Accept: 'text/html,application/xhtml+xml'
}
});
historyPlayerIdsByName = htmlResult.success
? this.extractHistoryPlayerIdsFromAndroRankingHtml(htmlResult.data)
: new Map();
}
const enrichedEntries = entries.map((entry) => {
const nameKey = this._buildRankingNameKey(entry?.firstname, entry?.lastname);
const historyPlayerId = historyPlayerIdsByName?.get(nameKey) || null;
return {
...entry,
historyPlayerId,
myTischtennisHistoryPlayerId: historyPlayerId
};
});
// Füge Entries hinzu
allEntries.push(...entries);
allEntries.push(...enrichedEntries);
// Prüfe ob es weitere Seiten gibt
// Wenn die aktuelle Seite weniger Einträge hat als das Limit, sind wir am Ende
// Oder wenn wir alle erwarteten Einträge haben
if (entries.length === 0) {
if (enrichedEntries.length === 0) {
hasMorePages = false;
} else if (rankingData.numberOfPages && currentPage >= rankingData.numberOfPages - 1) {
hasMorePages = false;
@@ -408,7 +969,45 @@ class MyTischtennisClient {
}
};
}
extractHistoryPlayerIdsFromAndroRankingHtml(html) {
const result = new Map();
const source = typeof html === 'string' ? html : String(html || '');
const anchorPattern = /href="\/community\/external-profile\?player-id=(P[A-Z0-9]+)"[^>]*>([^<]+)<\/a>/gi;
let match = null;
while ((match = anchorPattern.exec(source)) !== null) {
const playerId = match[1];
const fullName = this._decodeHtmlEntities(match[2] || '');
const key = this._buildRankingFullNameKey(fullName);
if (key && playerId && !result.has(key)) {
result.set(key, playerId);
}
}
return result;
}
_buildRankingNameKey(firstname, lastname) {
return this._buildRankingFullNameKey(`${firstname || ''} ${lastname || ''}`);
}
_buildRankingFullNameKey(name) {
return String(name || '')
.normalize('NFKC')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
}
_decodeHtmlEntities(value) {
return String(value || '')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>');
}
}
export default new MyTischtennisClient();

View File

@@ -1,13 +1,14 @@
import { register, activateUser, login, logout } from '../services/authService.js';
import jwt from 'jsonwebtoken';
import UserToken from '../models/UserToken.js';
import User from '../models/User.js'; // ggf. Pfad anpassen
import { register, activateUser, login, logout, deleteOwnAccount, requestPasswordReset, resetPassword } from '../services/authService.js';
const registerUser = async (req, res, next) => {
try {
const { email, password } = req.body;
const user = await register(email, password);
res.status(201).json(user);
console.log('registerUser', email, password);
await register(email, password);
console.log('registerUser done');
// Aus Sicherheitsgründen KEINE Userdaten (Passwort-Hash, Aktivierungscode, ...) zurückgeben
res.status(201).json({ success: true });
console.log('registerUser response sent');
} catch (error) {
next(error);
}
@@ -16,8 +17,9 @@ const registerUser = async (req, res, next) => {
const activate = async (req, res, next) => {
try {
const { activationCode } = req.params;
const user = await activateUser(activationCode);
res.status(200).json(user);
await activateUser(activationCode);
// Auch bei Aktivierung kein komplettes User-Objekt zurückgeben
res.status(200).json({ success: true });
} catch (error) {
next(error);
}
@@ -25,8 +27,8 @@ const activate = async (req, res, next) => {
const loginUser = async (req, res, next) => {
try {
const { email, password } = req.body;
const result = await login(email, password);
const { email, password, rememberMe } = req.body;
const result = await login(email, password, { rememberMe });
res.status(200).json(result);
} catch (error) {
next(error);
@@ -35,7 +37,7 @@ const loginUser = async (req, res, next) => {
const logoutUser = async (req, res, next) => {
try {
const token = req.headers['authorization']?.split(' ')[1];
const token = req.headers['authorization']?.split(' ')[1] || req.headers.authcode;
const result = await logout(token);
res.status(200).json(result);
} catch (error) {
@@ -43,4 +45,34 @@ const logoutUser = async (req, res, next) => {
}
};
export { registerUser, activate, loginUser, logoutUser };
const deleteAccount = async (req, res, next) => {
try {
const { password } = req.body || {};
const result = await deleteOwnAccount(req.user?.id, password);
res.status(200).json(result);
} catch (error) {
next(error);
}
};
const forgotPassword = async (req, res, next) => {
try {
const { email } = req.body;
const result = await requestPasswordReset(email);
res.status(200).json(result);
} catch (error) {
next(error);
}
};
const resetUserPassword = async (req, res, next) => {
try {
const { token, password } = req.body;
const result = await resetPassword(token, password);
res.status(200).json(result);
} catch (error) {
next(error);
}
};
export { registerUser, activate, loginUser, logoutUser, deleteAccount, forgotPassword, resetUserPassword };

View File

@@ -0,0 +1,207 @@
import fs from 'fs';
import multer from 'multer';
import path from 'path';
import { fileURLToPath } from 'url';
import billingService from '../services/billingService.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const BILLING_TEMPLATE_UPLOAD_DIR = path.resolve(__dirname, '..', 'uploads', 'billing-templates');
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const dir = BILLING_TEMPLATE_UPLOAD_DIR;
fs.mkdirSync(dir, { recursive: true });
cb(null, dir);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname) || '.pdf';
cb(null, `billing-template-${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`);
}
});
const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
const ext = path.extname(file.originalname || '').toLowerCase();
const isPdf = ext === '.pdf' || (file.mimetype || '').includes('pdf');
if (!isPdf) {
return cb(new Error('Nur PDF-Dateien sind erlaubt.'));
}
cb(null, true);
}
});
export const uploadBillingTemplateMiddleware = upload.single('templatePdf');
export const listTemplates = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const result = await billingService.listTemplates(userToken, clubId);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[listTemplates] Error:', error);
res.status(500).json({ success: false, error: 'Vorlagen konnten nicht geladen werden.' });
}
};
export const createTemplate = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const result = await billingService.createTemplate(userToken, clubId, req.body || {}, req.file);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[createTemplate] Error:', error);
res.status(500).json({ success: false, error: 'Vorlage konnte nicht gespeichert werden.' });
}
};
export const deleteTemplate = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { templateId } = req.params;
const result = await billingService.deleteTemplate(userToken, templateId);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[deleteTemplate] Error:', error);
res.status(500).json({ success: false, error: 'Vorlage konnte nicht gelöscht werden.' });
}
};
export const updateTemplateFields = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { templateId } = req.params;
const result = await billingService.saveTemplateFields(userToken, templateId, req.body?.fields || []);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[updateTemplateFields] Error:', error);
res.status(500).json({ success: false, error: 'Felder konnten nicht gespeichert werden.' });
}
};
export const createRun = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const result = await billingService.createRun(userToken, clubId, req.body || {});
res.status(result.status).json(result.response);
} catch (error) {
console.error('[createRun] Error:', error);
res.status(500).json({ success: false, error: 'Abrechnungslauf konnte nicht erstellt werden.' });
}
};
export const listRuns = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const result = await billingService.listRuns(userToken, clubId);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[listRuns] Error:', error);
res.status(500).json({ success: false, error: 'Abrechnungsläufe konnten nicht geladen werden.' });
}
};
export const getRunDetails = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { runId } = req.params;
const result = await billingService.getRunDetails(userToken, runId);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[getRunDetails] Error:', error);
res.status(500).json({ success: false, error: 'Abrechnungslauf konnte nicht geladen werden.' });
}
};
export const deleteRun = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { runId } = req.params;
const result = await billingService.deleteRun(userToken, runId);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[deleteRun] Error:', error);
res.status(500).json({ success: false, error: 'Abrechnung konnte nicht gelöscht werden.' });
}
};
export const getUserSettings = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const result = await billingService.getUserSettings(userToken, clubId);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[getUserSettings] Error:', error);
res.status(500).json({ success: false, error: 'Einstellungen konnten nicht geladen werden.' });
}
};
export const calculateHoursPreview = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const { monthFrom, monthTo } = req.query;
const result = await billingService.calculateHoursPreview(userToken, clubId, monthFrom, monthTo);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[calculateHoursPreview] Error:', error);
res.status(500).json({ success: false, error: 'Stunden konnten nicht berechnet werden.' });
}
};
export const generateRun = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { runId } = req.params;
const result = await billingService.generateRun(userToken, runId, req.body || {});
res.status(result.status).json(result.response);
} catch (error) {
console.error('[generateRun] Error:', error);
const message = String(error?.message || '');
if (message.includes('Formularfelder') || message.includes('Feldnamen') || message.includes('Fehlend:')) {
return res.status(400).json({ success: false, error: message });
}
res.status(500).json({ success: false, error: 'Abrechnung konnte nicht erzeugt werden.' });
}
};
export const downloadTemplatePdf = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { templateId } = req.params;
const result = await billingService.downloadTemplatePdf(userToken, templateId);
if (result.status !== 200) {
return res.status(result.status).json(result.response);
}
res.setHeader('Content-Disposition', `inline; filename="${result.file.name}"`);
res.setHeader('Content-Type', result.file.mimeType);
return res.sendFile(result.file.path);
} catch (error) {
console.error('[downloadTemplatePdf] Error:', error);
res.status(500).json({ success: false, error: 'PDF konnte nicht geladen werden.' });
}
};
export const downloadRunPdf = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { runId } = req.params;
const result = await billingService.downloadGeneratedRunPdf(userToken, runId);
if (result.status !== 200) {
return res.status(result.status).json(result.response);
}
res.setHeader('Content-Disposition', `attachment; filename="${result.file.name}"`);
res.setHeader('Content-Type', result.file.mimeType);
return res.sendFile(result.file.path);
} catch (error) {
console.error('[downloadRunPdf] Error:', error);
res.status(500).json({ success: false, error: 'Abrechnungs-PDF konnte nicht geladen werden.' });
}
};

View File

@@ -0,0 +1,20 @@
import calendarHolidayService from '../services/calendarHolidayService.js';
export const getClubCalendarDays = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubId } = req.params;
const { year } = req.query;
const result = await calendarHolidayService.getClubCalendarDays(token, clubId, year);
res.status(200).json(result);
} catch (error) {
if (error.message === 'clubnotfound') {
res.status(404).json({ error: 'clubnotfound' });
} else if (error.message === 'noaccess') {
res.status(403).json({ error: 'noaccess' });
} else {
console.error('[getClubCalendarDays] - error:', error);
res.status(502).json({ error: 'calendarproviderfailed' });
}
}
};

View File

@@ -0,0 +1,42 @@
import calendarEventService from '../services/calendarEventService.js';
import { getSafeErrorMessage } from '../utils/errorUtils.js';
export const listClubCalendarEvents = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const { year } = req.query;
const events = await calendarEventService.listClubEvents(userToken, clubId, year);
res.status(200).json(events);
} catch (error) {
console.error('[listClubCalendarEvents] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Laden der Kalender-Events');
res.status(error.statusCode || 500).json({ error: msg });
}
};
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);
res.status(201).json(event);
} catch (error) {
console.error('[createClubCalendarEvent] - 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;
const { clubId, eventId } = req.params;
const result = await calendarEventService.deleteClubEvent(userToken, clubId, eventId);
res.status(200).json(result);
} catch (error) {
console.error('[deleteClubCalendarEvent] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Löschen des Kalender-Events');
res.status(error.statusCode || 500).json({ error: msg });
}
};

View File

@@ -0,0 +1,72 @@
import clickTtAccountService from '../services/clickTtAccountService.js';
import HttpError from '../exceptions/HttpError.js';
class ClickTtAccountController {
async getAccount(req, res, next) {
try {
const account = await clickTtAccountService.getAccount(req.user.id);
res.status(200).json({ account });
} catch (error) {
next(error);
}
}
async getStatus(req, res, next) {
try {
const status = await clickTtAccountService.checkAccountStatus(req.user.id);
res.status(200).json(status);
} catch (error) {
next(error);
}
}
async upsertAccount(req, res, next) {
try {
const { username, password, savePassword, userPassword } = req.body;
if (!username) {
throw new HttpError('Benutzername erforderlich', 400);
}
if (password && !userPassword) {
throw new HttpError('App-Passwort erforderlich zum Setzen des HTTV-/click-TT-Passworts', 400);
}
const account = await clickTtAccountService.upsertAccount(
req.user.id,
username,
password,
savePassword || false,
userPassword
);
res.status(200).json({
message: 'HTTV-/click-TT-Account erfolgreich gespeichert',
account
});
} catch (error) {
next(error);
}
}
async deleteAccount(req, res, next) {
try {
const deleted = await clickTtAccountService.deleteAccount(req.user.id);
if (!deleted) {
throw new HttpError('Kein HTTV-/click-TT-Account gefunden', 404);
}
res.status(200).json({ message: 'HTTV-/click-TT-Account gelöscht' });
} catch (error) {
next(error);
}
}
async verifyLogin(req, res, next) {
try {
const result = await clickTtAccountService.verifyLogin(req.user.id, req.body.password);
res.status(200).json({ success: true, message: 'Login erfolgreich', ...result });
} catch (error) {
next(error);
}
}
}
export default new ClickTtAccountController();

View File

@@ -0,0 +1,60 @@
import clubAccountService from '../services/clubAccountService.js';
class ClubAccountController {
async listClubAccounts(req, res) {
try {
const { clubId } = req.params;
const accounts = await clubAccountService.listClubAccounts(Number(clubId));
res.json({ accounts });
} 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.' });
}
}
}
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,557 @@
import { Op } from 'sequelize';
import sequelize from '../database.js';
import {
CalendarEvent,
ClubPaymentClaim,
ClubRequest,
ClubSepaMandate,
ClubTask,
Match,
Member,
TrainingGroup,
} from '../models/index.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',
}[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',
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 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 [
requests,
tasks,
members,
mandates,
paymentClaims,
upcomingEvents,
trainingGroups,
upcomingMatches,
] = 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'],
})),
loadOptionalTableData(availableTables, 'club_payment_claims', () => ClubPaymentClaim.findAll({
where: {
clubId,
status: { [Op.in]: ['open', 'partially_paid'] },
archivedAt: null,
},
order: [['dueOn', 'ASC']],
})),
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,
})),
]);
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 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',
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',
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: reminderCount > 0 ? `${reminderCount} mit Mahnstufe` : 'Keine Mahnungen aktiv',
to: '/club-tasks',
items: paymentClaims.slice(0, 3).map((claim) => {
const amount = `${(Number(claim.amountCents) / 100).toFixed(2)} ${claim.currencyCode || 'EUR'}`;
return createDashboardItem(
`${amount} fällig am ${claim.dueOn}`,
claim.memberId ? buildMemberRoute(claim.memberId, 'active') : '/club-tasks'
);
}),
},
{
title: 'Fehlende Daten',
value: `${missingFields.email + missingFields.birthDate + missingMandateCount}`,
to: '/members',
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',
items: [
createDashboardItem(`${automatedOpenTasks.length} automatisch erzeugte Schritte`, '/club-tasks'),
...visibleDashboardTasks.slice(0, 3).map((task) => taskDashboardItem(task, task.title)),
],
},
],
},
{
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-tasks',
},
],
},
{
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 === '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,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,274 @@
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,
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.'),
});
}
};

View File

@@ -42,7 +42,7 @@ export const createClubTeam = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubid: clubId } = req.params;
const { name, leagueId, seasonId } = req.body;
const { name, leagueId, seasonId, teamGender, teamAgeGroup, plannedLeagueName } = req.body;
const user = await getUserByToken(token);
@@ -50,11 +50,17 @@ export const createClubTeam = async (req, res) => {
return res.status(400).json({ error: "missingname" });
}
const planned = plannedLeagueName !== undefined && plannedLeagueName !== null
? String(plannedLeagueName).trim() || null
: undefined;
const clubTeamData = {
name,
clubId: parseInt(clubId),
leagueId: leagueId ? parseInt(leagueId) : null,
seasonId: seasonId ? parseInt(seasonId) : null
seasonId: seasonId ? parseInt(seasonId) : null,
teamGender: teamGender || 'open',
teamAgeGroup: teamAgeGroup || 'adult',
...(planned !== undefined ? { plannedLeagueName: planned } : {})
};
const newClubTeam = await ClubTeamService.createClubTeam(clubTeamData);
@@ -70,7 +76,7 @@ export const updateClubTeam = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubteamid: clubTeamId } = req.params;
const { name, leagueId, seasonId } = req.body;
const { name, leagueId, seasonId, teamGender, teamAgeGroup, plannedLeagueName } = req.body;
const user = await getUserByToken(token);
@@ -78,6 +84,13 @@ export const updateClubTeam = async (req, res) => {
if (name !== undefined) updateData.name = name;
if (leagueId !== undefined) updateData.leagueId = leagueId ? parseInt(leagueId) : null;
if (seasonId !== undefined) updateData.seasonId = seasonId ? parseInt(seasonId) : null;
if (teamGender !== undefined) updateData.teamGender = teamGender || 'open';
if (teamAgeGroup !== undefined) updateData.teamAgeGroup = teamAgeGroup || 'adult';
if (plannedLeagueName !== undefined) {
updateData.plannedLeagueName = plannedLeagueName === null || plannedLeagueName === ''
? null
: String(plannedLeagueName).trim() || null;
}
const success = await ClubTeamService.updateClubTeam(clubTeamId, updateData);
if (!success) {
@@ -126,3 +139,47 @@ export const getLeagues = async (req, res) => {
res.status(500).json({ error: "internalerror" });
}
};
export const getClubTeamLineup = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubteamid: clubTeamId } = req.params;
const lineupHalf = req.query.half === 'second_half' ? 'second_half' : 'first_half';
await getUserByToken(token);
const clubTeam = await ClubTeamService.getClubTeamById(clubTeamId);
if (!clubTeam) {
return res.status(404).json({ error: "notfound" });
}
const lineup = await ClubTeamService.getTeamLineup(clubTeamId, lineupHalf);
res.status(200).json(lineup);
} catch (error) {
console.error('[getClubTeamLineup] - Error:', error);
res.status(500).json({ error: "internalerror" });
}
};
export const updateClubTeamLineup = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubteamid: clubTeamId } = req.params;
const { assignments, lineupHalf: requestLineupHalf } = req.body;
const lineupHalf = requestLineupHalf === 'second_half' || req.query.half === 'second_half' ? 'second_half' : 'first_half';
await getUserByToken(token);
const clubTeam = await ClubTeamService.getClubTeamById(clubTeamId);
if (!clubTeam) {
return res.status(404).json({ error: "notfound" });
}
const lineup = await ClubTeamService.replaceTeamLineup(clubTeamId, assignments, lineupHalf);
res.status(200).json(lineup);
} catch (error) {
console.error('[updateClubTeamLineup] - Error:', error);
if (error?.code === 'TEAM_LINEUP_TABLE_MISSING') {
return res.status(500).json({ error: 'teamlineuptablemissing' });
}
res.status(500).json({ error: "internalerror" });
}
};

View File

@@ -0,0 +1,52 @@
import clubVenueService from '../services/clubVenueService.js';
const handleError = (res, label, error) => {
if (error.message === 'noaccess') return res.status(403).json({ error: 'noaccess' });
if (error.statusCode || error.status) return res.status(error.statusCode || error.status).json({ error: error.message });
console.error(`[${label}] - error:`, error);
return res.status(500).json({ error: 'internalerror' });
};
export const listClubVenues = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubId } = req.params;
const venues = await clubVenueService.list(token, clubId);
res.status(200).json(venues);
} catch (error) {
handleError(res, 'listClubVenues', error);
}
};
export const createClubVenue = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubId } = req.params;
const venue = await clubVenueService.create(token, clubId, req.body);
res.status(201).json(venue);
} catch (error) {
handleError(res, 'createClubVenue', error);
}
};
export const updateClubVenue = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubId, venueId } = req.params;
const venue = await clubVenueService.update(token, clubId, venueId, req.body);
res.status(200).json(venue);
} catch (error) {
handleError(res, 'updateClubVenue', error);
}
};
export const deleteClubVenue = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubId, venueId } = req.params;
const result = await clubVenueService.delete(token, clubId, venueId);
res.status(200).json(result);
} catch (error) {
handleError(res, 'deleteClubVenue', error);
}
};

View File

@@ -60,8 +60,24 @@ export const updateClubSettings = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubid } = req.params;
const { greetingText, associationMemberNumber } = req.body;
const updated = await ClubService.updateClubSettings(token, clubid, { greetingText, associationMemberNumber });
const {
greetingText,
associationMemberNumber,
myTischtennisFedNickname,
autoFetchRankings,
countryCode,
stateCode,
memberDataQualityRequirements
} = req.body;
const updated = await ClubService.updateClubSettings(token, clubid, {
greetingText,
associationMemberNumber,
myTischtennisFedNickname,
autoFetchRankings,
countryCode,
stateCode,
memberDataQualityRequirements
});
res.status(200).json(updated);
} catch (error) {
if (error.message === 'noaccess') {

View File

@@ -18,14 +18,14 @@ const createDateForClub = async (req, res) => {
try {
const { clubId } = req.params;
const { authcode: userToken } = req.headers;
const { date, trainingStart, trainingEnd } = req.body;
const { date, trainingStart, trainingEnd, excludeFromBilling } = req.body;
if (!date) {
throw new HttpError('The date field is required', 400);
}
if (isNaN(new Date(date).getTime())) {
throw new HttpError('Invalid date format', 400);
}
const newDate = await diaryService.createDateForClub(userToken, clubId, date, trainingStart, trainingEnd);
const newDate = await diaryService.createDateForClub(userToken, clubId, date, trainingStart, trainingEnd, excludeFromBilling);
res.status(201).json(newDate);
} catch (error) {
console.error('[createDateForClub] - Error:', error);
@@ -37,15 +37,22 @@ const updateTrainingTimes = async (req, res) => {
try {
const { clubId } = req.params;
const { authcode: userToken } = req.headers;
const { dateId, trainingStart, trainingEnd } = req.body;
if (!dateId || !trainingStart) {
devLog(dateId, trainingStart, trainingEnd);
const { dateId, trainingStart, trainingEnd, excludeFromBilling } = req.body;
if (!dateId) {
devLog(dateId, trainingStart, trainingEnd, excludeFromBilling);
throw new HttpError('notallfieldsfilled', 400);
}
const updatedDate = await diaryService.updateTrainingTimes(userToken, clubId, dateId, trainingStart, trainingEnd);
const updatedDate = await diaryService.updateTrainingTimes(
userToken,
clubId,
dateId,
trainingStart,
trainingEnd,
excludeFromBilling,
);
// Emit Socket-Event
emitDiaryDateUpdated(clubId, dateId, { trainingStart, trainingEnd });
emitDiaryDateUpdated(clubId, dateId, { trainingStart, trainingEnd, excludeFromBilling });
res.status(200).json(updatedDate);
} catch (error) {

View File

@@ -1,20 +1,27 @@
import fs from 'fs';
import diaryDateActivityService from '../services/diaryDateActivityService.js';
import { emitActivityChanged } from '../services/socketService.js';
import DiaryDate from '../models/DiaryDates.js';
import { devLog } from '../utils/logger.js';
import { devLog, errorLog } from '../utils/logger.js';
export const createDiaryDateActivity = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
let userToken = req.headers['authcode'] || req.headers['auth-code'] || null;
const authHeader = req.headers['authorization'] || req.headers['Authorization'];
if (!userToken && authHeader && typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
userToken = authHeader.split(' ')[1];
}
const { clubId } = req.params;
const { diaryDateId, activity, duration, durationText, orderId, isTimeblock } = req.body;
const { diaryDateId, activity, predefinedActivityId, duration, durationText, orderId, isTimeblock, groupId } = req.body;
const activityItem = await diaryDateActivityService.createActivity(userToken, clubId, {
diaryDateId,
activity,
predefinedActivityId,
duration,
durationText,
orderId,
isTimeblock,
groupId,
});
// Emit Socket-Event
@@ -32,7 +39,11 @@ export const createDiaryDateActivity = async (req, res) => {
export const updateDiaryDateActivity = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
let userToken = req.headers['authcode'] || req.headers['auth-code'] || null;
const authHeader = req.headers['authorization'] || req.headers['Authorization'];
if (!userToken && authHeader && typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
userToken = authHeader.split(' ')[1];
}
const { clubId, id } = req.params;
const { predefinedActivityId, customActivityName, duration, durationText, orderId, groupId } = req.body; // Add groupId
const updatedActivity = await diaryDateActivityService.updateActivity(userToken, clubId, id, {
@@ -60,7 +71,11 @@ export const updateDiaryDateActivity = async (req, res) => {
export const deleteDiaryDateActivity = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
let userToken = req.headers['authcode'] || req.headers['auth-code'] || null;
const authHeader = req.headers['authorization'] || req.headers['Authorization'];
if (!userToken && authHeader && typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
userToken = authHeader.split(' ')[1];
}
const { clubId, id } = req.params;
// Hole diaryDateId vor dem Löschen
@@ -86,7 +101,11 @@ export const deleteDiaryDateActivity = async (req, res) => {
export const updateDiaryDateActivityOrder = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
let userToken = req.headers['authcode'] || req.headers['auth-code'] || null;
const authHeader = req.headers['authorization'] || req.headers['Authorization'];
if (!userToken && authHeader && typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
userToken = authHeader.split(' ')[1];
}
const { clubId, id } = req.params;
const { orderId } = req.body;
const updatedActivity = await diaryDateActivityService.updateActivityOrder(userToken, clubId, id, orderId);
@@ -108,21 +127,47 @@ export const updateDiaryDateActivityOrder = async (req, res) => {
export const getDiaryDateActivities = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
let userToken = req.headers['authcode'] || req.headers['auth-code'] || null;
const authHeader = req.headers['authorization'] || req.headers['Authorization'];
if (!userToken && authHeader && typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
userToken = authHeader.split(' ')[1];
}
const { clubId, diaryDateId } = req.params;
const activities = await diaryDateActivityService.getActivities(userToken, clubId, diaryDateId);
res.status(200).json(activities);
} catch (error) {
// Fallback-Logging: schreibe Stacktrace in eine Datei, falls STDOUT/STDERR nicht sichtbar ist
try {
const msg = `${new Date().toISOString()} - getDiaryDateActivities error: ${error && error.stack ? error.stack : JSON.stringify(error)}\n`;
fs.appendFileSync('/tmp/diary-activity-error.log', msg);
} catch (e) {
// ignore
}
devLog(error);
errorLog(error);
res.status(500).json({ error: 'Error getting activities' });
}
}
export const addGroupActivity = async(req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, diaryDateId, groupId, activity, predefinedActivityId, timeblockId } = req.body;
const activityItem = await diaryDateActivityService.addGroupActivity(userToken, clubId, diaryDateId, groupId, activity, predefinedActivityId, timeblockId);
let userToken = req.headers['authcode'] || req.headers['auth-code'] || null;
const authHeader = req.headers['authorization'] || req.headers['Authorization'];
if (!userToken && authHeader && typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
userToken = authHeader.split(' ')[1];
}
const { clubId, diaryDateId, groupId, activity, predefinedActivityId, timeblockId, duration, durationText } = req.body;
const activityItem = await diaryDateActivityService.addGroupActivity(
userToken,
clubId,
diaryDateId,
groupId,
activity,
predefinedActivityId,
timeblockId,
duration,
durationText
);
// Emit Socket-Event
const diaryDate = await DiaryDate.findByPk(diaryDateId);
@@ -141,8 +186,17 @@ export const updateGroupActivity = async(req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, groupActivityId } = req.params;
const { predefinedActivityId } = req.body;
const activityItem = await diaryDateActivityService.updateGroupActivity(userToken, clubId, groupActivityId, predefinedActivityId);
const { predefinedActivityId, duration, durationText, orderId, groupId } = req.body;
const activityItem = await diaryDateActivityService.updateGroupActivity(
userToken,
clubId,
groupActivityId,
predefinedActivityId,
duration,
durationText,
orderId,
groupId
);
// Emit Socket-Event
const GroupActivity = (await import('../models/GroupActivity.js')).default;
@@ -197,4 +251,4 @@ export const deleteGroupActivity = async(req, res) => {
devLog(error);
res.status(500).json({ error: 'Error deleting group activity' });
}
}
}

View File

@@ -61,8 +61,12 @@ const removeMemberNote = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, noteId } = req.params;
const { diaryDateId, memberId } = req.query;
if (!diaryDateId || !memberId) {
return res.status(400).json({ error: 'diaryDateId and memberId query parameters are required' });
}
await DiaryMemberService.removeNoteFromMember(userToken, clubId, noteId);
const notes = await DiaryMemberService.getNotesForMember(userToken, req.params.clubId, diaryDateId, memberId);
const notes = await DiaryMemberService.getNotesForMember(userToken, clubId, diaryDateId, memberId);
res.status(200).json(notes);
} catch (error) {
console.error('[removeMemberNote] - Error: ', error.message);
@@ -74,7 +78,7 @@ const removeMemberTag = async (req, res) => {
try {
const { diaryDateId, memberId, tagId } = req.body;
const { authcode: userToken } = req.headers;
await DiaryMemberService.removeTagFromMemberAndDate(userToken, req.params.clubId, diaryDateId, memberId, tagId);
await DiaryMemberService.removeTagFromMemberAndDate(userToken, req.params.clubId, diaryDateId, memberId, { id: tagId });
const tags = await DiaryMemberService.getTagsForMemberAndDate(userToken, req.params.clubId, diaryDateId, memberId);
res.status(200).json(tags);
} catch (error) {

View File

@@ -0,0 +1,70 @@
import FriendlyMatchService from '../services/friendlyMatchService.js';
import { emitScheduleMatchUpdated } from '../services/socketService.js';
function userTokenFrom(req) {
return req.headers.authcode;
}
export const listFriendlyMatches = async (req, res) => {
try {
const matches = await FriendlyMatchService.list(userTokenFrom(req), req.params.clubId);
res.status(200).json(matches);
} catch (error) {
console.error('[listFriendlyMatches] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Freundschaftsspiele konnten nicht geladen werden' });
}
};
export const createFriendlyMatch = async (req, res) => {
try {
const match = await FriendlyMatchService.create(userTokenFrom(req), req.params.clubId, req.body);
emitScheduleMatchUpdated(req.params.clubId, match.id, match);
res.status(201).json(match);
} catch (error) {
console.error('[createFriendlyMatch] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Freundschaftsspiel konnte nicht erstellt werden' });
}
};
export const updateFriendlyMatch = async (req, res) => {
try {
const match = await FriendlyMatchService.update(userTokenFrom(req), req.params.clubId, req.params.matchId, req.body);
emitScheduleMatchUpdated(req.params.clubId, match.id, match);
res.status(200).json(match);
} catch (error) {
console.error('[updateFriendlyMatch] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Freundschaftsspiel konnte nicht gespeichert werden' });
}
};
export const deleteFriendlyMatch = async (req, res) => {
try {
const result = await FriendlyMatchService.remove(userTokenFrom(req), req.params.clubId, req.params.matchId);
emitScheduleMatchUpdated(req.params.clubId, Number(req.params.matchId), null);
res.status(200).json(result);
} catch (error) {
console.error('[deleteFriendlyMatch] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Freundschaftsspiel konnte nicht gelöscht werden' });
}
};
export const updateFriendlyMatchPlayers = async (req, res) => {
try {
const match = await FriendlyMatchService.updatePlayers(userTokenFrom(req), req.params.clubId, req.params.matchId, req.body);
emitScheduleMatchUpdated(req.params.clubId, match.id, match);
res.status(200).json({ message: 'Teilnehmer gespeichert', data: match });
} catch (error) {
console.error('[updateFriendlyMatchPlayers] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Teilnehmer konnten nicht gespeichert werden' });
}
};
export const getFriendlyMatchMembers = async (req, res) => {
try {
const members = await FriendlyMatchService.members(userTokenFrom(req), req.params.clubId);
res.status(200).json(members);
} catch (error) {
console.error('[getFriendlyMatchMembers] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Mitglieder konnten nicht geladen werden' });
}
};

View File

@@ -0,0 +1,69 @@
import friendlyMatchSharedService from '../services/friendlyMatchSharedService.js';
import {
emitFriendlyInvitationAccepted,
emitFriendlyInvitationCreated,
emitFriendlyInvitationDeclined,
emitFriendlySharedMatchUpdated,
} from '../services/socketService.js';
function userTokenFrom(req) {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
return authHeader.slice(7);
}
return req.headers.authcode || authHeader;
}
export const createFriendlyMatchInvitation = async (req, res) => {
try {
const invitation = await friendlyMatchSharedService.createInvitation(userTokenFrom(req), req.params.clubId, req.body);
emitFriendlyInvitationCreated(invitation.fromClubId, invitation.toClubId, invitation);
res.status(201).json(invitation);
} catch (error) {
console.error('[createFriendlyMatchInvitation] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Einladung konnte nicht erstellt werden.' });
}
};
export const listIncomingFriendlyMatchInvitations = async (req, res) => {
try {
const items = await friendlyMatchSharedService.listIncomingInvitations(userTokenFrom(req), req.params.clubId);
res.status(200).json(items);
} catch (error) {
console.error('[listIncomingFriendlyMatchInvitations] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Eingehende Einladungen konnten nicht geladen werden.' });
}
};
export const listOutgoingFriendlyMatchInvitations = async (req, res) => {
try {
const items = await friendlyMatchSharedService.listOutgoingInvitations(userTokenFrom(req), req.params.clubId);
res.status(200).json(items);
} catch (error) {
console.error('[listOutgoingFriendlyMatchInvitations] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Ausgehende Einladungen konnten nicht geladen werden.' });
}
};
export const acceptFriendlyMatchInvitation = async (req, res) => {
try {
const result = await friendlyMatchSharedService.acceptInvitation(userTokenFrom(req), req.params.clubId, req.params.invitationId);
emitFriendlyInvitationAccepted(result.invitation.fromClubId, result.invitation.toClubId, result.invitation);
emitFriendlySharedMatchUpdated(result.sharedMatch.homeClubId, result.sharedMatch.guestClubId, result.sharedMatch);
res.status(200).json(result);
} catch (error) {
console.error('[acceptFriendlyMatchInvitation] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Einladung konnte nicht angenommen werden.' });
}
};
export const declineFriendlyMatchInvitation = async (req, res) => {
try {
const invitation = await friendlyMatchSharedService.declineInvitation(userTokenFrom(req), req.params.clubId, req.params.invitationId);
emitFriendlyInvitationDeclined(invitation.fromClubId, invitation.toClubId, invitation.id);
res.status(200).json({ success: true, id: invitation.id });
} catch (error) {
console.error('[declineFriendlyMatchInvitation] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Einladung konnte nicht abgelehnt werden.' });
}
};

View File

@@ -0,0 +1,101 @@
import friendlyMatchSharedService from '../services/friendlyMatchSharedService.js';
import {
emitFriendlySharedMatchDeleted,
emitFriendlySharedMatchUpdated,
} from '../services/socketService.js';
function userTokenFrom(req) {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
return authHeader.slice(7);
}
return req.headers.authcode || authHeader;
}
export const findSharedFriendlyMatches = async (req, res) => {
try {
const { clubId, name, date, startTime } = req.query;
const matches = await friendlyMatchSharedService.findByNameDateStartTime(userTokenFrom(req), clubId, {
name,
date,
startTime,
});
res.status(200).json(matches);
} catch (error) {
console.error('[findSharedFriendlyMatches] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Suche nach Freundschaftsspielen fehlgeschlagen.' });
}
};
export const listSharedFriendlyMatches = async (req, res) => {
try {
const data = await friendlyMatchSharedService.listShared(userTokenFrom(req), req.params.clubId);
res.status(200).json(data);
} catch (error) {
console.error('[listSharedFriendlyMatches] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Gemeinsame Freundschaftsspiele konnten nicht geladen werden.' });
}
};
export const getSharedFriendlyMatchMembers = async (req, res) => {
try {
const members = await friendlyMatchSharedService.membersForSide(
userTokenFrom(req),
req.params.clubId,
req.params.matchId,
req.params.side,
);
res.status(200).json(members);
} catch (error) {
console.error('[getSharedFriendlyMatchMembers] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Mitglieder konnten nicht geladen werden.' });
}
};
export const updateSharedFriendlyMatch = async (req, res) => {
try {
const match = await friendlyMatchSharedService.updateShared(
userTokenFrom(req),
req.params.clubId,
req.params.matchId,
req.body,
);
emitFriendlySharedMatchUpdated(match.homeClubId, match.guestClubId, match);
res.status(200).json(match);
} catch (error) {
console.error('[updateSharedFriendlyMatch] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Gemeinsames Freundschaftsspiel konnte nicht gespeichert werden.' });
}
};
export const updateSharedFriendlyMatchPlayers = async (req, res) => {
try {
const match = await friendlyMatchSharedService.updateSharedPlayers(
userTokenFrom(req),
req.params.clubId,
req.params.matchId,
req.body,
);
emitFriendlySharedMatchUpdated(match.homeClubId, match.guestClubId, match);
res.status(200).json({ message: 'Teilnehmer gespeichert', data: match });
} catch (error) {
console.error('[updateSharedFriendlyMatchPlayers] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Teilnehmer konnten nicht gespeichert werden.' });
}
};
export const deleteSharedFriendlyMatch = async (req, res) => {
try {
const match = await friendlyMatchSharedService.getSharedById(
userTokenFrom(req),
req.params.clubId,
req.params.matchId,
);
const result = await friendlyMatchSharedService.removeShared(userTokenFrom(req), req.params.clubId, req.params.matchId);
emitFriendlySharedMatchDeleted(match.homeClubId, match.guestClubId, Number(req.params.matchId));
res.status(200).json(result);
} catch (error) {
console.error('[deleteSharedFriendlyMatch] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Gemeinsames Freundschaftsspiel konnte nicht geloescht werden.' });
}
};

View File

@@ -1,6 +1,6 @@
import MatchService from '../services/matchService.js';
import fs from 'fs';
import { emitScheduleMatchUpdated } from '../services/socketService.js';
import { devLog } from '../utils/logger.js';
export const uploadCSV = async (req, res) => {
try {
@@ -51,7 +51,8 @@ export const getMatchesForLeague = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, leagueId } = req.params;
const matches = await MatchService.getMatchesForLeague(userToken, clubId, leagueId);
const { scope = 'own' } = req.query;
const matches = await MatchService.getMatchesForLeague(userToken, clubId, leagueId, scope);
return res.status(200).json(matches);
} catch (error) {
console.error('Error retrieving matches:', error);
@@ -116,7 +117,11 @@ export const updateMatchPlayers = async (req, res) => {
playersPlanned,
playersPlayed
);
if (result.clubId) {
emitScheduleMatchUpdated(result.clubId, result.id, result.match || null);
}
return res.status(200).json({
message: 'Match players updated successfully',
data: result
@@ -145,3 +150,21 @@ export const getPlayerMatchStats = async (req, res) => {
});
}
};
export const getMatchPlayers = async (req, res) => {
try {
const { clubId } = req.params;
if (!clubId) {
return res.status(400).json({ error: 'Club-ID fehlt' });
}
const Member = (await import('../models/Member.js')).default;
const members = await Member.findAll({
where: { clubId: clubId, active: true },
attributes: ['id', 'firstName', 'lastName', 'gender']
});
return res.status(200).json(members);
} catch (error) {
console.error('Error retrieving match players:', error);
return res.status(500).json({ error: 'Failed to retrieve match players' });
}
};

View File

@@ -171,10 +171,36 @@ export const getMemberActivities = async (req, res) => {
}
}
// Filter: explizite Zuordnungen sollen nur dann zählen, wenn
// - der Participant keine Gruppe hat UND die Aktivität KEINE Gruppenbindung hat, oder
// - die Aktivität keine Gruppenbindung hat, oder
// - es eine Gruppenbindung gibt, die zur Gruppe des Participants passt.
const filteredMemberActivities = memberActivities.filter((ma) => {
if (!ma?.participant || !ma?.activity) {
return false;
}
const participantGroupId = ma.participant.groupId;
const groupActivitiesForActivity = ma.activity.groupActivities || [];
// Participant ohne Gruppe -> nur Aktivitäten ohne Gruppenbindung zählen
if (participantGroupId === null || participantGroupId === undefined) {
return !groupActivitiesForActivity.length;
}
// Keine Gruppenbindung -> immer zählen
if (!groupActivitiesForActivity.length) {
return true;
}
// Gruppenbindung vorhanden -> nur zählen, wenn die Gruppe passt
return groupActivitiesForActivity.some((ga) => Number(ga.groupId) === Number(participantGroupId));
});
// 3. Kombiniere beide Listen und entferne Duplikate
// Ein Duplikat liegt vor, wenn dieselbe Aktivität für denselben Participant bereits explizit zugeordnet ist
const explicitActivityKeys = new Set();
memberActivities.forEach(ma => {
filteredMemberActivities.forEach(ma => {
if (ma.activity && ma.activity.id && ma.participant && ma.participant.id) {
// Erstelle einen eindeutigen Schlüssel: activityId-participantId
const key = `${ma.activity.id}-${ma.participant.id}`;
@@ -192,7 +218,7 @@ export const getMemberActivities = async (req, res) => {
});
// Kombiniere beide Listen
const allActivities = [...memberActivities, ...uniqueGroupActivities];
const allActivities = [...filteredMemberActivities, ...uniqueGroupActivities];
// Group activities by name and count occurrences
// Verwende einen Set pro Aktivität, um eindeutige Datum-Aktivität-Kombinationen zu tracken
@@ -323,6 +349,22 @@ export const getMemberLastParticipations = async (req, res) => {
order: [[{ model: DiaryDateActivity, as: 'activity' }, { model: DiaryDates, as: 'diaryDate' }, 'date', 'DESC']],
limit: parseInt(limit) * 10 // Get more to filter by group
});
// Siehe getMemberActivities(): nur zählen, wenn Gruppenbindung passt (oder keine existiert)
const filteredMemberActivities = memberActivities.filter((ma) => {
if (!ma?.participant || !ma?.activity) {
return false;
}
const participantGroupId = ma.participant.groupId;
const groupActivitiesForActivity = ma.activity.groupActivities || [];
if (!groupActivitiesForActivity.length) {
return true;
}
return groupActivitiesForActivity.some((ga) => Number(ga.groupId) === Number(participantGroupId));
});
// 2. Get all group activities for groups the member belongs to
const groupActivities = [];
@@ -399,7 +441,7 @@ export const getMemberLastParticipations = async (req, res) => {
// 3. Kombiniere beide Listen und entferne Duplikate
// Ein Duplikat liegt vor, wenn dieselbe Aktivität für denselben Participant bereits explizit zugeordnet ist
const explicitActivityKeys = new Set();
memberActivities.forEach(ma => {
filteredMemberActivities.forEach(ma => {
if (ma.activity && ma.activity.id && ma.participant && ma.participant.id) {
// Erstelle einen eindeutigen Schlüssel: activityId-participantId
const key = `${ma.activity.id}-${ma.participant.id}`;
@@ -417,7 +459,7 @@ export const getMemberLastParticipations = async (req, res) => {
});
// Kombiniere beide Listen
const allActivities = [...memberActivities, ...uniqueGroupActivities];
const allActivities = [...filteredMemberActivities, ...uniqueGroupActivities];
// Gruppiere nach Datum
const participationsByDate = new Map();

View File

@@ -1,5 +1,6 @@
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 { devLog } from '../utils/logger.js';
@@ -27,12 +28,12 @@ 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, 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, 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, contacts);
phone, email, active, testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, adultReleaseApproved, adultReserveApproved, contacts);
// Emit Socket-Event wenn Member erfolgreich erstellt/aktualisiert wurde
if (addResult.status === 200) {
@@ -46,6 +47,70 @@ 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;
const { seasonId, lineupHalf } = req.query;
const { authcode: userToken } = req.headers;
const result = await MemberService.getMemberPlayInterests(userToken, Number(clubId), Number(seasonId), String(lineupHalf || ''));
res.status(result.status || 500).json(result.response);
} catch (error) {
console.error('[getMemberPlayInterests] - Error:', error);
res.status(500).json({ error: 'Failed to load member play interests' });
}
};
const setMemberPlayInterest = async (req, res) => {
try {
const { clubId } = req.params;
const { memberId, seasonId, lineupHalf, interested = true } = req.body;
const { authcode: userToken } = req.headers;
const normalizedInterested = interested === true || interested === 'true' || interested === 1 || interested === '1';
const result = await MemberService.setMemberPlayInterest(
userToken,
Number(clubId),
Number(memberId),
Number(seasonId),
String(lineupHalf || ''),
normalizedInterested
);
if (result.status === 200) {
emitMemberChanged(clubId);
}
res.status(result.status || 500).json(result.response);
} catch (error) {
console.error('[setMemberPlayInterest] - Error:', error);
res.status(500).json({ error: 'Failed to save member play interest' });
}
};
const uploadMemberImage = async (req, res) => {
try {
const { clubId, memberId } = req.params;
@@ -92,6 +157,30 @@ const updateRatingsFromMyTischtennis = async (req, res) => {
}
};
const getMemberTtrHistory = async (req, res) => {
try {
const { clubId, memberId } = req.params;
const { authcode: userToken } = req.headers;
const result = await MemberService.getMemberTtrHistory(userToken, clubId, memberId);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[getMemberTtrHistory] - Error:', error);
res.status(500).json({ success: false, error: 'TTR-Historie konnte nicht geladen werden.' });
}
};
const refreshMemberTtrHistory = async (req, res) => {
try {
const { clubId, memberId } = req.params;
const { authcode: userToken } = req.headers;
const result = await MemberService.refreshMemberTtrHistory(userToken, clubId, memberId);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[refreshMemberTtrHistory] - Error:', error);
res.status(500).json({ success: false, error: 'TTR-Historie konnte nicht aktualisiert werden.' });
}
};
const rotateMemberImage = async (req, res) => {
try {
const { clubId, memberId, imageId } = req.params;
@@ -207,6 +296,28 @@ const quickDeactivateMember = async (req, res) => {
}
};
const requestClickTtPlayerRegistration = async (req, res) => {
try {
const { clubId, memberId } = req.params;
const { authcode: userToken } = req.headers;
const userId = req.user?.id;
const result = await clickTtPlayerRegistrationService.submitExistingPlayerApplication({
userToken,
userId,
clubId,
memberId
});
res.status(200).json(result);
} catch (error) {
console.error('[requestClickTtPlayerRegistration] - Error:', error);
res.status(error.statusCode || error.status || 500).json({
success: false,
error: error.message || 'Click-TT-Antrag konnte nicht eingereicht werden',
details: error.details || null
});
}
};
const transferMembers = async (req, res) => {
try {
const { id: clubId } = req.params;
@@ -243,15 +354,22 @@ export {
getClubMembers,
getWaitingApprovals,
setClubMembers,
getMemberSepaMandate,
saveMemberSepaMandate,
getMemberPlayInterests,
setMemberPlayInterest,
uploadMemberImage,
getMemberImage,
updateRatingsFromMyTischtennis,
getMemberTtrHistory,
refreshMemberTtrHistory,
rotateMemberImage,
transferMembers,
quickUpdateTestMembership,
quickUpdateMemberFormHandedOver,
quickDeactivateMember,
requestClickTtPlayerRegistration,
deleteMemberImage,
setPrimaryMemberImage,
generateMemberGallery
};
};

View File

@@ -0,0 +1,65 @@
import memberGroupPhotoService from '../services/memberGroupPhotoService.js';
export const listMemberGroupPhotos = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const photos = await memberGroupPhotoService.list(userToken, clubId);
res.status(200).json({ success: true, photos });
} catch (error) {
console.error('[listMemberGroupPhotos] error:', error);
res.status(500).json({ success: false, error: 'Failed to list group photos' });
}
};
export const createMemberGroupPhoto = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const result = await memberGroupPhotoService.create(userToken, clubId, req.file, req.body);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[createMemberGroupPhoto] error:', error);
res.status(500).json({ success: false, error: 'Failed to save group photo' });
}
};
export const updateMemberGroupPhoto = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, photoId } = req.params;
const result = await memberGroupPhotoService.update(userToken, clubId, photoId, req.body);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[updateMemberGroupPhoto] error:', error);
res.status(500).json({ success: false, error: 'Failed to update group photo' });
}
};
export const deleteMemberGroupPhoto = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, photoId } = req.params;
const result = await memberGroupPhotoService.remove(userToken, clubId, photoId);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[deleteMemberGroupPhoto] error:', error);
res.status(500).json({ success: false, error: 'Failed to delete group photo' });
}
};
export const getMemberGroupPhotoImage = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, photoId } = req.params;
const result = await memberGroupPhotoService.getImage(userToken, clubId, photoId);
if (result.status === 200) {
res.setHeader('Content-Type', 'image/jpeg');
return res.sendFile(result.imagePath);
}
return res.status(result.status).json({ success: false, error: result.error });
} catch (error) {
console.error('[getMemberGroupPhotoImage] error:', error);
res.status(500).json({ success: false, error: 'Failed to load group photo' });
}
};

View File

@@ -0,0 +1,55 @@
import memberOrderService from '../services/memberOrderService.js';
const getMemberOrders = async (req, res) => {
try {
const { clubId, memberId } = req.params;
const { authcode: userToken } = req.headers;
const result = await memberOrderService.getMemberOrders(userToken, clubId, memberId);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[getMemberOrders] - Error:', error);
res.status(500).json({ success: false, error: 'Bestellungen konnten nicht geladen werden.' });
}
};
const createMemberOrder = async (req, res) => {
try {
const { clubId, memberId } = req.params;
const { authcode: userToken } = req.headers;
const result = await memberOrderService.createMemberOrder(userToken, clubId, memberId, req.body || {});
res.status(result.status).json(result.response);
} catch (error) {
console.error('[createMemberOrder] - Error:', error);
res.status(500).json({ success: false, error: 'Bestellung konnte nicht gespeichert werden.' });
}
};
const updateMemberOrder = async (req, res) => {
try {
const { clubId, memberId, orderId } = req.params;
const { authcode: userToken } = req.headers;
const result = await memberOrderService.updateMemberOrder(userToken, clubId, memberId, orderId, req.body || {});
res.status(result.status).json(result.response);
} catch (error) {
console.error('[updateMemberOrder] - Error:', error);
res.status(500).json({ success: false, error: 'Bestellung konnte nicht aktualisiert werden.' });
}
};
const getGlobalOrders = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const result = await memberOrderService.getGlobalOrders(userToken);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[getGlobalOrders] - Error:', error);
res.status(500).json({ success: false, error: 'Bestellübersicht konnte nicht geladen werden.' });
}
};
export {
getMemberOrders,
createMemberOrder,
updateMemberOrder,
getGlobalOrders
};

View File

@@ -0,0 +1,29 @@
import User from '../models/User.js';
import { sendMobileFeedbackEmail } from '../services/emailService.js';
const clean = (value, max = 4000) => String(value ?? '').trim().slice(0, max);
export const sendMobileFeedback = async (req, res) => {
try {
const message = clean(req.body?.message, 5000);
if (!message) {
return res.status(400).json({ error: 'message_required' });
}
const user = req.user?.id ? await User.findByPk(req.user.id) : null;
await sendMobileFeedbackEmail({
message,
screen: clean(req.body?.screen, 200),
clubId: req.body?.clubId ?? null,
appVersion: clean(req.body?.appVersion, 80),
platform: clean(req.body?.platform, 80) || 'Android',
backendBaseUrl: clean(req.body?.backendBaseUrl, 300),
user: user ? { id: user.id, username: user.username, email: user.email } : { id: req.user?.id },
});
return res.status(200).json({ success: true });
} catch (error) {
console.error('[sendMobileFeedback] - error:', error);
return res.status(500).json({ error: 'internalerror' });
}
};

View File

@@ -1,6 +1,9 @@
import myTischtennisService from '../services/myTischtennisService.js';
import myTischtennisSessionService from '../services/myTischtennisSessionService.js';
import myTischtennisProxyService from '../services/myTischtennisProxyService.js';
import HttpError from '../exceptions/HttpError.js';
import axios from 'axios';
import myTischtennisClient from '../clients/myTischtennisClient.js';
class MyTischtennisController {
/**
@@ -36,6 +39,49 @@ class MyTischtennisController {
}
}
/**
* GET /api/mytischtennis/login-form
* Parsed login form data from mytischtennis.de
*/
async getLoginForm(req, res, next) {
try {
const myTischtennisClient = (await import('../clients/myTischtennisClient.js')).default;
const result = await myTischtennisClient.getLoginPage();
if (!result.success) {
throw new HttpError('Login-Formular konnte nicht geladen werden', 502);
}
const publicFields = (result.fields || [])
.filter((field) => ['email', 'password'].includes(field.type) || field.name === 'email' || field.name === 'password')
.map((field) => ({
name: field.name,
id: field.id,
type: field.type,
placeholder: field.placeholder || null,
required: !!field.required,
autocomplete: field.autocomplete || null,
minlength: field.minlength ? Number(field.minlength) : null
}));
res.status(200).json({
success: true,
form: {
action: result.loginAction,
fields: publicFields
},
captcha: {
required: !!result.requiresCaptcha,
siteKey: result.captchaSiteKey || null,
puzzleEndpoint: result.captchaPuzzleEndpoint || null,
solutionField: result.captchaSolutionField || 'captcha'
}
});
} catch (error) {
next(error);
}
}
/**
* POST /api/mytischtennis/account
* Create or update myTischtennis account
@@ -43,7 +89,9 @@ class MyTischtennisController {
async upsertAccount(req, res, next) {
try {
const userId = req.user.id;
const { email, password, savePassword, autoUpdateRatings, userPassword } = req.body;
const { email, password, savePassword, userPassword } = req.body;
const hasAutoUpdateRatings = Object.prototype.hasOwnProperty.call(req.body, 'autoUpdateRatings');
const autoUpdateRatings = hasAutoUpdateRatings ? req.body.autoUpdateRatings : undefined;
if (!email) {
throw new HttpError('E-Mail-Adresse erforderlich', 400);
@@ -59,7 +107,7 @@ class MyTischtennisController {
email,
password,
savePassword || false,
autoUpdateRatings || false,
autoUpdateRatings,
userPassword
);
@@ -226,7 +274,7 @@ class MyTischtennisController {
req.userId = userId;
// Lade die Login-Seite von mytischtennis.de
const response = await axios.get('https://www.mytischtennis.de/login?next=%2F', {
const response = await axios.get(`${myTischtennisProxyService.getOrigin()}/login?next=%2F`, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
@@ -262,6 +310,14 @@ class MyTischtennisController {
/action="\/login/g,
'action="/api/mytischtennis/login-submit'
);
html = myTischtennisProxyService.rewriteContent(html);
// MyTischtennis bootet eine große React-App, die im Proxy-Kontext häufig mit
// Runtime-Fehlern abstürzt ("Da ist etwas schiefgelaufen"). Für den iframe-Login
// reicht die serverseitig gerenderte Form aus; deshalb Bootstrap-Skripte entfernen.
html = html.replace(/<script\b[^>]*type=(?:"|')module(?:"|')[^>]*>[\s\S]*?<\/script>/gi, '');
html = html.replace(/<script\b[^>]*src=(?:"|')[^"']*\/build\/[^"']*(?:"|')[^>]*>\s*<\/script>/gi, '');
html = html.replace(/<link\b[^>]*rel=(?:"|')modulepreload(?:"|')[^>]*>/gi, '');
}
// Setze Content-Type
@@ -275,6 +331,55 @@ class MyTischtennisController {
}
}
/**
* GET /api/mytischtennis/proxy/*
* Same-Origin-Proxy für mytischtennis Build-/Font-/Captcha-Ressourcen
*/
async proxyRemote(req, res, next) {
try {
const proxyPath = req.params[0] || '';
const queryString = new URLSearchParams(req.query || {}).toString();
const targetUrl = `${myTischtennisProxyService.getOrigin()}/${proxyPath}${queryString ? `?${queryString}` : ''}`;
const upstream = await axios.get(targetUrl, {
responseType: 'arraybuffer',
headers: {
'User-Agent': req.headers['user-agent'] || 'Mozilla/5.0',
'Accept': req.headers.accept || '*/*',
'Accept-Language': req.headers['accept-language'] || 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7',
...(req.headers.cookie ? { 'Cookie': req.headers.cookie } : {})
},
validateStatus: () => true
});
// Wichtige Header durchreichen
const passthroughHeaders = ['content-type', 'cache-control', 'etag', 'last-modified', 'expires'];
for (const headerName of passthroughHeaders) {
const value = upstream.headers[headerName];
if (value) {
res.setHeader(headerName, value);
}
}
if (upstream.headers['set-cookie']) {
res.setHeader('Set-Cookie', upstream.headers['set-cookie']);
}
const contentType = String(upstream.headers['content-type'] || '').toLowerCase();
const isTextLike = /(text\/|javascript|json|xml|svg)/.test(contentType);
if (isTextLike) {
const asText = Buffer.from(upstream.data).toString('utf-8');
const rewritten = myTischtennisProxyService.rewriteContent(asText);
return res.status(upstream.status).send(rewritten);
}
return res.status(upstream.status).send(upstream.data);
} catch (error) {
console.error('Fehler beim Proxy von mytischtennis-Ressourcen:', error.message);
next(error);
}
}
/**
* POST /api/mytischtennis/login-submit
* Proxy für Login-Form-Submission
@@ -300,14 +405,81 @@ class MyTischtennisController {
if (req.body.__token) {
delete req.body.__token;
}
// Hole Cookies aus dem Request
// Hole Cookies aus dem Request (wird auch für CAPTCHA-Fallback benötigt)
const cookies = req.headers.cookie || '';
// Normalisiere Payload
const payload = { ...(req.body || {}) };
const mask = (v) => (typeof v === 'string' && v.length > 12 ? `${v.slice(0, 12)}...(${v.length})` : v);
// Falls captcha im Browser-Kontext nicht gesetzt wurde, versuche serverseitigen Fallback
if (!payload.captcha) {
try {
const loginPageResponse = await axios.get('https://www.mytischtennis.de/login?next=%2F', {
headers: {
'Cookie': cookies,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7',
'Referer': 'https://www.mytischtennis.de/'
},
validateStatus: () => true
});
const html = typeof loginPageResponse.data === 'string' ? loginPageResponse.data : '';
const siteKeyMatch = html.match(/data-sitekey=(?:"([^"]+)"|'([^']+)')/i);
const puzzleEndpointMatch = html.match(/data-puzzle-endpoint=(?:"([^"]+)"|'([^']+)')/i);
const siteKey = siteKeyMatch ? (siteKeyMatch[1] || siteKeyMatch[2]) : null;
const puzzleEndpoint = puzzleEndpointMatch ? (puzzleEndpointMatch[1] || puzzleEndpointMatch[2]) : null;
if (siteKey && puzzleEndpoint) {
const puzzleResponse = await axios.get(`${puzzleEndpoint}?sitekey=${encodeURIComponent(siteKey)}`, {
headers: {
'Cookie': cookies,
'Accept': '*/*',
'Origin': 'https://www.mytischtennis.de',
'Referer': 'https://www.mytischtennis.de/'
},
validateStatus: () => true
});
if (puzzleResponse.status === 200 && typeof puzzleResponse.data === 'string' && puzzleResponse.data.trim()) {
payload.captcha = puzzleResponse.data.trim();
payload.captcha_clicked = 'true';
}
}
} catch (captchaFallbackError) {
console.warn('[submitLogin] CAPTCHA-Fallback fehlgeschlagen:', captchaFallbackError.message);
}
}
// Wenn captcha vorhanden ist, als bestätigt markieren
if (payload.captcha && !payload.captcha_clicked) {
payload.captcha_clicked = 'true';
}
console.log('[submitLogin] Incoming payload fields:', {
keys: Object.keys(payload),
hasEmail: !!payload.email,
hasPassword: !!payload.password,
xsrf: mask(payload.xsrf),
captchaClicked: payload.captcha_clicked,
captcha: mask(payload.captcha)
});
// Form-Daten sauber als x-www-form-urlencoded serialisieren
const formData = new URLSearchParams();
for (const [key, value] of Object.entries(payload)) {
if (value !== undefined && value !== null) {
formData.append(key, String(value));
}
}
// Leite den Login-Request an mytischtennis.de weiter
const response = await axios.post(
'https://www.mytischtennis.de/login?next=%2F&_data=routes%2F_auth%2B%2Flogin',
req.body, // Form-Daten
formData.toString(),
{
headers: {
'Cookie': cookies,
@@ -321,6 +493,34 @@ class MyTischtennisController {
}
);
console.log('[submitLogin] Upstream response:', {
status: response.status,
hasSetCookie: Array.isArray(response.headers['set-cookie']) && response.headers['set-cookie'].length > 0,
bodyPreview: typeof response.data === 'string'
? response.data.slice(0, 220)
: JSON.stringify(response.data || {}).slice(0, 220)
});
// Falls CAPTCHA-Bestätigung im Proxy-Flow fehlschlägt:
// Fallback auf echten Browser-Login (Playwright), dann Session direkt speichern.
const upstreamBody = typeof response.data === 'string' ? response.data : JSON.stringify(response.data || {});
const isCaptchaFailure = response.status === 400
&& (upstreamBody.includes('Captcha-Bestätigung fehlgeschlagen') || upstreamBody.includes('Captcha-Bestätigung ist erforderlich'));
if (isCaptchaFailure && userId && payload.email && payload.password) {
console.log('[submitLogin] CAPTCHA-Fehler erkannt, starte Playwright-Fallback...');
const browserLogin = await myTischtennisClient.loginWithBrowserAutomation(payload.email, payload.password);
if (browserLogin.success && browserLogin.cookie) {
await myTischtennisSessionService.saveSessionFromCookie(userId, browserLogin.cookie);
return res.status(200).send(
'<!doctype html><html><body><p>Login erfolgreich. Fenster kann geschlossen werden.</p></body></html>'
);
}
console.warn('[submitLogin] Playwright-Fallback fehlgeschlagen:', browserLogin.error);
}
// Setze Cookies aus der Response
const setCookieHeaders = response.headers['set-cookie'];
if (setCookieHeaders) {
@@ -339,7 +539,7 @@ class MyTischtennisController {
const authCookie = setCookieHeaders?.find(cookie => cookie.startsWith('sb-10-auth-token='));
if (authCookie && userId) {
// Login erfolgreich - speichere Session (nur wenn userId vorhanden)
await this.saveSessionFromCookie(userId, authCookie);
await myTischtennisSessionService.saveSessionFromCookie(userId, authCookie);
}
// Sende Response weiter
@@ -350,49 +550,6 @@ class MyTischtennisController {
}
}
/**
* Speichere Session-Daten aus Cookie
*/
async saveSessionFromCookie(userId, cookieString) {
try {
const tokenMatch = cookieString.match(/sb-10-auth-token=base64-([^;]+)/);
if (!tokenMatch) {
throw new Error('Token-Format ungültig');
}
const base64Token = tokenMatch[1];
const decodedToken = Buffer.from(base64Token, 'base64').toString('utf-8');
const tokenData = JSON.parse(decodedToken);
const MyTischtennis = (await import('../models/MyTischtennis.js')).default;
const myTischtennisAccount = await MyTischtennis.findOne({ where: { userId } });
if (myTischtennisAccount) {
myTischtennisAccount.accessToken = tokenData.access_token;
myTischtennisAccount.refreshToken = tokenData.refresh_token;
myTischtennisAccount.expiresAt = tokenData.expires_at;
myTischtennisAccount.cookie = cookieString.split(';')[0].trim();
myTischtennisAccount.userData = tokenData.user;
myTischtennisAccount.lastLoginSuccess = new Date();
myTischtennisAccount.lastLoginAttempt = new Date();
// Hole Club-Informationen
const myTischtennisClient = (await import('../clients/myTischtennisClient.js')).default;
const profileResult = await myTischtennisClient.getUserProfile(myTischtennisAccount.cookie);
if (profileResult.success) {
myTischtennisAccount.clubId = profileResult.clubId;
myTischtennisAccount.clubName = profileResult.clubName;
myTischtennisAccount.fedNickname = profileResult.fedNickname;
}
await myTischtennisAccount.save();
}
} catch (error) {
console.error('Fehler beim Speichern der Session:', error);
throw error;
}
}
/**
* POST /api/mytischtennis/extract-session
* Extrahiere Session nach Login im iframe

View File

@@ -3,14 +3,132 @@ import myTischtennisService from '../services/myTischtennisService.js';
import MemberService from '../services/memberService.js';
import autoFetchMatchResultsService from '../services/autoFetchMatchResultsService.js';
import apiLogService from '../services/apiLogService.js';
import axios from 'axios';
import ClubTeam from '../models/ClubTeam.js';
import League from '../models/League.js';
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 { randomUUID } from 'crypto';
const teamDataFetchJobs = new Map();
const TEAM_DATA_JOB_TTL_MS = 60 * 60 * 1000;
const cleanupFinishedTeamDataJobs = () => {
const now = Date.now();
for (const [jobId, job] of teamDataFetchJobs.entries()) {
if (job.finishedAt && (now - job.finishedAt) > TEAM_DATA_JOB_TTL_MS) {
teamDataFetchJobs.delete(jobId);
}
}
};
class MyTischtennisUrlController {
async startFetchTeamDataJob(req, res, next) {
try {
const { clubTeamId } = req.body || {};
if (!clubTeamId) {
throw new HttpError('clubTeamId is required', 400);
}
cleanupFinishedTeamDataJobs();
const jobId = randomUUID();
const startedAt = Date.now();
teamDataFetchJobs.set(jobId, {
jobId,
status: 'queued',
startedAt,
updatedAt: startedAt,
finishedAt: null,
clubTeamId,
result: null,
error: null
});
const authHeaders = {
authcode: req.headers.authcode,
userid: req.headers.userid
};
const internalPort = process.env.PORT || 3050;
const internalUrl = `http://127.0.0.1:${internalPort}/api/mytischtennis/fetch-team-data`;
// Background execution; response is returned immediately.
(async () => {
const job = teamDataFetchJobs.get(jobId);
if (!job) return;
job.status = 'running';
job.updatedAt = Date.now();
try {
const response = await axios.post(
internalUrl,
{ clubTeamId },
{
headers: authHeaders,
timeout: 10 * 60 * 1000,
validateStatus: () => true
}
);
if (response.status >= 200 && response.status < 300 && response.data?.success) {
job.status = 'completed';
job.result = response.data;
job.error = null;
} else {
job.status = 'failed';
job.result = null;
job.error = response.data?.error || response.data?.message || `Job failed with status ${response.status}`;
}
} catch (error) {
job.status = 'failed';
job.result = null;
job.error = error?.message || String(error);
} finally {
job.updatedAt = Date.now();
job.finishedAt = Date.now();
}
})();
return res.status(202).json({
success: true,
jobId,
status: 'queued'
});
} catch (error) {
next(error);
}
}
async getFetchTeamDataJobStatus(req, res, next) {
try {
const { jobId } = req.params;
cleanupFinishedTeamDataJobs();
const job = teamDataFetchJobs.get(jobId);
if (!job) {
throw new HttpError('Job not found', 404);
}
return res.status(200).json({
success: true,
job: {
jobId: job.jobId,
status: job.status,
startedAt: job.startedAt,
updatedAt: job.updatedAt,
finishedAt: job.finishedAt,
clubTeamId: job.clubTeamId,
result: job.result,
error: job.error
}
});
} catch (error) {
next(error);
}
}
/**
* Parse myTischtennis URL and return configuration data
* POST /api/mytischtennis/parse-url

View File

@@ -1,16 +1,21 @@
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const pdfParse = require('pdf-parse/lib/pdf-parse.js');
import { checkAccess } from '../utils/userUtils.js';
import OfficialTournament from '../models/OfficialTournament.js';
import OfficialCompetition from '../models/OfficialCompetition.js';
import OfficialCompetitionMember from '../models/OfficialCompetitionMember.js';
import Member from '../models/Member.js';
import { Op } from 'sequelize';
import officialTournamentService from '../services/officialTournamentService.js';
import clickTtTournamentRegistrationService from '../services/clickTtTournamentRegistrationService.js';
// In-Memory Store (einfacher Start); später DB-Modell
const parsedTournaments = new Map(); // key: id, value: { id, clubId, rawText, parsedData }
let seq = 1;
export const updateOfficialTournament = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, id } = req.params;
await checkAccess(userToken, clubId);
const result = await officialTournamentService.updateOfficialTournament(clubId, id, req.body);
if (!result) return res.status(404).json({ error: 'not found' });
res.status(200).json(result);
} catch (e) {
console.error('[updateOfficialTournament] Error:', e);
res.status(500).json({ error: 'Failed to update tournament' });
}
};
export const uploadTournamentPdf = async (req, res) => {
try {
@@ -18,45 +23,9 @@ export const uploadTournamentPdf = async (req, res) => {
const { clubId } = req.params;
await checkAccess(userToken, clubId);
if (!req.file || !req.file.buffer) return res.status(400).json({ error: 'No pdf provided' });
const data = await pdfParse(req.file.buffer);
const parsed = parseTournamentText(data.text);
const t = await OfficialTournament.create({
clubId,
title: parsed.title || null,
eventDate: parsed.termin || null,
organizer: null,
host: null,
venues: JSON.stringify(parsed.austragungsorte || []),
competitionTypes: JSON.stringify(parsed.konkurrenztypen || []),
registrationDeadlines: JSON.stringify(parsed.meldeschluesse || []),
entryFees: JSON.stringify(parsed.entryFees || {}),
});
// competitions persistieren
for (const c of parsed.competitions || []) {
// Korrigiere Fehlzuordnung: Wenn die Zeile mit "Stichtag" fälschlich in performanceClass steht
let performanceClass = c.leistungsklasse || c.performanceClass || null;
let cutoffDate = c.stichtag || c.cutoffDate || null;
if (performanceClass && /^stichtag\b/i.test(performanceClass)) {
cutoffDate = performanceClass.replace(/^stichtag\s*:?\s*/i, '').trim();
performanceClass = null;
}
await OfficialCompetition.create({
tournamentId: t.id,
ageClassCompetition: c.altersklasseWettbewerb || c.ageClassCompetition || null,
performanceClass,
startTime: c.startzeit || c.startTime || null,
registrationDeadlineDate: c.meldeschlussDatum || c.registrationDeadlineDate || null,
registrationDeadlineOnline: c.meldeschlussOnline || c.registrationDeadlineOnline || null,
cutoffDate,
ttrRelevant: c.ttrRelevant || null,
openTo: c.offenFuer || c.openTo || null,
preliminaryRound: c.vorrunde || c.preliminaryRound || null,
finalRound: c.endrunde || c.finalRound || null,
maxParticipants: c.maxTeilnehmer || c.maxParticipants || null,
entryFee: c.startgeld || c.entryFee || null,
});
}
res.status(201).json({ id: String(t.id) });
const result = await officialTournamentService.uploadTournamentPdf(clubId, req.file.buffer);
res.status(201).json(result);
} catch (e) {
console.error('[uploadTournamentPdf] Error:', e);
res.status(500).json({ error: 'Failed to parse pdf' });
@@ -68,64 +37,10 @@ export const getParsedTournament = async (req, res) => {
const { authcode: userToken } = req.headers;
const { clubId, id } = req.params;
await checkAccess(userToken, clubId);
const t = await OfficialTournament.findOne({ where: { id, clubId } });
if (!t) return res.status(404).json({ error: 'not found' });
const comps = await OfficialCompetition.findAll({ where: { tournamentId: id } });
const entries = await OfficialCompetitionMember.findAll({ where: { tournamentId: id } });
const competitions = comps.map((c) => {
const j = c.toJSON();
return {
id: j.id,
tournamentId: j.tournamentId,
ageClassCompetition: j.ageClassCompetition || null,
performanceClass: j.performanceClass || null,
startTime: j.startTime || null,
registrationDeadlineDate: j.registrationDeadlineDate || null,
registrationDeadlineOnline: j.registrationDeadlineOnline || null,
cutoffDate: j.cutoffDate || null,
ttrRelevant: j.ttrRelevant || null,
openTo: j.openTo || null,
preliminaryRound: j.preliminaryRound || null,
finalRound: j.finalRound || null,
maxParticipants: j.maxParticipants || null,
entryFee: j.entryFee || null,
// Legacy Felder zusätzlich, falls Frontend sie noch nutzt
altersklasseWettbewerb: j.ageClassCompetition || null,
leistungsklasse: j.performanceClass || null,
startzeit: j.startTime || null,
meldeschlussDatum: j.registrationDeadlineDate || null,
meldeschlussOnline: j.registrationDeadlineOnline || null,
stichtag: j.cutoffDate || null,
offenFuer: j.openTo || null,
vorrunde: j.preliminaryRound || null,
endrunde: j.finalRound || null,
maxTeilnehmer: j.maxParticipants || null,
startgeld: j.entryFee || null,
};
});
res.status(200).json({
id: String(t.id),
clubId: String(t.clubId),
parsedData: {
title: t.title,
termin: t.eventDate,
austragungsorte: JSON.parse(t.venues || '[]'),
konkurrenztypen: JSON.parse(t.competitionTypes || '[]'),
meldeschluesse: JSON.parse(t.registrationDeadlines || '[]'),
entryFees: JSON.parse(t.entryFees || '{}'),
competitions,
},
participation: entries.map(e => ({
id: e.id,
tournamentId: e.tournamentId,
competitionId: e.competitionId,
memberId: e.memberId,
wants: !!e.wants,
registered: !!e.registered,
participated: !!e.participated,
placement: e.placement || null,
})),
});
const result = await officialTournamentService.getParsedTournament(clubId, id);
if (!result) return res.status(404).json({ error: 'not found' });
res.status(200).json(result);
} catch (e) {
res.status(500).json({ error: 'Failed to fetch parsed tournament' });
}
@@ -134,30 +49,14 @@ export const getParsedTournament = async (req, res) => {
export const upsertCompetitionMember = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, id } = req.params; // id = tournamentId
const { clubId, id } = req.params;
await checkAccess(userToken, clubId);
const { competitionId, memberId, wants, registered, participated, placement } = req.body;
if (!competitionId || !memberId) return res.status(400).json({ error: 'competitionId and memberId required' });
const [row] = await OfficialCompetitionMember.findOrCreate({
where: { competitionId, memberId },
defaults: {
tournamentId: id,
competitionId,
memberId,
wants: !!wants,
registered: !!registered,
participated: !!participated,
placement: placement || null,
}
});
row.wants = wants !== undefined ? !!wants : row.wants;
row.registered = registered !== undefined ? !!registered : row.registered;
row.participated = participated !== undefined ? !!participated : row.participated;
if (placement !== undefined) row.placement = placement;
await row.save();
return res.status(200).json({ success: true, id: row.id });
const result = await officialTournamentService.upsertCompetitionMember(id, req.body);
return res.status(200).json(result);
} catch (e) {
console.error('[upsertCompetitionMember] Error:', e);
if (e?.status) return res.status(e.status).json({ error: e.message });
res.status(500).json({ error: 'Failed to save participation' });
}
};
@@ -165,64 +64,14 @@ export const upsertCompetitionMember = async (req, res) => {
export const updateParticipantStatus = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, id } = req.params; // id = tournamentId
const { clubId, id } = req.params;
await checkAccess(userToken, clubId);
const { competitionId, memberId, action } = req.body;
if (!competitionId || !memberId || !action) {
return res.status(400).json({ error: 'competitionId, memberId and action required' });
}
const [row] = await OfficialCompetitionMember.findOrCreate({
where: { competitionId, memberId },
defaults: {
tournamentId: id,
competitionId,
memberId,
wants: false,
registered: false,
participated: false,
placement: null,
}
});
// Status-Update basierend auf Aktion
switch (action) {
case 'register':
// Von "möchte teilnehmen" zu "angemeldet"
row.wants = true;
row.registered = true;
row.participated = false;
break;
case 'participate':
// Von "angemeldet" zu "hat gespielt"
row.wants = true;
row.registered = true;
row.participated = true;
break;
case 'reset':
// Zurück zu "möchte teilnehmen"
row.wants = true;
row.registered = false;
row.participated = false;
break;
default:
return res.status(400).json({ error: 'Invalid action. Use: register, participate, or reset' });
}
await row.save();
return res.status(200).json({
success: true,
id: row.id,
status: {
wants: row.wants,
registered: row.registered,
participated: row.participated,
placement: row.placement
}
});
const result = await officialTournamentService.updateParticipantStatus(id, req.body);
return res.status(200).json(result);
} catch (e) {
console.error('[updateParticipantStatus] Error:', e);
if (e?.status) return res.status(e.status).json({ error: e.message });
res.status(500).json({ error: 'Failed to update participant status' });
}
};
@@ -232,8 +81,9 @@ export const listOfficialTournaments = async (req, res) => {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
await checkAccess(userToken, clubId);
const list = await OfficialTournament.findAll({ where: { clubId } });
res.status(200).json(Array.isArray(list) ? list : []);
const list = await officialTournamentService.listOfficialTournaments(clubId);
res.status(200).json(list);
} catch (e) {
console.error('[listOfficialTournaments] Error:', e);
const errorMessage = e.message || 'Failed to list tournaments';
@@ -246,99 +96,8 @@ export const listClubParticipations = async (req, res) => {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
await checkAccess(userToken, clubId);
const tournaments = await OfficialTournament.findAll({ where: { clubId } });
if (!tournaments || tournaments.length === 0) return res.status(200).json([]);
const tournamentIds = tournaments.map(t => t.id);
const rows = await OfficialCompetitionMember.findAll({
where: { tournamentId: { [Op.in]: tournamentIds }, participated: true },
include: [
{ model: OfficialCompetition, as: 'competition', attributes: ['id', 'tournamentId', 'ageClassCompetition', 'startTime'] },
{ model: OfficialTournament, as: 'tournament', attributes: ['id', 'title', 'eventDate'] },
{ model: Member, as: 'member', attributes: ['id', 'firstName', 'lastName'] },
]
});
const parseDmy = (s) => {
if (!s) return null;
const m = String(s).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
if (!m) return null;
const d = new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1]));
return isNaN(d.getTime()) ? null : d;
};
const fmtDmy = (d) => {
const dd = String(d.getDate()).padStart(2, '0');
const mm = String(d.getMonth() + 1).padStart(2, '0');
const yyyy = d.getFullYear();
return `${dd}.${mm}.${yyyy}`;
};
const byTournament = new Map();
for (const r of rows) {
const t = r.tournament;
const c = r.competition;
const m = r.member;
if (!t || !c || !m) continue;
if (!byTournament.has(t.id)) {
byTournament.set(t.id, {
tournamentId: String(t.id),
title: t.title || null,
startDate: null,
endDate: null,
entries: [],
_dates: [],
_eventDate: t.eventDate || null,
});
}
const bucket = byTournament.get(t.id);
const compDate = parseDmy(c.startTime || '') || null;
if (compDate) bucket._dates.push(compDate);
bucket.entries.push({
memberId: m.id,
memberName: `${m.firstName || ''} ${m.lastName || ''}`.trim(),
competitionId: c.id,
competitionName: c.ageClassCompetition || '',
placement: r.placement || null,
date: compDate ? fmtDmy(compDate) : null,
});
}
const out = [];
for (const t of tournaments) {
const bucket = byTournament.get(t.id) || {
tournamentId: String(t.id),
title: t.title || null,
startDate: null,
endDate: null,
entries: [],
_dates: [],
_eventDate: t.eventDate || null,
};
// Ableiten Start/Ende
if (bucket._dates.length) {
bucket._dates.sort((a, b) => a - b);
bucket.startDate = fmtDmy(bucket._dates[0]);
bucket.endDate = fmtDmy(bucket._dates[bucket._dates.length - 1]);
} else if (bucket._eventDate) {
const all = String(bucket._eventDate).match(/(\d{1,2}\.\d{1,2}\.\d{4})/g) || [];
if (all.length >= 1) {
const d1 = parseDmy(all[0]);
const d2 = all.length >= 2 ? parseDmy(all[1]) : d1;
if (d1) bucket.startDate = fmtDmy(d1);
if (d2) bucket.endDate = fmtDmy(d2);
}
}
// Sort entries: Mitglied, dann Konkurrenz
bucket.entries.sort((a, b) => {
const mcmp = (a.memberName || '').localeCompare(b.memberName || '', 'de', { sensitivity: 'base' });
if (mcmp !== 0) return mcmp;
return (a.competitionName || '').localeCompare(b.competitionName || '', 'de', { sensitivity: 'base' });
});
delete bucket._dates;
delete bucket._eventDate;
out.push(bucket);
}
const out = await officialTournamentService.listClubParticipations(clubId);
res.status(200).json(out);
} catch (e) {
res.status(500).json({ error: 'Failed to list club participations' });
@@ -350,272 +109,34 @@ export const deleteOfficialTournament = async (req, res) => {
const { authcode: userToken } = req.headers;
const { clubId, id } = req.params;
await checkAccess(userToken, clubId);
const t = await OfficialTournament.findOne({ where: { id, clubId } });
if (!t) return res.status(404).json({ error: 'not found' });
await OfficialCompetition.destroy({ where: { tournamentId: id } });
await OfficialTournament.destroy({ where: { id } });
const deleted = await officialTournamentService.deleteOfficialTournament(clubId, id);
if (!deleted) return res.status(404).json({ error: 'not found' });
res.status(204).send();
} catch (e) {
res.status(500).json({ error: 'Failed to delete tournament' });
}
};
function parseTournamentText(text) {
const lines = text.split(/\r?\n/);
const normLines = lines.map(l => l.replace(/\s+/g, ' ').trim());
export const autoRegisterOfficialTournamentParticipants = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, id: tournamentId } = req.params;
const userId = req.user?.id;
const findTitle = () => {
const idx = normLines.findIndex(l => /Kreiseinzelmeisterschaften/i.test(l));
return idx >= 0 ? normLines[idx] : null;
};
const result = await clickTtTournamentRegistrationService.autoRegisterPendingParticipants({
userToken,
userId,
clubId,
tournamentId
});
// Neue Funktion: Teilnahmegebühren pro Spielklasse extrahieren
const extractEntryFees = () => {
const entryFees = {};
// Verschiedene Patterns für Teilnahmegebühren suchen
const feePatterns = [
// Pattern 1: "Startgeld: U12: 5€, U14: 7€, U16: 10€"
/startgeld\s*:?\s*(.+)/i,
// Pattern 2: "Teilnahmegebühr: U12: 5€, U14: 7€"
/teilnahmegebühr\s*:?\s*(.+)/i,
// Pattern 3: "Gebühr: U12: 5€, U14: 7€"
/gebühr\s*:?\s*(.+)/i,
// Pattern 4: "Einschreibegebühr: U12: 5€, U14: 7€"
/einschreibegebühr\s*:?\s*(.+)/i,
// Pattern 5: "Anmeldegebühr: U12: 5€, U14: 7€"
/anmeldegebühr\s*:?\s*(.+)/i
];
for (const pattern of feePatterns) {
for (let i = 0; i < normLines.length; i++) {
const line = normLines[i];
const match = line.match(pattern);
if (match) {
const feeText = match[1];
// Extrahiere Gebühren aus dem Text
// Unterstützt verschiedene Formate:
// "U12: 5€, U14: 7€, U16: 10€"
// "U12: 5 Euro, U14: 7 Euro"
// "U12 5€, U14 7€"
// "U12: 5,00€, U14: 7,00€"
const feeMatches = feeText.matchAll(/(U\d+|AK\s*\d+)\s*:?\s*(\d+(?:[,.]\d+)?)\s*(?:€|Euro|EUR)?/gi);
for (const feeMatch of feeMatches) {
const ageClass = feeMatch[1].toUpperCase().replace(/\s+/g, '');
const amount = feeMatch[2].replace(',', '.');
const numericAmount = parseFloat(amount);
if (!isNaN(numericAmount)) {
entryFees[ageClass] = {
amount: numericAmount,
currency: '€',
rawText: feeMatch[0]
};
}
}
// Wenn wir Gebühren gefunden haben, brechen wir ab
if (Object.keys(entryFees).length > 0) {
break;
}
}
}
if (Object.keys(entryFees).length > 0) {
break;
}
}
return entryFees;
};
const extractBlockAfter = (labels, multiline = false) => {
const idx = normLines.findIndex(l => labels.some(lb => l.toLowerCase().startsWith(lb)));
if (idx === -1) return multiline ? [] : null;
const line = normLines[idx];
const afterColon = line.includes(':') ? line.split(':').slice(1).join(':').trim() : '';
if (!multiline) {
if (afterColon) return afterColon;
// sonst nächste nicht-leere Zeile
for (let i = idx + 1; i < normLines.length; i++) {
if (normLines[i]) return normLines[i];
}
return null;
}
// multiline bis zur nächsten Leerzeile oder nächsten bekannten Section
const out = [];
if (afterColon) out.push(afterColon);
for (let i = idx + 1; i < normLines.length; i++) {
const ln = normLines[i];
if (!ln) break;
if (/^(termin|austragungsort|austragungsorte|konkurrenz|konkurrenzen|konkurrenztypen|meldeschluss|altersklassen|startzeiten)/i.test(ln)) break;
out.push(ln);
}
return out;
};
const extractAllMatches = (regex) => {
const results = [];
for (const l of normLines) {
const m = l.match(regex);
if (m) results.push(m);
}
return results;
};
const title = findTitle();
const termin = extractBlockAfter(['termin', 'termin '], false);
const austragungsorte = extractBlockAfter(['austragungsort', 'austragungsorte'], true);
let konkurrenzRaw = extractBlockAfter(['konkurrenz', 'konkurrenzen', 'konkurrenztypen'], true);
if (konkurrenzRaw && !Array.isArray(konkurrenzRaw)) konkurrenzRaw = [konkurrenzRaw];
const konkurrenztypen = (konkurrenzRaw || []).flatMap(l => l.split(/[;,]/)).map(s => s.trim()).filter(Boolean);
// Meldeschlüsse mit Position und Zuordnung zu AK ermitteln
const meldeschluesseRaw = [];
for (let i = 0; i < normLines.length; i++) {
const l = normLines[i];
const m = l.match(/meldeschluss\s*:?\s*(.+)$/i);
if (m) meldeschluesseRaw.push({ line: i, value: m[1].trim() });
res.status(200).json(result);
} catch (e) {
console.error('[autoRegisterOfficialTournamentParticipants] Error:', e);
res.status(e.statusCode || e.status || 500).json({
success: false,
error: e.message || 'Teilnehmer konnten nicht automatisch in click-TT angemeldet werden'
});
}
let altersRaw = extractBlockAfter(['altersklassen', 'altersklasse'], true);
if (altersRaw && !Array.isArray(altersRaw)) altersRaw = [altersRaw];
const altersklassen = (altersRaw || []).flatMap(l => l.split(/[;,]/)).map(s => s.trim()).filter(Boolean);
// Wettbewerbe/Konkurrenzen parsen (Block ab "3. Konkurrenzen")
const competitions = [];
const konkIdx = normLines.findIndex(l => /^\s*3\.?\s+Konkurrenzen/i.test(l) || /^Konkurrenzen\b/i.test(l));
// Bestimme Start-Sektionsnummer (z. B. 3 bei "3. Konkurrenzen"), fallback 3
const startSectionNum = (() => {
if (konkIdx === -1) return 3;
const m = normLines[konkIdx].match(/^\s*(\d+)\./);
return m ? parseInt(m[1], 10) : 3;
})();
const nextSectionIdx = () => {
for (let i = konkIdx + 1; i < normLines.length; i++) {
const m = normLines[i].match(/^\s*(\d+)\.\s+/);
if (m) {
const num = parseInt(m[1], 10);
if (!Number.isNaN(num) && num > startSectionNum) return i;
}
// Hinweis: Seitenfußzeilen wie "nu.Dokument ..." ignorieren wir, damit mehrseitige Blöcke nicht abbrechen
}
return normLines.length;
};
if (konkIdx !== -1) {
const endIdx = nextSectionIdx();
let i = konkIdx + 1;
while (i < endIdx) {
const line = normLines[i];
if (/^Altersklasse\/Wettbewerb\s*:/i.test(line)) {
const comp = {};
comp.altersklasseWettbewerb = line.split(':').slice(1).join(':').trim();
i++;
while (i < endIdx && !/^Altersklasse\/Wettbewerb\s*:/i.test(normLines[i])) {
const ln = normLines[i];
const m = ln.match(/^([^:]+):\s*(.*)$/);
if (m) {
const key = m[1].trim().toLowerCase();
const val = m[2].trim();
if (key.startsWith('leistungsklasse')) comp.leistungsklasse = val;
else if (key === 'startzeit') {
// Erwartet: 20.09.2025 13:30 Uhr -> wir extrahieren Datum+Zeit
const sm = val.match(/(\d{2}\.\d{2}\.\d{4})\s+(\d{1,2}:\d{2})/);
comp.startzeit = sm ? `${sm[1]} ${sm[2]}` : val;
}
else if (key.startsWith('meldeschluss datum')) comp.meldeschlussDatum = val;
else if (key.startsWith('meldeschluss online')) comp.meldeschlussOnline = val;
else if (key === 'stichtag') comp.stichtag = val;
else if (key === 'ttr-relevant') comp.ttrRelevant = val;
else if (key === 'offen für') comp.offenFuer = val;
else if (key.startsWith('austragungssys. vorrunde')) comp.vorrunde = val;
else if (key.startsWith('austragungssys. endrunde')) comp.endrunde = val;
else if (key.startsWith('max. teilnehmerzahl')) comp.maxTeilnehmer = val;
else if (key === 'startgeld') {
comp.startgeld = val;
// Versuche auch spezifische Gebühren für diese Altersklasse zu extrahieren
const ageClassMatch = comp.altersklasseWettbewerb?.match(/(U\d+|AK\s*\d+)/i);
if (ageClassMatch) {
const ageClass = ageClassMatch[1].toUpperCase().replace(/\s+/g, '');
const feeMatch = val.match(/(\d+(?:[,.]\d+)?)\s*(?:€|Euro|EUR)?/);
if (feeMatch) {
const amount = feeMatch[1].replace(',', '.');
const numericAmount = parseFloat(amount);
if (!isNaN(numericAmount)) {
comp.entryFeeDetails = {
amount: numericAmount,
currency: '€',
ageClass: ageClass
};
}
}
}
}
}
i++;
}
competitions.push(comp);
continue; // schon auf nächster Zeile
}
i++;
}
}
// Altersklassen-Positionen im Text (zur Zuordnung von Meldeschlüssen)
const akPositions = [];
for (let i = 0; i < normLines.length; i++) {
const l = normLines[i];
const m = l.match(/\b(U\d+|AK\s*\d+)\b/i);
if (m) akPositions.push({ line: i, ak: m[1].toUpperCase().replace(/\s+/g, '') });
}
const meldeschluesseByAk = {};
for (const ms of meldeschluesseRaw) {
// Nächste AK im Umkreis von 3 Zeilen suchen
let best = null;
let bestDist = Infinity;
for (const ak of akPositions) {
const dist = Math.abs(ak.line - ms.line);
if (dist < bestDist && dist <= 3) { best = ak; bestDist = dist; }
}
if (best) {
if (!meldeschluesseByAk[best.ak]) meldeschluesseByAk[best.ak] = new Set();
meldeschluesseByAk[best.ak].add(ms.value);
}
}
// Dedup global
const meldeschluesse = Array.from(new Set(meldeschluesseRaw.map(x => x.value)));
// Sets zu Arrays
const meldeschluesseByAkOut = Object.fromEntries(Object.entries(meldeschluesseByAk).map(([k,v]) => [k, Array.from(v)]));
// Vorhandene einfache Personenerkennung (optional, zu Analysezwecken)
const entries = [];
for (const l of normLines) {
const m = l.match(/^([A-Za-zÄÖÜäöüß\-\s']{3,})(?:\s+\((m|w|d)\))?$/i);
if (m && /\s/.test(m[1])) {
entries.push({ name: m[1].trim(), genderHint: m[2] || null });
}
}
// Extrahiere Teilnahmegebühren
const entryFees = extractEntryFees();
return {
title,
termin,
austragungsorte,
konkurrenztypen,
meldeschluesse,
meldeschluesseByAk: meldeschluesseByAkOut,
altersklassen,
startzeiten: {},
competitions,
entries,
entryFees, // Neue: Teilnahmegebühren pro Spielklasse
debug: { normLines },
};
}
};

View File

@@ -1,13 +1,17 @@
import Participant from '../models/Participant.js';
import DiaryDates from '../models/DiaryDates.js';
import DiaryMemberActivity from '../models/DiaryMemberActivity.js';
import { devLog } from '../utils/logger.js';
import { emitParticipantAdded, emitParticipantRemoved, emitParticipantUpdated } from '../services/socketService.js';
const PARTICIPANT_ATTRIBUTES = ['id', 'diaryDateId', 'memberId', 'attendanceStatus', 'groupId', 'notes', 'createdAt', 'updatedAt'];
export const getParticipants = async (req, res) => {
try {
const { dateId } = req.params;
const participants = await Participant.findAll({
where: { diaryDateId: dateId },
attributes: ['id', 'diaryDateId', 'memberId', 'groupId', 'notes', 'createdAt', 'updatedAt']
attributes: PARTICIPANT_ATTRIBUTES
});
res.status(200).json(participants);
} catch (error) {
@@ -68,12 +72,22 @@ export const updateParticipantGroup = async (req, res) => {
export const addParticipant = async (req, res) => {
try {
const { diaryDateId, memberId } = req.body;
const participant = await Participant.create({ diaryDateId, memberId });
const [participant, created] = await Participant.findOrCreate({
where: { diaryDateId, memberId },
defaults: { diaryDateId, memberId, attendanceStatus: 'present' }
});
participant.attendanceStatus = 'present';
await participant.save();
// Hole DiaryDate für clubId
const diaryDate = await DiaryDates.findByPk(diaryDateId);
if (diaryDate?.clubId) {
emitParticipantAdded(diaryDate.clubId, diaryDateId, participant);
if (created) {
emitParticipantAdded(diaryDate.clubId, diaryDateId, participant);
} else {
emitParticipantUpdated(diaryDate.clubId, diaryDateId, participant);
}
}
res.status(201).json(participant);
@@ -90,6 +104,15 @@ export const removeParticipant = async (req, res) => {
// Hole DiaryDate für clubId vor dem Löschen
const diaryDate = await DiaryDates.findByPk(diaryDateId);
const clubId = diaryDate?.clubId;
const participant = await Participant.findOne({
where: { diaryDateId, memberId },
attributes: ['id']
});
if (participant) {
await DiaryMemberActivity.destroy({ where: { participantId: participant.id } });
}
await Participant.destroy({ where: { diaryDateId, memberId } });
@@ -104,3 +127,53 @@ export const removeParticipant = async (req, res) => {
res.status(500).json({ error: 'Fehler beim Entfernen des Teilnehmers' });
}
};
export const updateParticipantStatus = async (req, res) => {
try {
const { dateId, memberId } = req.params;
const { attendanceStatus } = req.body;
if (!['excused', 'cancelled'].includes(attendanceStatus)) {
return res.status(400).json({ error: 'Ungültiger Teilnehmerstatus' });
}
const diaryDate = await DiaryDates.findByPk(dateId);
if (!diaryDate) {
return res.status(404).json({ error: 'Trainingstag nicht gefunden' });
}
const [participant] = await Participant.findOrCreate({
where: {
diaryDateId: dateId,
memberId
},
defaults: {
diaryDateId: dateId,
memberId,
attendanceStatus
}
});
participant.attendanceStatus = attendanceStatus;
participant.groupId = null;
await participant.save();
await DiaryMemberActivity.destroy({ where: { participantId: participant.id } });
const updatedParticipant = await Participant.findOne({
where: {
diaryDateId: dateId,
memberId
},
attributes: PARTICIPANT_ATTRIBUTES
});
if (diaryDate.clubId && updatedParticipant) {
emitParticipantUpdated(diaryDate.clubId, dateId, updatedParticipant);
}
res.status(200).json(updatedParticipant || participant);
} catch (error) {
devLog(error);
res.status(500).json({ error: 'Fehler beim Aktualisieren des Teilnehmerstatus' });
}
};

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

View File

@@ -5,8 +5,8 @@ import fs from 'fs';
export const createPredefinedActivity = async (req, res) => {
try {
const { name, code, description, durationText, duration, imageLink, drawingData } = req.body;
const predefinedActivity = await predefinedActivityService.createPredefinedActivity({ name, code, description, durationText, duration, imageLink, drawingData });
const { name, code, description, durationText, duration, imageLink, drawingData, excludeFromStats } = req.body;
const predefinedActivity = await predefinedActivityService.createPredefinedActivity({ name, code, description, durationText, duration, imageLink, drawingData, excludeFromStats });
res.status(201).json(predefinedActivity);
} catch (error) {
console.error('[createPredefinedActivity] - Error:', error);
@@ -16,7 +16,8 @@ export const createPredefinedActivity = async (req, res) => {
export const getAllPredefinedActivities = async (req, res) => {
try {
const predefinedActivities = await predefinedActivityService.getAllPredefinedActivities();
const { scope = 'all' } = req.query;
const predefinedActivities = await predefinedActivityService.getAllPredefinedActivities(scope);
res.status(200).json(predefinedActivities);
} catch (error) {
console.error('[getAllPredefinedActivities] - Error:', error);
@@ -42,8 +43,8 @@ export const getPredefinedActivityById = async (req, res) => {
export const updatePredefinedActivity = async (req, res) => {
try {
const { id } = req.params;
const { name, code, description, durationText, duration, imageLink, drawingData } = req.body;
const updatedActivity = await predefinedActivityService.updatePredefinedActivity(id, { name, code, description, durationText, duration, imageLink, drawingData });
const { name, code, description, durationText, duration, imageLink, drawingData, excludeFromStats } = req.body;
const updatedActivity = await predefinedActivityService.updatePredefinedActivity(id, { name, code, description, durationText, duration, imageLink, drawingData, excludeFromStats });
res.status(200).json(updatedActivity);
} catch (error) {
console.error('[updatePredefinedActivity] - Error:', error);

View File

@@ -2,6 +2,7 @@
import tournamentService from "../services/tournamentService.js";
import { emitTournamentChanged } from '../services/socketService.js';
import TournamentClass from '../models/TournamentClass.js';
import HttpError from '../exceptions/HttpError.js';
// 1. Alle Turniere eines Vereins
export const getTournaments = async (req, res) => {
@@ -12,6 +13,11 @@ export const getTournaments = async (req, res) => {
res.status(200).json(tournaments);
} catch (error) {
console.error(error);
if (error instanceof HttpError) {
res.set('x-debug-tournament-clubid', String(clubId));
res.set('x-debug-tournament-clubid-num', String(Number(clubId)));
return res.status(error.statusCode || 500).json({ error: error.message });
}
res.status(500).json({ error: error.message });
}
};
@@ -36,23 +42,36 @@ export const addTournament = async (req, res) => {
// 3. Teilnehmer hinzufügen - klassengebunden
export const addParticipant = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, classId, participant: participantId } = req.body;
const { clubId, classId, participant: participantId, tournamentId } = req.body;
try {
// Payloads:
// - Mit Klasse (klassengebunden): { clubId, classId, participant }
// - Ohne Klasse (turnierweit): { clubId, tournamentId, participant, classId: null }
if (!participantId) {
return res.status(400).json({ error: 'Teilnehmer-ID ist erforderlich' });
}
if (!classId) {
return res.status(400).json({ error: 'Klasse ist erforderlich' });
// Allow adding a participant either to a specific class (classId) or to the whole tournament (no class)
if (!classId && !tournamentId) {
return res.status(400).json({ error: 'Klasse oder tournamentId ist erforderlich' });
}
await tournamentService.addParticipant(token, clubId, classId, participantId);
// Hole tournamentId über die Klasse
const tournamentClass = await TournamentClass.findByPk(classId);
if (!tournamentClass) {
return res.status(404).json({ error: 'Klasse nicht gefunden' });
// Pass through to service. If classId is present it will be used, otherwise the service should add the participant with classId = null for the given tournamentId
await tournamentService.addParticipant(token, clubId, classId || null, participantId, tournamentId || null);
// Determine tournamentId for response and event emission
let respTournamentId = tournamentId;
if (classId && !respTournamentId) {
const tournamentClass = await TournamentClass.findByPk(classId);
if (!tournamentClass) {
return res.status(404).json({ error: 'Klasse nicht gefunden' });
}
respTournamentId = tournamentClass.tournamentId;
}
const participants = await tournamentService.getParticipants(token, clubId, tournamentClass.tournamentId, classId);
// Fetch updated participants for the (optional) class or whole tournament
const participants = await tournamentService.getParticipants(token, clubId, respTournamentId, classId || null);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentClass.tournamentId);
if (respTournamentId) emitTournamentChanged(clubId, respTournamentId);
res.status(200).json(participants);
} catch (error) {
console.error('[addParticipant] Error:', error);
@@ -93,7 +112,29 @@ export const createGroups = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, numberOfGroups } = req.body;
try {
await tournamentService.createGroups(token, clubId, tournamentId, numberOfGroups);
// DEBUG: Eingehende Daten sichtbar machen (temporär)
console.log('[tournamentController.createGroups] body:', req.body);
console.log('[tournamentController.createGroups] types:', {
clubId: typeof clubId,
tournamentId: typeof tournamentId,
numberOfGroups: typeof numberOfGroups,
});
// Turniere ohne Klassen: `numberOfGroups: 0` kommt aus der UI (Default) vor.
// Statt „nichts passiert“ normalisieren wir auf mindestens 1 Gruppe.
let normalizedNumberOfGroups = numberOfGroups;
if (normalizedNumberOfGroups !== undefined && normalizedNumberOfGroups !== null) {
const n = Number(normalizedNumberOfGroups);
console.log('[tournamentController.createGroups] parsed numberOfGroups:', n);
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
return res.status(400).json({ error: 'numberOfGroups muss eine ganze Zahl >= 0 sein' });
}
normalizedNumberOfGroups = Math.max(1, n);
}
console.log('[tournamentController.createGroups] normalizedNumberOfGroups:', normalizedNumberOfGroups);
await tournamentService.createGroups(token, clubId, tournamentId, normalizedNumberOfGroups);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.sendStatus(204);
@@ -133,6 +174,21 @@ export const fillGroups = async (req, res) => {
}
};
// 7b. Gruppenspiele erstellen ohne Gruppenzuordnungen zu ändern
export const createGroupMatches = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, classId } = req.body;
try {
await tournamentService.createGroupMatches(token, clubId, tournamentId, classId);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.sendStatus(204);
} catch (error) {
console.error(error);
res.status(500).json({ error: error.message });
}
};
// 8. Gruppen mit ihren Teilnehmern abfragen
export const getGroups = async (req, res) => {
const { authcode: token } = req.headers;
@@ -163,9 +219,11 @@ export const getTournament = async (req, res) => {
export const updateTournament = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.params;
const { name, date, winningSets } = req.body;
const { name, date, winningSets, numberOfTables } = req.body;
try {
const tournament = await tournamentService.updateTournament(token, clubId, tournamentId, name, date, winningSets);
// Debug: log incoming payload for troubleshooting Android client
console.log('[updateTournament] incoming body:', req.body);
const tournament = await tournamentService.updateTournament(token, clubId, tournamentId, name, date, winningSets, numberOfTables);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.status(200).json(tournament);
@@ -189,6 +247,35 @@ export const getTournamentMatches = async (req, res) => {
}
};
// Setze Tischnummer für ein Spiel
export const setMatchTable = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, matchId } = req.params;
const { tableNumber } = req.body;
try {
const updated = await tournamentService.setMatchTable(token, clubId, tournamentId, matchId, tableNumber);
emitTournamentChanged(clubId, tournamentId);
res.status(200).json(updated);
} catch (error) {
console.error('[setMatchTable] Error:', error);
res.status(500).json({ error: error.message });
}
};
// Freie Tische verteilen (Batch)
export const distributeTables = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.body;
try {
const updated = await tournamentService.distributeTables(token, clubId, tournamentId);
emitTournamentChanged(clubId, tournamentId);
res.status(200).json({ updated, message: 'Tische wurden verteilt.' });
} catch (error) {
console.error('[distributeTables] Error:', error);
res.status(500).json({ error: error.message });
}
};
// 11. Satz-Ergebnis speichern
export const addMatchResult = async (req, res) => {
const { authcode: token } = req.headers;
@@ -300,9 +387,9 @@ export const resetGroups = async (req, res) => {
export const resetMatches = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.body;
const { clubId, tournamentId, classId } = req.body;
try {
await tournamentService.resetMatches(token, clubId, tournamentId);
await tournamentService.resetMatches(token, clubId, tournamentId, classId || null);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.sendStatus(204);
@@ -380,9 +467,9 @@ export const reopenMatch = async (req, res) => {
export const deleteKnockoutMatches = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.body;
const { clubId, tournamentId, classId } = req.body;
try {
await tournamentService.resetKnockout(token, clubId, tournamentId);
await tournamentService.resetKnockout(token, clubId, tournamentId, classId);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.status(200).json({ message: "K.o.-Runde gelöscht" });

View File

@@ -0,0 +1,70 @@
import tournamentService from '../services/tournamentService.js';
import HttpError from '../exceptions/HttpError.js';
export const getStages = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.query;
try {
if (clubId == null || tournamentId == null) {
return res.status(400).json({ error: 'clubId und tournamentId sind erforderlich.' });
}
const data = await tournamentService.getTournamentStages(token, Number(clubId), Number(tournamentId));
res.status(200).json(data);
} catch (error) {
console.error(error);
if (error instanceof HttpError) {
// Debug-Hilfe: zeigt, welche IDs tatsächlich am Endpoint ankamen (ohne sensible Daten)
res.set('x-debug-stages-clubid', String(clubId));
res.set('x-debug-stages-tournamentid', String(tournamentId));
res.set('x-debug-stages-clubid-num', String(Number(clubId)));
return res.status(error.statusCode || 500).json({ error: error.message });
}
res.status(500).json({ error: error.message });
}
};
export const upsertStages = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, stages, advancement, advancements } = req.body;
try {
const data = await tournamentService.upsertTournamentStages(
token,
Number(clubId),
Number(tournamentId),
stages,
advancement,
advancements
);
res.status(200).json(data);
} catch (error) {
console.error(error);
if (error instanceof HttpError) {
res.set('x-debug-stages-clubid', String(clubId));
res.set('x-debug-stages-tournamentid', String(tournamentId));
res.set('x-debug-stages-clubid-num', String(Number(clubId)));
return res.status(error.statusCode || 500).json({ error: error.message });
}
res.status(500).json({ error: error.message });
}
};
export const advanceStage = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, fromStageIndex, toStageIndex } = req.body;
try {
const data = await tournamentService.advanceTournamentStage(
token,
Number(clubId),
Number(tournamentId),
Number(fromStageIndex || 1),
(toStageIndex == null ? null : Number(toStageIndex))
);
res.status(200).json(data);
} catch (error) {
console.error(error);
if (error instanceof HttpError) {
return res.status(error.statusCode || 500).json({ error: error.message });
}
res.status(500).json({ error: error.message });
}
};

View File

@@ -0,0 +1,50 @@
import trainingCancellationService from '../services/trainingCancellationService.js';
import { getSafeErrorMessage } from '../utils/errorUtils.js';
export const getTrainingCancellations = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const { year } = req.query;
const result = await trainingCancellationService.getTrainingCancellations(userToken, clubId, year);
res.status(200).json(result);
} catch (error) {
console.error('[getTrainingCancellations] - Error:', error);
const message = getSafeErrorMessage(error, 'Fehler beim Laden der Trainingsausfälle');
res.status(error.statusCode || 500).json({ error: message });
}
};
export const upsertTrainingCancellation = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const { date, startDate, endDate, reason, trainingGroupIds } = req.body;
const result = await trainingCancellationService.upsertTrainingCancellation(
userToken,
clubId,
startDate || date,
reason,
endDate || date || startDate,
trainingGroupIds
);
res.status(200).json(result);
} catch (error) {
console.error('[upsertTrainingCancellation] - Error:', error);
const message = getSafeErrorMessage(error, 'Fehler beim Speichern des Trainingsausfalls');
res.status(error.statusCode || 500).json({ error: message });
}
};
export const deleteTrainingCancellation = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, cancellationId } = req.params;
const result = await trainingCancellationService.deleteTrainingCancellation(userToken, clubId, cancellationId);
res.status(200).json(result);
} catch (error) {
console.error('[deleteTrainingCancellation] - Error:', error);
const message = getSafeErrorMessage(error, 'Fehler beim Löschen des Trainingsausfalls');
res.status(error.statusCode || 500).json({ error: message });
}
};

View File

@@ -32,8 +32,7 @@ export const updateTrainingGroup = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, groupId } = req.params;
const { name, sortOrder } = req.body;
const group = await trainingGroupService.updateTrainingGroup(userToken, clubId, groupId, name, sortOrder);
const group = await trainingGroupService.updateTrainingGroup(userToken, clubId, groupId, req.body);
res.status(200).json(group);
} catch (error) {
console.error('[updateTrainingGroup] - Error:', error);

View File

@@ -1,173 +1,16 @@
import { DiaryDate, Member, Participant } from '../models/index.js';
import { Op } from 'sequelize';
import trainingStatsService from '../services/trainingStatsService.js';
class TrainingStatsController {
async getTrainingStats(req, res) {
try {
const { clubId } = req.params;
// Aktuelle Datum für Berechnungen
const now = new Date();
const twelveMonthsAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
const threeMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate());
// Alle aktiven Mitglieder des spezifischen Vereins laden
const members = await Member.findAll({
where: {
active: true,
clubId: parseInt(clubId)
}
});
// Anzahl der Trainings im jeweiligen Zeitraum berechnen
const trainingsCount12Months = await DiaryDate.count({
where: {
clubId: parseInt(clubId),
date: {
[Op.gte]: twelveMonthsAgo
}
}
});
const trainingsCount3Months = await DiaryDate.count({
where: {
clubId: parseInt(clubId),
date: {
[Op.gte]: threeMonthsAgo
}
}
});
const stats = [];
for (const member of members) {
// Trainingsteilnahmen der letzten 12 Monate über Participant-Model
const participation12Months = await Participant.count({
include: [{
model: DiaryDate,
as: 'diaryDate',
where: {
clubId: parseInt(clubId),
date: {
[Op.gte]: twelveMonthsAgo
}
}
}],
where: {
memberId: member.id
}
});
// Trainingsteilnahmen der letzten 3 Monate über Participant-Model
const participation3Months = await Participant.count({
include: [{
model: DiaryDate,
as: 'diaryDate',
where: {
clubId: parseInt(clubId),
date: {
[Op.gte]: threeMonthsAgo
}
}
}],
where: {
memberId: member.id
}
});
// Trainingsteilnahmen insgesamt über Participant-Model
const participationTotal = await Participant.count({
include: [{
model: DiaryDate,
as: 'diaryDate',
where: {
clubId: parseInt(clubId)
}
}],
where: {
memberId: member.id
}
});
// Detaillierte Trainingsdaten (absteigend sortiert) über Participant-Model
const trainingDetails = await Participant.findAll({
include: [{
model: DiaryDate,
as: 'diaryDate',
where: {
clubId: parseInt(clubId)
}
}],
where: {
memberId: member.id
},
order: [['diaryDate', 'date', 'DESC']],
limit: 50 // Begrenzen auf die letzten 50 Trainingseinheiten
});
// Trainingsteilnahmen für den Member formatieren
const formattedTrainingDetails = trainingDetails.map(participation => ({
id: participation.id,
date: participation.diaryDate.date,
activityName: 'Training',
startTime: '--:--',
endTime: '--:--'
}));
// Letztes Training
const lastTrainingDate = trainingDetails.length ? trainingDetails[0].diaryDate.date : null;
const lastTrainingTs = lastTrainingDate ? new Date(lastTrainingDate).getTime() : 0;
stats.push({
id: member.id,
firstName: member.firstName,
lastName: member.lastName,
birthDate: member.birthDate,
participation12Months,
participation3Months,
participationTotal,
lastTraining: lastTrainingDate,
lastTrainingTs,
trainingDetails: formattedTrainingDetails
});
}
// Nach Gesamtteilnahme absteigend sortieren
stats.sort((a, b) => b.participationTotal - a.participationTotal);
// Trainingstage mit Teilnehmerzahlen abrufen (letzte 12 Monate, absteigend sortiert)
const trainingDays = await DiaryDate.findAll({
where: {
clubId: parseInt(clubId),
date: {
[Op.gte]: twelveMonthsAgo
}
},
include: [{
model: Participant,
as: 'participantList',
attributes: ['id']
}],
order: [['date', 'DESC']]
});
// Formatiere Trainingstage mit Teilnehmerzahl
const formattedTrainingDays = trainingDays.map(day => ({
id: day.id,
date: day.date,
participantCount: day.participantList ? day.participantList.length : 0
}));
// Zusätzliche Metadaten mit Trainingsanzahl zurückgeben
res.json({
members: stats,
trainingsCount12Months,
trainingsCount3Months,
trainingDays: formattedTrainingDays
});
const stats = await trainingStatsService.getTrainingStats(clubId);
res.json(stats);
} catch (error) {
console.error('Fehler beim Laden der Trainings-Statistik:', error);
if (error?.status) {
return res.status(error.status).json({ error: error.message });
}
res.status(500).json({ error: 'Fehler beim Laden der Trainings-Statistik' });
}
}

View File

@@ -142,7 +142,7 @@ export const requireAdmin = () => {
parseInt(clubId)
);
if (!userPermissions || (userPermissions.role !== 'admin' && !userPermissions.isOwner)) {
if (!userPermissions || (!userPermissions.isAdmin && !userPermissions.isOwner)) {
return res.status(403).json({
error: 'Keine Berechtigung',
details: 'Administrator-Rechte erforderlich'
@@ -190,7 +190,10 @@ export const requireRole = (roles) => {
parseInt(clubId)
);
if (!userPermissions || !roles.includes(userPermissions.role)) {
const assignedRoleKeys = Array.isArray(userPermissions?.roles)
? userPermissions.roles.map((role) => role.roleKey)
: [];
if (!userPermissions || (!roles.includes(userPermissions.role) && !assignedRoleKeys.some((roleKey) => roles.includes(roleKey)))) {
return res.status(403).json({
error: 'Keine Berechtigung',
details: `Erforderliche Rolle: ${roles.join(', ')}`
@@ -212,4 +215,3 @@ export default {
requireAdmin,
requireRole
};

View File

@@ -0,0 +1,58 @@
-- Adds multi-stage tournaments (rounds) support
-- MariaDB/MySQL compatible migration (manual execution)
-- 1) New table: tournament_stage
CREATE TABLE IF NOT EXISTS tournament_stage (
id INT NOT NULL AUTO_INCREMENT,
tournament_id INT NOT NULL,
stage_index INT NOT NULL,
name VARCHAR(255) NULL,
type VARCHAR(32) NOT NULL, -- 'groups' | 'knockout'
number_of_groups INT NULL,
advancing_per_group INT NULL,
max_group_size INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
CONSTRAINT fk_tournament_stage_tournament
FOREIGN KEY (tournament_id) REFERENCES tournament(id)
ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE INDEX idx_tournament_stage_tournament_id ON tournament_stage (tournament_id);
CREATE UNIQUE INDEX uq_tournament_stage_tournament_id_index ON tournament_stage (tournament_id, stage_index);
-- 2) New table: tournament_stage_advancement
CREATE TABLE IF NOT EXISTS tournament_stage_advancement (
id INT NOT NULL AUTO_INCREMENT,
tournament_id INT NOT NULL,
from_stage_id INT NOT NULL,
to_stage_id INT NOT NULL,
mode VARCHAR(32) NOT NULL DEFAULT 'pools',
config JSON NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
CONSTRAINT fk_tournament_stage_adv_tournament
FOREIGN KEY (tournament_id) REFERENCES tournament(id)
ON DELETE CASCADE,
CONSTRAINT fk_tournament_stage_adv_from
FOREIGN KEY (from_stage_id) REFERENCES tournament_stage(id)
ON DELETE CASCADE,
CONSTRAINT fk_tournament_stage_adv_to
FOREIGN KEY (to_stage_id) REFERENCES tournament_stage(id)
ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE INDEX idx_tournament_stage_adv_tournament_id ON tournament_stage_advancement (tournament_id);
CREATE INDEX idx_tournament_stage_adv_from_stage_id ON tournament_stage_advancement (from_stage_id);
CREATE INDEX idx_tournament_stage_adv_to_stage_id ON tournament_stage_advancement (to_stage_id);
-- 3) Add stage_id to tournament_group and tournament_match
-- MariaDB has no IF NOT EXISTS for columns; run each ALTER once.
-- If you rerun, comment out the ALTERs or check INFORMATION_SCHEMA first.
ALTER TABLE tournament_group ADD COLUMN stage_id INT NULL;
ALTER TABLE tournament_match ADD COLUMN stage_id INT NULL;
CREATE INDEX idx_tournament_group_tournament_stage ON tournament_group (tournament_id, stage_id);
CREATE INDEX idx_tournament_match_tournament_stage ON tournament_match (tournament_id, stage_id);

View File

@@ -0,0 +1,16 @@
-- Allow NULL placeholders for KO (e.g. "Spiel um Platz 3")
-- MariaDB/MySQL manual migration
--
-- Background: We create placeholder matches with player1_id/player2_id = NULL.
-- Some prod DBs still have NOT NULL on these columns.
-- 1) Make player columns nullable
ALTER TABLE tournament_match MODIFY COLUMN player1_id INT NULL;
ALTER TABLE tournament_match MODIFY COLUMN player2_id INT NULL;
-- 2) (Optional) If you have foreign keys to tournament_member/external participant IDs,
-- ensure they also allow NULL. (Not adding here because not all installations have FKs.)
-- 3) Verify
-- SHOW COLUMNS FROM tournament_match LIKE 'player1_id';
-- SHOW COLUMNS FROM tournament_match LIKE 'player2_id';

View File

@@ -0,0 +1,11 @@
-- Add pool_id to tournament_group for pooled group phases
ALTER TABLE `tournament_group`
ADD COLUMN `pool_id` INT NULL AFTER `class_id`;
-- Add out_of_competition flags
ALTER TABLE `tournament_member`
ADD COLUMN `out_of_competition` TINYINT(1) NOT NULL DEFAULT 0 AFTER `class_id`;
ALTER TABLE `external_tournament_participant`
ADD COLUMN `out_of_competition` TINYINT(1) NOT NULL DEFAULT 0 AFTER `class_id`;

View File

@@ -0,0 +1,3 @@
-- Change accident field from VARCHAR to TEXT to allow longer descriptions
ALTER TABLE `accident`
MODIFY COLUMN `accident` TEXT NOT NULL;

View File

@@ -0,0 +1,6 @@
-- E-Mail und Adresse für externe Teilnehmer (für Weitermeldung)
-- Die Felder werden verschlüsselt gespeichert (siehe Model)
ALTER TABLE `external_tournament_participant`
ADD COLUMN `email` VARCHAR(500) NULL AFTER `club`,
ADD COLUMN `address` TEXT NULL AFTER `email`;

View File

@@ -0,0 +1,8 @@
-- Add gave_up (Aufgabe) to tournament participants
-- Wenn ein Spieler aufgibt: alle seine Spiele zählen für den Gegner (11:0), beide aufgegeben = 0:0, kein Sieger
ALTER TABLE `tournament_member`
ADD COLUMN `gave_up` TINYINT(1) NOT NULL DEFAULT 0 AFTER `out_of_competition`;
ALTER TABLE `external_tournament_participant`
ADD COLUMN `gave_up` TINYINT(1) NOT NULL DEFAULT 0 AFTER `out_of_competition`;

View File

@@ -0,0 +1,9 @@
-- Minimeisterschaften: Turnier-Jahr und Alters-Obergrenze pro Klasse
-- tournament.mini_championship_year: Jahr der Minimeisterschaft (z.B. 2025); nur gesetzt bei Minimeisterschaften
-- tournament_class.max_birth_year: Geboren im Jahr X oder früher (<=); für Altersklassen 12/10
ALTER TABLE `tournament`
ADD COLUMN `mini_championship_year` INT NULL AFTER `allows_external`;
ALTER TABLE `tournament_class`
ADD COLUMN `max_birth_year` INT NULL AFTER `min_birth_year`;

View File

@@ -0,0 +1,9 @@
-- Anzahl der Tische im Turnier
ALTER TABLE tournament
ADD COLUMN number_of_tables INT NULL DEFAULT NULL
COMMENT 'Anzahl der Tische, auf denen gespielt wird';
-- Tischnummer pro Match
ALTER TABLE tournament_match
ADD COLUMN table_number INT NULL DEFAULT NULL
COMMENT 'Tischnummer, an der das Match stattfindet';

View File

@@ -0,0 +1,8 @@
-- Felder für "Passwort vergessen"-Funktion
ALTER TABLE user
ADD COLUMN reset_token VARCHAR(255) NULL DEFAULT NULL
COMMENT 'Token für Passwort-Reset';
ALTER TABLE user
ADD COLUMN reset_token_expires DATETIME NULL DEFAULT NULL
COMMENT 'Ablaufzeitpunkt des Reset-Tokens';

View File

@@ -0,0 +1,10 @@
ALTER TABLE http_page_fetch_log
ADD COLUMN IF NOT EXISTS request_method VARCHAR(16) NULL COMMENT 'HTTP-Methode des ausgehenden Requests' AFTER club_id_param,
ADD COLUMN IF NOT EXISTS request_headers LONGTEXT NULL COMMENT 'Gesendete Request-Header als JSON' AFTER request_method,
ADD COLUMN IF NOT EXISTS request_body LONGTEXT NULL COMMENT 'Gesendeter Request-Body im Originalformat' AFTER request_headers,
ADD COLUMN IF NOT EXISTS response_headers LONGTEXT NULL COMMENT 'Empfangene Response-Header als JSON' AFTER content_type,
ADD COLUMN IF NOT EXISTS response_body LONGTEXT NULL COMMENT 'Vollstaendiger Response-Body' AFTER response_headers,
ADD COLUMN IF NOT EXISTS response_url TEXT NULL COMMENT 'Finale Response-URL nach Redirects' AFTER response_body;
ALTER TABLE http_page_fetch_log
MODIFY COLUMN response_snippet LONGTEXT NULL COMMENT 'Gekuerzter oder kompletter Response-Anfang zur Strukturanalyse';

View File

@@ -0,0 +1,9 @@
-- Migration: Add myTischtennis rankings settings to clubs table
-- Enables per-club configuration of TTR/QTTR rankings fetch.
-- Club number comes from association_member_number (Verbands-Mitgliedsnummer).
ALTER TABLE clubs
ADD COLUMN IF NOT EXISTS my_tischtennis_fed_nickname VARCHAR(50) NULL
COMMENT 'Federation short name for rankings (e.g. HeTTV)',
ADD COLUMN IF NOT EXISTS auto_fetch_rankings BOOLEAN NOT NULL DEFAULT FALSE
COMMENT 'Enable automatic TTR/QTTR rankings fetch for this club';

View File

@@ -0,0 +1,36 @@
CREATE TABLE IF NOT EXISTS member_orders (
id INT NOT NULL AUTO_INCREMENT,
member_id INT NOT NULL,
club_id INT NOT NULL,
item VARCHAR(255) NOT NULL,
status ENUM('requested', 'ordered', 'arrived', 'handed_over') NOT NULL DEFAULT 'requested',
order_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
status_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
cost DECIMAL(10,2) NOT NULL DEFAULT 0.00,
paid_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_member_orders_member_id (member_id),
KEY idx_member_orders_club_id (club_id),
KEY idx_member_orders_status (status)
);
CREATE TABLE IF NOT EXISTS member_order_history (
id INT NOT NULL AUTO_INCREMENT,
member_order_id INT NOT NULL,
member_id INT NOT NULL,
club_id INT NOT NULL,
item VARCHAR(255) NOT NULL,
status ENUM('requested', 'ordered', 'arrived', 'handed_over') NOT NULL,
changed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
cost DECIMAL(10,2) NOT NULL DEFAULT 0.00,
paid_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_member_order_history_order_id (member_order_id),
KEY idx_member_order_history_member_id (member_id),
KEY idx_member_order_history_club_id (club_id),
KEY idx_member_order_history_changed_at (changed_at)
);

View File

@@ -0,0 +1,10 @@
-- Jugend-Freigaben (Schema wie backend/models/Member.js)
-- Fehlende Spalten verursachen SequelizeDatabaseError ER_BAD_FIELD_ERROR bei getClubMembers.
ALTER TABLE `member`
ADD COLUMN `adult_release_approved` TINYINT(1) NOT NULL DEFAULT 0
COMMENT 'Jugendspieler mit Freigabe fuer Erwachsene'
AFTER `member_form_handed_over`,
ADD COLUMN `adult_reserve_approved` TINYINT(1) NOT NULL DEFAULT 0
COMMENT 'Jugendspieler als Ersatz bei Erwachsenen zugelassen'
AFTER `adult_release_approved`;

View File

@@ -0,0 +1,10 @@
-- club_team: Felder wie backend/models/ClubTeam.js (teamGender, teamAgeGroup)
-- Fehlen in der DB -> SequelizeDatabaseError ER_BAD_FIELD_ERROR bei getClubTeams.
ALTER TABLE `club_team`
ADD COLUMN `team_gender` ENUM('open', 'female') NOT NULL DEFAULT 'open'
COMMENT 'Geschlecht Team (offen / nur weiblich)'
AFTER `my_tischtennis_team_id`,
ADD COLUMN `team_age_group` ENUM('adult', 'J19', 'J17', 'J15', 'J13', 'J11') NOT NULL DEFAULT 'adult'
COMMENT 'Altersklasse Mannschaft'
AFTER `team_gender`;

View File

@@ -0,0 +1,6 @@
-- Optional: manuell gepflegte geplante Spielklasse (unabhängig von league / MyTischtennis)
ALTER TABLE `club_team`
ADD COLUMN `planned_league_name` VARCHAR(512) NULL
COMMENT 'Geplante Spielklasse (freier Text, optional)'
AFTER `team_age_group`;

View File

@@ -0,0 +1,6 @@
-- Migration: Add per-club member data quality requirements.
-- Controls which optional contact/address fields count as required on /members.
ALTER TABLE clubs
ADD COLUMN IF NOT EXISTS member_data_quality_requirements JSON NULL
COMMENT 'Configures which member fields are required for data quality checks';

View File

@@ -0,0 +1,28 @@
-- Migration: Store group photos for later member photo cropping.
CREATE TABLE IF NOT EXISTS `member_group_photo` (
`id` INT NOT NULL AUTO_INCREMENT,
`club_id` INT NOT NULL,
`title` VARCHAR(255) NOT NULL,
`description` TEXT NULL,
`file_name` VARCHAR(255) NOT NULL,
`original_file_name` VARCHAR(255) NULL,
`mime_type` VARCHAR(100) NULL,
`file_size` INT NULL,
`width` INT NULL,
`height` INT NULL,
`taken_at` DATETIME NULL,
`created_by_user_id` INT NULL,
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
INDEX `idx_member_group_photo_club_id` (`club_id`),
CONSTRAINT `fk_member_group_photo_club`
FOREIGN KEY (`club_id`) REFERENCES `clubs` (`id`)
ON DELETE CASCADE
ON UPDATE CASCADE,
CONSTRAINT `fk_member_group_photo_created_by`
FOREIGN KEY (`created_by_user_id`) REFERENCES `user` (`id`)
ON DELETE SET NULL
ON UPDATE CASCADE
);

View File

@@ -0,0 +1,16 @@
-- Halbserienbasierte Spielinteressen (pro Mitglied, Club, Saison und Halbserie)
CREATE TABLE IF NOT EXISTS `member_play_interest` (
`id` INT NOT NULL AUTO_INCREMENT,
`club_id` INT NOT NULL,
`member_id` INT NOT NULL,
`season_id` INT NOT NULL,
`lineup_half` ENUM('first_half', 'second_half') NOT NULL,
`interested` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_member_play_interest_half` (`club_id`, `member_id`, `season_id`, `lineup_half`),
KEY `idx_member_play_interest_member` (`member_id`),
KEY `idx_member_play_interest_season` (`season_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,122 @@
-- Abrechnungsmodul: Vorlagen, Feld-Mapping, Abrechnungslauf und erzeugte Dokumente
CREATE TABLE IF NOT EXISTS `billing_template` (
`id` INT NOT NULL AUTO_INCREMENT,
`club_id` INT NOT NULL,
`name` VARCHAR(255) NOT NULL,
`description` TEXT NULL,
`pdf_storage_path` VARCHAR(1000) NOT NULL,
`pdf_filename` VARCHAR(255) NOT NULL,
`pdf_mime_type` VARCHAR(100) NOT NULL DEFAULT 'application/pdf',
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`version` INT NOT NULL DEFAULT 1,
`created_by_user_id` INT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_billing_template_club` (`club_id`),
UNIQUE KEY `uniq_billing_template_club_name_version` (`club_id`, `name`, `version`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `billing_template_field` (
`id` INT NOT NULL AUTO_INCREMENT,
`template_id` INT NOT NULL,
`field_key` VARCHAR(120) NOT NULL,
`label` VARCHAR(255) NOT NULL,
`field_type` ENUM('text', 'number', 'currency', 'date', 'checkbox', 'formula', 'table_row') NOT NULL,
`source_type` ENUM('manual', 'member', 'trainer', 'club', 'system', 'constant', 'formula') NOT NULL DEFAULT 'manual',
`source_path` VARCHAR(255) NULL,
`constant_value` VARCHAR(500) NULL,
`formatter` ENUM('none', 'iban_no_country', 'date_dd_mm_yyyy', 'currency_eur_2') NOT NULL DEFAULT 'none',
`is_required` TINYINT(1) NOT NULL DEFAULT 0,
`mapping_mode` ENUM('acroform', 'overlay') NOT NULL DEFAULT 'overlay',
`acroform_field_name` VARCHAR(255) NULL,
`page_number` INT NULL,
`x` DECIMAL(10, 2) NULL,
`y` DECIMAL(10, 2) NULL,
`width` DECIMAL(10, 2) NULL,
`height` DECIMAL(10, 2) NULL,
`font_size` DECIMAL(5, 2) NULL,
`align` ENUM('left', 'center', 'right') NULL,
`formula_expression` VARCHAR(1000) NULL,
`table_group` VARCHAR(100) NULL,
`row_index` INT NULL,
`sort_order` INT NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_billing_template_field_template` (`template_id`),
UNIQUE KEY `uniq_billing_template_field_key` (`template_id`, `field_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `billing_run` (
`id` INT NOT NULL AUTO_INCREMENT,
`club_id` INT NOT NULL,
`template_id` INT NOT NULL,
`name` VARCHAR(255) NOT NULL,
`period_start` DATE NOT NULL,
`period_end` DATE NOT NULL,
`self_recipient_user_id` INT NOT NULL,
`self_recipient_name` VARCHAR(255) NOT NULL,
`hourly_rate` DECIMAL(10, 2) NOT NULL,
`computed_hours_total` DECIMAL(10, 2) NOT NULL DEFAULT 0,
`iban` VARCHAR(64) NULL,
`iban_without_country` TINYINT(1) NOT NULL DEFAULT 0,
`session_label` VARCHAR(255) NULL,
`same_account_checkbox` TINYINT(1) NOT NULL DEFAULT 0,
`omit_self_recipient_name` TINYINT(1) NOT NULL DEFAULT 0,
`omit_iban` TINYINT(1) NOT NULL DEFAULT 0,
`omit_location_text` TINYINT(1) NOT NULL DEFAULT 0,
`omit_document_date` TINYINT(1) NOT NULL DEFAULT 0,
`omit_session_label` TINYINT(1) NOT NULL DEFAULT 0,
`location_text` VARCHAR(255) NULL,
`document_date` DATE NULL,
`status` ENUM('draft', 'generated', 'finalized', 'cancelled') NOT NULL DEFAULT 'draft',
`created_by_user_id` INT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_billing_run_club` (`club_id`),
KEY `idx_billing_run_template` (`template_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `billing_user_setting` (
`id` INT NOT NULL AUTO_INCREMENT,
`club_id` INT NOT NULL,
`user_id` INT NOT NULL,
`last_hourly_rate` DECIMAL(10, 2) NOT NULL DEFAULT 0,
`last_self_recipient_name` VARCHAR(255) NULL,
`last_location_text` VARCHAR(255) NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_billing_user_setting_club_user` (`club_id`, `user_id`),
KEY `idx_billing_user_setting_club` (`club_id`),
KEY `idx_billing_user_setting_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `billing_document` (
`id` INT NOT NULL AUTO_INCREMENT,
`run_id` INT NOT NULL,
`display_name` VARCHAR(255) NOT NULL,
`status` ENUM('draft', 'generated', 'error') NOT NULL DEFAULT 'draft',
`pdf_storage_path` VARCHAR(1000) NULL,
`pdf_filename` VARCHAR(255) NULL,
`error_message` TEXT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_billing_document_run` (`run_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `billing_document_value` (
`id` INT NOT NULL AUTO_INCREMENT,
`billing_document_id` INT NOT NULL,
`field_key` VARCHAR(120) NOT NULL,
`resolved_value` TEXT NULL,
`resolved_source` VARCHAR(255) NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_billing_doc_value_doc` (`billing_document_id`),
UNIQUE KEY `uniq_billing_doc_value_field` (`billing_document_id`, `field_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,5 @@
ALTER TABLE `member_orders`
ADD COLUMN `budget` DECIMAL(10,2) NOT NULL DEFAULT 0.00 AFTER `paid_amount`;
ALTER TABLE `member_order_history`
ADD COLUMN `budget` DECIMAL(10,2) NOT NULL DEFAULT 0.00 AFTER `paid_amount`;

View File

@@ -0,0 +1,5 @@
ALTER TABLE `member_orders`
ADD COLUMN `paid_confirmed` TINYINT(1) NOT NULL DEFAULT 0 AFTER `budget`;
ALTER TABLE `member_order_history`
ADD COLUMN `paid_confirmed` TINYINT(1) NOT NULL DEFAULT 0 AFTER `budget`;

View File

@@ -0,0 +1,4 @@
ALTER TABLE `group_activity`
ADD COLUMN `duration` INT NULL AFTER `custom_activity`,
ADD COLUMN `duration_text` VARCHAR(255) NULL AFTER `duration`,
ADD COLUMN `order_id` INT NOT NULL DEFAULT 1 AFTER `duration_text`;

View File

@@ -0,0 +1,30 @@
ALTER TABLE `diary_date_activities`
ADD COLUMN `group_id` INT NULL AFTER `predefined_activity_id`,
ADD CONSTRAINT `fk_diary_date_activities_group_id`
FOREIGN KEY (`group_id`) REFERENCES `group`(`id`)
ON DELETE SET NULL
ON UPDATE CASCADE;
INSERT INTO `diary_date_activities` (
`diary_date_id`,
`is_timeblock`,
`predefined_activity_id`,
`group_id`,
`duration`,
`duration_text`,
`order_id`,
`created_at`,
`updated_at`
)
SELECT
d.`diary_date_id`,
0 AS `is_timeblock`,
ga.`custom_activity` AS `predefined_activity_id`,
ga.`group_id`,
ga.`duration`,
ga.`duration_text`,
(d.`order_id` * 100) + COALESCE(ga.`order_id`, 1) AS `order_id`,
COALESCE(ga.`created_at`, NOW()) AS `created_at`,
COALESCE(ga.`updated_at`, NOW()) AS `updated_at`
FROM `group_activity` ga
INNER JOIN `diary_date_activities` d ON d.`id` = ga.`diary_date_activity`;

View File

@@ -0,0 +1,4 @@
-- Trainingsgruppen vom Tagebuch-Schnellanlegen ausnehmen (optional)
ALTER TABLE training_group
ADD COLUMN exclude_from_quick_diary_create TINYINT(1) NOT NULL DEFAULT 0
COMMENT '1 = Gruppe bei Schnellanlegen-Terminsuche ignorieren';

View File

@@ -0,0 +1,2 @@
ALTER TABLE `friendly_match`
ADD COLUMN IF NOT EXISTS `result_details` JSON NULL AFTER `guest_participants`;

View File

@@ -0,0 +1,30 @@
CREATE TABLE IF NOT EXISTS `friendly_match` (
`id` INT NOT NULL AUTO_INCREMENT,
`club_id` INT NOT NULL,
`date` DATE NOT NULL,
`time` TIME NULL,
`home_team_name` VARCHAR(255) NOT NULL,
`guest_team_name` VARCHAR(255) NOT NULL,
`location_name` VARCHAR(255) NULL,
`location_address` VARCHAR(255) NULL,
`location_city` VARCHAR(255) NULL,
`location_zip` VARCHAR(32) NULL,
`match_system` VARCHAR(120) NOT NULL DEFAULT 'Braunschweiger System',
`singles_count` INT NOT NULL DEFAULT 12,
`doubles_count` INT NOT NULL DEFAULT 4,
`winning_sets` INT NOT NULL DEFAULT 3,
`home_match_points` INT NOT NULL DEFAULT 0,
`guest_match_points` INT NOT NULL DEFAULT 0,
`is_completed` TINYINT(1) NOT NULL DEFAULT 0,
`home_participants` JSON NULL,
`guest_participants` JSON NULL,
`result_details` JSON NULL,
`players_ready` JSON NULL,
`players_planned` JSON NULL,
`players_played` JSON NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_friendly_match_club_date` (`club_id`, `date`),
KEY `idx_friendly_match_completed` (`club_id`, `is_completed`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,84 @@
-- Manual migration for cross-club friendly match concept
-- Created: 2026-05-30
-- 1) Invitation table
CREATE TABLE IF NOT EXISTS `friendly_match_invitation` (
`id` INT NOT NULL AUTO_INCREMENT,
`from_club_id` INT NOT NULL,
`to_club_id` INT NOT NULL,
`proposed_date` DATE NOT NULL,
`proposed_start_time` TIME NULL,
`proposed_match_name` VARCHAR(255) NOT NULL,
`message` TEXT NULL,
`status` VARCHAR(32) NOT NULL DEFAULT 'pending',
`created_by_user_id` INT NULL,
`accepted_by_user_id` INT NULL,
`accepted_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
CONSTRAINT `chk_friendly_match_invitation_clubs_different`
CHECK (`from_club_id` <> `to_club_id`),
CONSTRAINT `fk_friendly_match_invitation_from_club`
FOREIGN KEY (`from_club_id`) REFERENCES `clubs` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_friendly_match_invitation_to_club`
FOREIGN KEY (`to_club_id`) REFERENCES `clubs` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_friendly_match_invitation_created_by`
FOREIGN KEY (`created_by_user_id`) REFERENCES `user` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_friendly_match_invitation_accepted_by`
FOREIGN KEY (`accepted_by_user_id`) REFERENCES `user` (`id`) ON DELETE SET NULL,
KEY `idx_friendly_match_invitation_to_status_date` (`to_club_id`, `status`, `proposed_date`),
KEY `idx_friendly_match_invitation_from_status_date` (`from_club_id`, `status`, `proposed_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 2) Shared match table
CREATE TABLE IF NOT EXISTS `friendly_match_shared` (
`id` INT NOT NULL AUTO_INCREMENT,
`home_club_id` INT NOT NULL,
`guest_club_id` INT NOT NULL,
`date` DATE NOT NULL,
`start_time` TIME NULL,
`match_name` VARCHAR(255) NULL,
`home_team_name` VARCHAR(255) NOT NULL,
`guest_team_name` VARCHAR(255) NOT NULL,
`location_name` VARCHAR(255) NULL,
`location_address` VARCHAR(255) NULL,
`location_city` VARCHAR(255) NULL,
`location_zip` VARCHAR(32) NULL,
`match_system` VARCHAR(120) NOT NULL DEFAULT 'Braunschweiger System',
`singles_count` INT NOT NULL DEFAULT 12,
`doubles_count` INT NOT NULL DEFAULT 4,
`winning_sets` INT NOT NULL DEFAULT 3,
`home_match_points` INT NOT NULL DEFAULT 0,
`guest_match_points` INT NOT NULL DEFAULT 0,
`is_completed` TINYINT(1) NOT NULL DEFAULT 0,
`home_participants` JSON NULL,
`guest_participants` JSON NULL,
`result_details` JSON NULL,
`players_ready` JSON NULL,
`players_planned` JSON NULL,
`players_played` JSON NULL,
`status` VARCHAR(32) NOT NULL DEFAULT 'active',
`created_by_user_id` INT NULL,
`created_from_invitation_id` INT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
CONSTRAINT `chk_friendly_match_shared_clubs_different`
CHECK (`home_club_id` <> `guest_club_id`),
CONSTRAINT `fk_friendly_match_shared_home_club`
FOREIGN KEY (`home_club_id`) REFERENCES `clubs` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_friendly_match_shared_guest_club`
FOREIGN KEY (`guest_club_id`) REFERENCES `clubs` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_friendly_match_shared_created_by`
FOREIGN KEY (`created_by_user_id`) REFERENCES `user` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_friendly_match_shared_from_invitation`
FOREIGN KEY (`created_from_invitation_id`) REFERENCES `friendly_match_invitation` (`id`) ON DELETE SET NULL,
KEY `idx_friendly_match_shared_home_date_time` (`home_club_id`, `date`, `start_time`),
KEY `idx_friendly_match_shared_guest_date_time` (`guest_club_id`, `date`, `start_time`),
KEY `idx_friendly_match_shared_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Optional rollback statements (manual use):
-- DROP TABLE IF EXISTS `friendly_match_shared`;
-- DROP TABLE IF EXISTS `friendly_match_invitation`;

View File

@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS `club_venue` (
`id` INT NOT NULL AUTO_INCREMENT,
`club_id` INT NOT NULL,
`name` VARCHAR(255) NOT NULL,
`address` VARCHAR(255) NULL,
`zip` VARCHAR(32) NULL,
`city` VARCHAR(255) NULL,
`sort_order` INT NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_club_venue_club_sort` (`club_id`, `sort_order`),
KEY `idx_club_venue_club_name` (`club_id`, `name`),
CONSTRAINT `fk_club_venue_club` FOREIGN KEY (`club_id`) REFERENCES `clubs` (`id`) ON DELETE CASCADE
);

View File

@@ -72,6 +72,7 @@
## API & Logging
51. `api_log` - API-Logs
52. `http_page_fetch_log` - HTTP-Aufrufe an click-TT/HTTV-Seiten (Logging)
## Gesamt: 51 Tabellen
## Gesamt: 52 Tabellen

View File

@@ -0,0 +1,3 @@
ALTER TABLE clubs
ADD COLUMN IF NOT EXISTS country_code VARCHAR(2) NOT NULL DEFAULT 'DE',
ADD COLUMN IF NOT EXISTS state_code VARCHAR(16) NULL;

View File

@@ -0,0 +1,5 @@
-- Add my_tischtennis_history_player_id column
ALTER TABLE member
ADD COLUMN my_tischtennis_history_player_id VARCHAR(255) NULL COMMENT 'TTR history player ID from myTischtennis (e.g. P14EC4981D)';
CREATE INDEX idx_member_my_tischtennis_history_player_id ON member(my_tischtennis_history_player_id);

View File

@@ -0,0 +1,13 @@
ALTER TABLE training_cancellations
ADD COLUMN IF NOT EXISTS start_date DATE NULL,
ADD COLUMN IF NOT EXISTS end_date DATE NULL;
UPDATE training_cancellations
SET
start_date = COALESCE(start_date, date),
end_date = COALESCE(end_date, date)
WHERE start_date IS NULL OR end_date IS NULL;
ALTER TABLE training_cancellations
MODIFY start_date DATE NOT NULL,
MODIFY end_date DATE NOT NULL;

View File

@@ -0,0 +1,2 @@
ALTER TABLE training_cancellations
ADD COLUMN IF NOT EXISTS training_group_ids JSON NULL;

View File

@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS calendar_events (
id INT AUTO_INCREMENT PRIMARY KEY,
club_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
category VARCHAR(64) NULL,
notes TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_calendar_events_club_start (club_id, start_date),
CONSTRAINT fk_calendar_events_club
FOREIGN KEY (club_id) REFERENCES clubs(id)
ON DELETE CASCADE
);

View File

@@ -0,0 +1,31 @@
-- Migration: Create http_page_fetch_log table for logging HTTP page fetches (HTTV/click-TT etc.)
-- Dient zum Verständnis der externen Seiten-Struktur und URL-Varianten je nach Verband/Saison
CREATE TABLE IF NOT EXISTS http_page_fetch_log (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NULL COMMENT 'Optional: User der den Aufruf ausgelöst hat',
fetch_type VARCHAR(64) NOT NULL COMMENT 'z.B. leaguePage, clubInfoDisplay, regionMeetingFilter',
base_domain VARCHAR(255) NOT NULL COMMENT 'z.B. httv.click-tt.de',
full_url TEXT NOT NULL COMMENT 'Vollständige aufgerufene URL',
association VARCHAR(64) NULL COMMENT 'Verband (z.B. HeTTV, RTTV)',
championship VARCHAR(128) NULL COMMENT 'Championship-Parameter (z.B. HTTV 25/26)',
club_id_param VARCHAR(64) NULL COMMENT 'Club-ID falls clubInfoDisplay',
request_method VARCHAR(16) NULL COMMENT 'HTTP-Methode des ausgehenden Requests',
request_headers LONGTEXT NULL COMMENT 'Gesendete Request-Header als JSON',
request_body LONGTEXT NULL COMMENT 'Gesendeter Request-Body im Originalformat',
http_status INT NULL COMMENT 'HTTP-Status der Response',
success BOOLEAN NOT NULL DEFAULT FALSE,
response_snippet TEXT NULL COMMENT 'Gekürzter Response-Anfang (max 2000 Zeichen) zur Strukturanalyse',
content_type VARCHAR(128) NULL COMMENT 'Content-Type der Response',
response_headers LONGTEXT NULL COMMENT 'Empfangene Response-Header als JSON',
response_body LONGTEXT NULL COMMENT 'Vollständiger Response-Body',
response_url TEXT NULL COMMENT 'Finale Response-URL nach Redirects',
error_message TEXT NULL,
execution_time_ms INT NULL COMMENT 'Laufzeit in Millisekunden',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_created_at (created_at),
INDEX idx_base_domain_fetch_type (base_domain, fetch_type),
INDEX idx_association_championship (association, championship),
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS training_cancellations (
id INT AUTO_INCREMENT PRIMARY KEY,
club_id INT NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
date DATE NULL,
reason VARCHAR(255) NULL,
training_group_ids JSON NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_training_cancellation_club_range (club_id, start_date, end_date),
CONSTRAINT fk_training_cancellations_club
FOREIGN KEY (club_id) REFERENCES clubs(id)
ON DELETE CASCADE
);

View File

@@ -13,7 +13,7 @@ const Accident = sequelize.define('Accident', {
allowNull: false,
},
accident: {
type: DataTypes.STRING,
type: DataTypes.TEXT,
allowNull: false,
set(value) {
const encryptedValue = encryptData(value);

View File

@@ -0,0 +1,47 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const BillingDocument = sequelize.define('BillingDocument', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false
},
runId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'run_id'
},
displayName: {
type: DataTypes.STRING(255),
allowNull: false,
field: 'display_name'
},
status: {
type: DataTypes.ENUM('draft', 'generated', 'error'),
allowNull: false,
defaultValue: 'draft'
},
pdfStoragePath: {
type: DataTypes.STRING(1000),
allowNull: true,
field: 'pdf_storage_path'
},
pdfFilename: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'pdf_filename'
},
errorMessage: {
type: DataTypes.TEXT,
allowNull: true,
field: 'error_message'
}
}, {
tableName: 'billing_document',
underscored: true,
timestamps: true
});
export default BillingDocument;

View File

@@ -0,0 +1,37 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const BillingDocumentValue = sequelize.define('BillingDocumentValue', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false
},
billingDocumentId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'billing_document_id'
},
fieldKey: {
type: DataTypes.STRING(120),
allowNull: false,
field: 'field_key'
},
resolvedValue: {
type: DataTypes.TEXT,
allowNull: true,
field: 'resolved_value'
},
resolvedSource: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'resolved_source'
}
}, {
tableName: 'billing_document_value',
underscored: true,
timestamps: false
});
export default BillingDocumentValue;

View File

@@ -0,0 +1,133 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const BillingRun = sequelize.define('BillingRun', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false
},
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id'
},
templateId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'template_id'
},
name: {
type: DataTypes.STRING(255),
allowNull: false
},
periodStart: {
type: DataTypes.DATEONLY,
allowNull: false,
field: 'period_start'
},
periodEnd: {
type: DataTypes.DATEONLY,
allowNull: false,
field: 'period_end'
},
selfRecipientUserId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'self_recipient_user_id'
},
selfRecipientName: {
type: DataTypes.STRING(255),
allowNull: false,
field: 'self_recipient_name'
},
hourlyRate: {
type: DataTypes.DECIMAL(10, 2),
allowNull: false,
field: 'hourly_rate'
},
computedHoursTotal: {
type: DataTypes.DECIMAL(10, 2),
allowNull: false,
defaultValue: 0,
field: 'computed_hours_total'
},
iban: {
type: DataTypes.STRING(64),
allowNull: true
},
ibanWithoutCountry: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'iban_without_country'
},
sessionLabel: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'session_label'
},
sameAccountCheckbox: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'same_account_checkbox'
},
omitSelfRecipientName: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'omit_self_recipient_name'
},
omitIban: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'omit_iban'
},
omitLocationText: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'omit_location_text'
},
omitDocumentDate: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'omit_document_date'
},
omitSessionLabel: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'omit_session_label'
},
locationText: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'location_text'
},
documentDate: {
type: DataTypes.DATEONLY,
allowNull: true,
field: 'document_date'
},
status: {
type: DataTypes.ENUM('draft', 'generated', 'finalized', 'cancelled'),
allowNull: false,
defaultValue: 'draft'
},
createdByUserId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'created_by_user_id'
}
}, {
tableName: 'billing_run',
underscored: true,
timestamps: true
});
export default BillingRun;

View File

@@ -0,0 +1,62 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const BillingTemplate = sequelize.define('BillingTemplate', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false
},
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id'
},
name: {
type: DataTypes.STRING(255),
allowNull: false
},
description: {
type: DataTypes.TEXT,
allowNull: true
},
pdfStoragePath: {
type: DataTypes.STRING(1000),
allowNull: false,
field: 'pdf_storage_path'
},
pdfFilename: {
type: DataTypes.STRING(255),
allowNull: false,
field: 'pdf_filename'
},
pdfMimeType: {
type: DataTypes.STRING(100),
allowNull: false,
defaultValue: 'application/pdf',
field: 'pdf_mime_type'
},
isActive: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
field: 'is_active'
},
version: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 1
},
createdByUserId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'created_by_user_id'
}
}, {
tableName: 'billing_template',
underscored: true,
timestamps: true
});
export default BillingTemplate;

View File

@@ -0,0 +1,125 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const BillingTemplateField = sequelize.define('BillingTemplateField', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false
},
templateId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'template_id'
},
fieldKey: {
type: DataTypes.STRING(120),
allowNull: false,
field: 'field_key'
},
label: {
type: DataTypes.STRING(255),
allowNull: false
},
fieldType: {
type: DataTypes.ENUM('text', 'number', 'currency', 'date', 'checkbox', 'formula', 'table_row'),
allowNull: false,
field: 'field_type'
},
sourceType: {
type: DataTypes.ENUM('manual', 'member', 'trainer', 'club', 'system', 'constant', 'formula'),
allowNull: false,
defaultValue: 'manual',
field: 'source_type'
},
sourcePath: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'source_path'
},
constantValue: {
type: DataTypes.STRING(500),
allowNull: true,
field: 'constant_value'
},
formatter: {
type: DataTypes.ENUM('none', 'iban_no_country', 'date_dd_mm_yyyy', 'currency_eur_2'),
allowNull: false,
defaultValue: 'none'
},
isRequired: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'is_required'
},
mappingMode: {
type: DataTypes.ENUM('acroform', 'overlay'),
allowNull: false,
defaultValue: 'overlay',
field: 'mapping_mode'
},
acroformFieldName: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'acroform_field_name'
},
pageNumber: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'page_number'
},
x: {
type: DataTypes.DECIMAL(10, 2),
allowNull: true
},
y: {
type: DataTypes.DECIMAL(10, 2),
allowNull: true
},
width: {
type: DataTypes.DECIMAL(10, 2),
allowNull: true
},
height: {
type: DataTypes.DECIMAL(10, 2),
allowNull: true
},
fontSize: {
type: DataTypes.DECIMAL(5, 2),
allowNull: true,
field: 'font_size'
},
align: {
type: DataTypes.ENUM('left', 'center', 'right'),
allowNull: true
},
formulaExpression: {
type: DataTypes.STRING(1000),
allowNull: true,
field: 'formula_expression'
},
tableGroup: {
type: DataTypes.STRING(100),
allowNull: true,
field: 'table_group'
},
rowIndex: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'row_index'
},
sortOrder: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'sort_order'
}
}, {
tableName: 'billing_template_field',
underscored: true,
timestamps: true
});
export default BillingTemplateField;

View File

@@ -0,0 +1,43 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const BillingUserSetting = sequelize.define('BillingUserSetting', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false
},
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id'
},
userId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'user_id'
},
lastHourlyRate: {
type: DataTypes.DECIMAL(10, 2),
allowNull: false,
defaultValue: 0,
field: 'last_hourly_rate'
},
lastSelfRecipientName: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'last_self_recipient_name'
},
lastLocationText: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'last_location_text'
}
}, {
tableName: 'billing_user_setting',
underscored: true,
timestamps: true
});
export default BillingUserSetting;

View File

@@ -0,0 +1,25 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
import Club from './Club.js';
const CalendarEvent = sequelize.define('CalendarEvent', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
references: { model: Club, key: 'id' },
onDelete: 'CASCADE',
},
title: { type: DataTypes.STRING(255), allowNull: false },
startDate: { type: DataTypes.DATEONLY, allowNull: false, field: 'start_date' },
endDate: { type: DataTypes.DATEONLY, allowNull: false, field: 'end_date' },
category: { type: DataTypes.STRING(64), allowNull: true },
notes: { type: DataTypes.TEXT, allowNull: true },
}, {
tableName: 'calendar_events',
underscored: true,
timestamps: true,
indexes: [{ fields: ['club_id', 'start_date'] }],
});
export default CalendarEvent;

View File

@@ -0,0 +1,107 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
import { encryptData, decryptData } from '../utils/encrypt.js';
const ClickTtAccount = sequelize.define('ClickTtAccount', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false
},
userId: {
type: DataTypes.INTEGER,
allowNull: false,
unique: true,
references: {
model: 'user',
key: 'id'
},
onDelete: 'CASCADE',
field: 'user_id'
},
username: {
type: DataTypes.STRING,
allowNull: false,
set(value) {
this.setDataValue('username', encryptData(value));
},
get() {
const encryptedValue = this.getDataValue('username');
return encryptedValue ? decryptData(encryptedValue) : null;
}
},
encryptedPassword: {
type: DataTypes.TEXT('long'),
allowNull: true,
field: 'encrypted_password'
},
savePassword: {
type: DataTypes.BOOLEAN,
defaultValue: false,
allowNull: false,
field: 'save_password'
},
lastLoginAttempt: {
type: DataTypes.DATE,
allowNull: true,
field: 'last_login_attempt'
},
lastLoginSuccess: {
type: DataTypes.DATE,
allowNull: true,
field: 'last_login_success'
},
playwrightStorageState: {
type: DataTypes.TEXT('long'),
allowNull: true,
field: 'playwright_storage_state',
set(value) {
if (value === null || value === undefined) {
this.setDataValue('playwrightStorageState', null);
} else {
const jsonString = typeof value === 'string' ? value : JSON.stringify(value);
this.setDataValue('playwrightStorageState', encryptData(jsonString));
}
},
get() {
const encrypted = this.getDataValue('playwrightStorageState');
if (!encrypted) return null;
try {
return JSON.parse(decryptData(encrypted));
} catch (_err) {
return null;
}
}
}
}, {
underscored: true,
tableName: 'click_tt_account',
timestamps: true,
hooks: {
beforeSave: async (instance) => {
if (!instance.savePassword) {
instance.encryptedPassword = null;
}
}
}
});
ClickTtAccount.prototype.setPassword = function(password) {
if (password && this.savePassword) {
this.encryptedPassword = encryptData(password);
} else {
this.encryptedPassword = null;
}
};
ClickTtAccount.prototype.getPassword = function() {
if (!this.encryptedPassword) return null;
try {
return decryptData(this.encryptedPassword);
} catch (_err) {
return null;
}
};
export default ClickTtAccount;

View File

@@ -17,6 +17,38 @@ const Club = sequelize.define('Club', {
allowNull: true,
field: 'association_member_number'
},
myTischtennisFedNickname: {
type: DataTypes.STRING,
allowNull: true,
field: 'my_tischtennis_fed_nickname',
comment: 'Federation short name for rankings (e.g. HeTTV)'
},
autoFetchRankings: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'auto_fetch_rankings',
comment: 'Enable automatic TTR/QTTR rankings fetch for this club'
},
countryCode: {
type: DataTypes.STRING(2),
allowNull: false,
defaultValue: 'DE',
field: 'country_code',
comment: 'ISO 3166-1 alpha-2 country code for regional calendar data'
},
stateCode: {
type: DataTypes.STRING(16),
allowNull: true,
field: 'state_code',
comment: 'ISO 3166-2 subdivision code for regional calendar data, e.g. DE-NW'
},
memberDataQualityRequirements: {
type: DataTypes.JSON,
allowNull: true,
field: 'member_data_quality_requirements',
comment: 'Configures which member fields are required for data quality checks'
}
}, {
tableName: 'clubs',
underscored: true,

View File

@@ -0,0 +1,94 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubAccount = sequelize.define('ClubAccount', {
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id',
},
name: {
type: DataTypes.STRING(160),
allowNull: false,
},
accountHolder: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'account_holder',
},
bankName: {
type: DataTypes.STRING(160),
allowNull: true,
field: 'bank_name',
},
iban: {
type: DataTypes.STRING(34),
allowNull: true,
},
bic: {
type: DataTypes.STRING(11),
allowNull: true,
},
accountType: {
type: DataTypes.ENUM('bank', 'cash', 'virtual'),
allowNull: false,
defaultValue: 'bank',
field: 'account_type',
},
usageType: {
type: DataTypes.ENUM('general', 'membership_fees', 'donations', 'expenses', 'reserve', 'petty_cash'),
allowNull: false,
defaultValue: 'general',
field: 'usage_type',
},
currencyCode: {
type: DataTypes.STRING(3),
allowNull: false,
defaultValue: 'EUR',
field: 'currency_code',
},
allowSepaCollections: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'allow_sepa_collections',
},
allowOutgoingPayments: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
field: 'allow_outgoing_payments',
},
isDefault: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'is_default',
},
status: {
type: DataTypes.ENUM('active', 'inactive', 'archived'),
allowNull: false,
defaultValue: 'active',
},
notes: {
type: DataTypes.TEXT,
allowNull: true,
},
archivedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'archived_at',
},
sortOrder: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'sort_order',
},
}, {
tableName: 'club_accounts',
underscored: true,
timestamps: true,
});
export default ClubAccount;

View File

@@ -0,0 +1,78 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubPaymentClaim = sequelize.define('ClubPaymentClaim', {
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id'
},
memberId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'member_id'
},
feeRuleId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'fee_rule_id'
},
claimType: {
type: DataTypes.STRING(32),
allowNull: false,
defaultValue: 'membership_fee',
field: 'claim_type'
},
status: {
type: DataTypes.ENUM('open', 'partially_paid', 'paid', 'written_off', 'cancelled'),
allowNull: false,
defaultValue: 'open'
},
dueOn: {
type: DataTypes.DATEONLY,
allowNull: false,
field: 'due_on'
},
amountCents: {
type: DataTypes.BIGINT,
allowNull: false,
field: 'amount_cents'
},
currencyCode: {
type: DataTypes.STRING(3),
allowNull: false,
defaultValue: 'EUR',
field: 'currency_code'
},
reminderLevel: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'reminder_level'
},
lastReminderAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'last_reminder_at'
},
notes: {
type: DataTypes.TEXT,
allowNull: true
},
settledAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'settled_at'
},
archivedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'archived_at'
}
}, {
tableName: 'club_payment_claims',
underscored: true,
timestamps: true
});
export default ClubPaymentClaim;

View File

@@ -0,0 +1,94 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubRequest = sequelize.define('ClubRequest', {
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id'
},
requestType: {
type: DataTypes.ENUM('contact', 'trial_training', 'membership', 'sponsoring'),
allowNull: false,
defaultValue: 'contact',
field: 'request_type'
},
status: {
type: DataTypes.ENUM('open', 'in_progress', 'waiting', 'converted', 'rejected', 'archived'),
allowNull: false,
defaultValue: 'open'
},
workflowStage: {
type: DataTypes.STRING(64),
allowNull: true,
field: 'workflow_stage'
},
priority: {
type: DataTypes.ENUM('low', 'normal', 'high', 'urgent'),
allowNull: false,
defaultValue: 'normal'
},
subject: {
type: DataTypes.STRING,
allowNull: true
},
firstName: {
type: DataTypes.STRING,
allowNull: true,
field: 'first_name'
},
lastName: {
type: DataTypes.STRING,
allowNull: true,
field: 'last_name'
},
email: {
type: DataTypes.STRING,
allowNull: true
},
phone: {
type: DataTypes.STRING,
allowNull: true
},
message: {
type: DataTypes.TEXT,
allowNull: true
},
sourceSystem: {
type: DataTypes.STRING,
allowNull: true,
field: 'source_system'
},
receivedAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'received_at'
},
assignedUserId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'assigned_user_id'
},
assignedMemberId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'assigned_member_id'
},
convertedMemberId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'converted_member_id'
},
closedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'closed_at'
}
}, {
tableName: 'club_requests',
underscored: true,
timestamps: true
});
export default ClubRequest;

View File

@@ -0,0 +1,32 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubRequestNote = sequelize.define('ClubRequestNote', {
clubRequestId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_request_id'
},
noteType: {
type: DataTypes.STRING(32),
allowNull: false,
defaultValue: 'internal',
field: 'note_type'
},
createdByUserId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'created_by_user_id'
},
body: {
type: DataTypes.TEXT,
allowNull: false
}
}, {
tableName: 'club_request_notes',
underscored: true,
timestamps: true,
updatedAt: false
});
export default ClubRequestNote;

View File

@@ -0,0 +1,58 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubRole = sequelize.define('ClubRole', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id',
},
roleKey: {
type: DataTypes.STRING(64),
allowNull: false,
field: 'role_key',
},
name: {
type: DataTypes.STRING(120),
allowNull: false,
},
description: {
type: DataTypes.TEXT,
allowNull: true,
},
permissions: {
type: DataTypes.JSON,
allowNull: false,
defaultValue: {},
},
isSystemRole: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'is_system_role',
},
sortOrder: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'sort_order',
},
}, {
tableName: 'club_roles',
underscored: true,
timestamps: true,
indexes: [
{
unique: true,
fields: ['club_id', 'role_key'],
},
],
});
export default ClubRole;

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