181 Commits

Author SHA1 Message Date
Torsten Schulz (local)
d74f7b852b Refactor proposal generation in FalukantService to improve character selection logic
- Removed the tracking of used character IDs and streamlined the exclusion of characters already proposed or currently active as directors.
- Enhanced logging for SQL queries and fallback mechanisms to ensure better visibility during character selection.
- Implemented a more efficient approach to gather and process character knowledge for proposal creation, ensuring accurate average calculations.
- Improved error handling to provide clearer feedback when no eligible characters are found.
2026-01-12 08:33:26 +01:00
Torsten Schulz (local)
92d6b15c3f Enhance proposal generation logic in FalukantService to prevent duplicate character usage
- Introduced a mechanism to track used character IDs, ensuring that previously proposed characters are excluded from future proposals.
- Added error handling and logging for scenarios where no eligible characters are found, improving traceability and user feedback.
- Implemented a fallback to include newer characters if older ones are unavailable, enhancing the robustness of the proposal generation process.
2026-01-12 08:24:00 +01:00
Torsten Schulz (local)
91f59062f5 Update BranchView to refresh active tab data on tab change and modify 3D model for female toddler character
- Enhanced the activeTab watcher to refresh data only when the selected branch changes and the tab is switched.
- Introduced a new refreshActiveTab method to load data for the currently active tab, improving data management and user experience.
- Updated the female toddler 3D model file for better integration in the application.
2026-01-12 08:07:50 +01:00
Torsten Schulz (local)
1674086c73 Enhance partner search and gift loading functionality in FalukantService and FamilyView
- Added detailed logging for partner search criteria and results in FalukantService to improve debugging and traceability.
- Refactored partner search logic to use a dynamic where clause for better readability and maintainability.
- Implemented error handling in FamilyView for gift loading, ensuring an empty array is set on failure to load gifts, enhancing user experience.
2026-01-09 14:28:01 +01:00
Torsten Schulz (local)
5ddb099f5a Add 3D character model integration and update dependencies
- Introduced a new CharacterModel3D component for rendering 3D character models in OverviewView.
- Updated package.json and package-lock.json to include 'three' library for 3D graphics support.
- Enhanced Vite configuration to allow access to external files and ensure proper handling of GLB/GLTF assets.
- Improved layout and styling in OverviewView for better visualization of character and avatar.
2026-01-09 13:29:32 +01:00
Torsten Schulz (local)
3722bcf8c8 Enhance parameter extraction in MessagesDialog component for money and effect changes
- Added extraction of money_change_absolute and money_change_percent from value to parameters.
- Updated effects handling to include money_change, storage_damage, production_quality_change, transport_speed_change, and storage_capacity_change types.
- Improved logic to prevent overwriting parameters already extracted from value, ensuring accurate data representation.
2026-01-09 09:31:33 +01:00
Torsten Schulz (local)
0372d213c0 Refine city filtering in NPC creation process within AdminService
- Added explicit filtering for city-type regions to ensure only valid cities are processed during NPC creation.
- Enhanced logging to provide feedback on the number of cities found and their names, improving traceability in the NPC creation workflow.
- Updated comments for clarity on the importance of using city regions exclusively.
2026-01-07 17:15:17 +01:00
Torsten Schulz (local)
c322eb1e5a Add NPC creation status tracking and progress reporting in Admin module
- Implemented getNPCsCreationStatus method in AdminController to retrieve the status of NPC creation jobs.
- Enhanced AdminService to manage NPC creation jobs, including job ID generation, progress updates, and error handling.
- Updated frontend CreateNPCView to display progress of NPC creation, including estimated time remaining and job status.
- Added localization strings for progress reporting in both German and English.
- Improved overall user experience by providing real-time feedback during NPC creation processes.
2026-01-07 17:09:54 +01:00
Torsten Schulz (local)
b34dcac685 Refactor CreateNPCView layout for improved structure and styling
- Updated the template structure in CreateNPCView.vue to enhance layout with additional div wrappers for better styling and organization.
- Ensured the main content is encapsulated within a scrollable area, improving user experience during NPC creation.
2026-01-07 17:00:56 +01:00
Torsten Schulz (local)
4850f50c66 Update package-lock.json dependencies for improved stability and security
- Upgraded 'glob' to version 10.5.0.
- Updated 'body-parser' to version 1.20.4 and adjusted its dependencies.
- Enhanced 'express' to version 4.22.1 with updated dependencies.
- Bumped 'qs' to version 6.14.1 and modified its dependencies.
- Updated 'raw-body' to version 2.5.3 and adjusted its dependencies.
- Ensured compatibility with newer versions of dependencies across the project.
2026-01-07 16:59:42 +01:00
Torsten Schulz (local)
5996f819e8 Enhance NPC creation functionality and validation in Admin module
- Updated AdminController to validate the count parameter, ensuring it is between 1 and 500.
- Refactored NPC creation logic in AdminService to create NPCs for each city-title combination, improving efficiency.
- Enhanced frontend localization files to reflect changes in count descriptions and validation messages.
- Updated CreateNPCView to provide user guidance on count input and display detailed creation results.
2026-01-07 16:57:50 +01:00
Torsten Schulz (local)
4d967fe7a2 Update German and English navigation localization files to include user rights translations 2026-01-07 16:49:33 +01:00
Torsten Schulz (local)
bb91c2bbe5 Add NPC creation and titles retrieval functionality in Admin module
- Implemented createNPCs method in AdminController to handle NPC creation with specified parameters including region, age, title, and count.
- Added getTitlesOfNobility method in AdminController to retrieve available titles for users.
- Updated adminRouter to include new routes for creating NPCs and fetching titles.
- Enhanced navigationController and frontend localization files to support new NPC creation feature.
- Introduced corresponding UI components and routes for NPC management in the admin interface.
2026-01-07 16:45:39 +01:00
Torsten Schulz (local)
511df52c3c Enhance MessagesDialog component to support HTML content and improve parameter extraction
- Updated notification description rendering to allow HTML content using v-html directive.
- Refactored formatBody method to better handle JSON formatted notifications and extract parameters from nested structures.
- Introduced new method for extracting parameters from value objects, improving compatibility with various notification types.
- Enhanced description formatting to include details from effects, providing richer user feedback in notifications.
2026-01-07 12:09:25 +01:00
Torsten Schulz (local)
d42e1da14b Refactor character creation and heir fetching logic in FalukantService and OverviewView
- Updated character creation process to utilize transactions for improved data integrity during heir generation.
- Enhanced heir fetching logic in OverviewView to check for both mainBranchRegion.id and mainBranchRegionId, adding error handling for missing regions.
- Added warnings for empty heir responses from the API to improve debugging and user feedback.
2026-01-07 11:20:03 +01:00
Torsten Schulz (local)
75dbd78da1 Add regional event handling and character creation logic in FalukantService
- Introduced new regional events ('regional_storm', 'regional_epidemic', 'earthquake') with specific health impact mechanics to enhance gameplay dynamics.
- Updated health modification logic to ensure regional events cause moderate health loss rather than fatal outcomes.
- Implemented character creation logic to generate potential heirs when none are found, including random gender, names, and age attributes.
- Enhanced heir retrieval process to include newly created characters, ensuring a seamless user experience.
2026-01-07 11:13:54 +01:00
Torsten Schulz (local)
c90b7785c0 Add heir selection functionality in Falukant module
- Implemented getPotentialHeirs and selectHeir methods in FalukantService to allow users to retrieve and select potential heirs based on specific criteria.
- Updated FalukantController to wrap new methods with user authentication and added corresponding routes in FalukantRouter.
- Enhanced OverviewView component to display heir selection UI when no character is present, including loading states and error handling.
- Added translations for heir selection messages in both German and English locales to improve user experience.
2026-01-07 10:29:16 +01:00
Torsten Schulz (local)
c17af04cbf Refactor vocabulary search functionality in VocabService and update UI components
- Modified the searchVocabs method in VocabService to consolidate search parameters into a single query term for improved flexibility.
- Updated VocabSearchDialog to replace separate input fields for mother tongue and learning language with a unified search term input.
- Adjusted button logic to enable search only when a term is provided, enhancing user experience.
- Added new translations for the search term in both German and English locales to support the updated UI.
2026-01-05 16:58:18 +01:00
Torsten Schulz (local)
f5e3a9a4a2 Add search functionality for vocabulary in VocabController and VocabService
- Implemented a new searchVocabs method in VocabService to allow users to search for vocabulary based on learning and mother tongue terms.
- Updated VocabController to include the searchVocabs method wrapped with user authentication.
- Added a new route in vocabRouter for searching vocabulary by language ID.
- Enhanced VocabChapterView and VocabLanguageView components to include a button for opening the search dialog.
- Added translations for search-related terms in both German and English locales, improving user accessibility.
2026-01-05 16:53:38 +01:00
Torsten Schulz (local)
dab3391aa2 Refactor socket.io URL normalization logic for improved clarity and robustness
- Simplified the normalization process for socket.io URLs by removing unnecessary production checks.
- Enhanced comments for better understanding of URL handling, particularly regarding environment variables and port management.
- Maintained fallback mechanisms for URL parsing failures to ensure consistent behavior across environments.
2026-01-05 16:43:42 +01:00
Torsten Schulz (local)
0336c55560 Enhance socket.io URL handling for production environments
- Added logic to normalize the socket.io URL based on the current origin and environment.
- Implemented fallback mechanisms for unusual ports in production to ensure secure connections.
- Included error handling for URL parsing failures to default to the current origin, improving robustness.
2026-01-05 16:26:23 +01:00
Torsten Schulz (local)
8e618ab443 Implement TLS support in WebSocket server for secure connections
- Added environment variable configuration for enabling TLS in the WebSocket server.
- Implemented logic to read TLS key, certificate, and optional CA paths from environment variables.
- Enhanced server initialization to handle both secure (WSS) and non-secure (WS) connections based on the TLS setting.
- Included error handling for missing TLS configuration to prevent server startup failures.
2026-01-05 16:06:37 +01:00
Torsten Schulz (local)
352d672bdd Add keyboard handling in VocabPracticeDialog for improved user interaction
- Implemented keydown event listener to manage Enter key functionality for navigating questions and submitting answers.
- Enhanced user experience by allowing Enter to trigger actions based on the current dialog state, improving accessibility during practice sessions.
- Ensured proper cleanup of event listeners when closing the dialog to prevent memory leaks.
2025-12-30 20:22:04 +01:00
Torsten Schulz (local)
df64c0a4b5 Update VocabPracticeDialog and VocabChapterView components to manage practice dialog state
- Changed the close button visibility in VocabPracticeDialog to false for a cleaner UI.
- Enhanced VocabChapterView to manage the practice dialog state with a new `practiceOpen` flag.
- Updated the `openPractice` method to handle the dialog's open and close events, ensuring proper state management.
2025-12-30 18:44:35 +01:00
Torsten Schulz (local)
83597d9e02 Add Vocab Trainer feature with routing, database schema, and translations
- Introduced Vocab Trainer functionality, including new routes for managing languages and chapters.
- Implemented database schema for vocab-related tables to ensure data integrity.
- Updated navigation and UI components to include Vocab Trainer in the social network menu.
- Added translations for Vocab Trainer in both German and English locales, enhancing user accessibility.
2025-12-30 18:34:32 +01:00
Torsten Schulz (local)
a09220b881 Add translations for reputation action school funding in German and English locales 2025-12-23 14:37:33 +01:00
Torsten Schulz (local)
5623f3af09 Refactor error handling in FalukantService and enhance user feedback in HealthView
- Changed error throwing in FalukantService to use PreconditionError for better clarity.
- Added translations for "too close" error in both German and English locales.
- Improved user feedback in HealthView by displaying error messages in a dialog upon measure execution failure.
2025-12-23 12:20:37 +01:00
Torsten Schulz (local)
820b5e8570 Enhance health management in FalukantService to improve user feedback and error handling
- Updated health deduction logic to clarify the distinction between health and monetary costs.
- Added error handling for money update failures to ensure robust transaction processing.
- Modified return value to include updated health status, providing clearer information for UI updates.
2025-12-23 10:51:22 +01:00
Torsten Schulz (local)
dc72ed2feb Add taxFromSaleProduct translation and enhance MoneyHistoryView for product loading
- Added new translation keys for "taxFromSaleProduct" in both German and English locales.
- Updated MoneyHistoryView component to load product data asynchronously and map product IDs to their labels for improved activity translation handling.
- Enhanced activity translation logic to support both legacy and new formats for tax-related activities.
2025-12-22 15:17:51 +01:00
Torsten Schulz (local)
ea468c9878 Improve conditionLabel method in BranchView component to handle edge cases and ensure accurate state representation. Added checks for null and undefined values, and clarified the return value for zero or negative conditions. 2025-12-22 14:54:51 +01:00
Torsten Schulz (local)
d1b683344e Update condition handling in FalukantService and syncDatabase utility for legacy data
- Enhanced condition processing in FalukantService to clamp values between 0 and 100, ensuring UI displays valid data.
- Implemented database cleanup in syncDatabase utility to set NULL conditions to 100 and clamp out-of-range values, improving data integrity.
2025-12-22 13:20:16 +01:00
Torsten Schulz (local)
a82ec7de3f Add cooldown feature for reputation actions in FalukantService and update UI components
- Introduced a cooldown mechanism for reputation actions, limiting execution to once per configured interval.
- Updated FalukantService to handle cooldown logic and return remaining cooldown time.
- Enhanced ReputationView component to display cooldown status and prevent action execution during cooldown.
- Added translations for cooldown messages in both German and English locales.
2025-12-21 22:18:29 +01:00
Torsten Schulz (local)
560a9efc69 Refactor ReputationView component to streamline action display and remove deprecated tab
- Added a new section for displaying reputation actions, including daily limits and action details.
- Removed the 'actions' tab from the navigation and adjusted the logic to reflect this change.
- Enhanced the user interface for executing reputation actions with improved button states and translations.
2025-12-21 21:37:22 +01:00
Torsten Schulz (local)
4f8b1e33fa Update message dialog parameters in ReputationView component for improved clarity
- Swapped the order of parameters in the message dialog open method to prioritize message content over the title, enhancing user experience during reputation action notifications.
2025-12-21 21:14:06 +01:00
Torsten Schulz (local)
38dd51f757 Add reputation actions feature to Falukant module
- Introduced new endpoints for retrieving and executing reputation actions in FalukantController and falukantRouter.
- Implemented service methods in FalukantService to handle reputation actions, including daily limits and action execution logic.
- Updated the frontend ReputationView component to display available actions and their details, including cost and potential reputation gain.
- Added translations for reputation actions in both German and English locales.
- Enhanced initialization logic to set up reputation action types in the database.
2025-12-21 21:09:31 +01:00
Torsten Schulz (local)
38f23cc6ae Enhance getFalukantUserOrFail and createParty methods in FalukantService to support transaction options
- Updated getFalukantUserOrFail to accept an options parameter for transaction handling.
- Refactored createParty to utilize transaction support, ensuring atomic operations for party creation and related financial transactions.
- Improved error handling for party creation, including checks for existing parties within a 24-hour window and validation of selected options.
2025-12-20 23:30:10 +01:00
Torsten Schulz (local)
6cf8fa8a9c Add reputation attribute to FalukantCharacter model and update related services and views
- Introduced a new 'reputation' attribute in the FalukantCharacter model with a default value and validation.
- Updated FalukantService to include 'reputation' in character attributes for API responses.
- Enhanced ReputationView component to display current reputation and load it from the API.
- Added translations for reputation in both German and English locales.
2025-12-20 23:00:31 +01:00
Torsten Schulz (local)
f9ea4715d7 Refactor BranchView component to replace JavaScript alerts with a message dialog for success and error notifications. This improves user experience by providing a more integrated feedback mechanism within the UI. 2025-12-20 22:14:39 +01:00
Torsten Schulz (local)
b34b374f76 Refactor sellAllProducts method in FalukantService to ensure atomic transactions for selling products, updating inventory, and handling financial transactions. Implement batch processing for sell items and enhance error handling for inventory deletions. Update updateFalukantUserMoney function to support transactions, improving consistency and reliability in financial operations. 2025-12-20 22:04:29 +01:00
Torsten Schulz (local)
83d1168f26 Refactor speedLabel method in SaleSection component to move it from computed properties to methods for better compatibility with Vue3. This change ensures the function is callable and maintains its intended functionality. 2025-12-20 21:32:53 +01:00
Torsten Schulz (local)
91009f52cd Refactor SaleSection component to utilize direct property assignment for betterPrices, enhancing reactivity in Vue3. Update inventory mapping to ensure betterPrices is always an array. 2025-12-20 21:28:01 +01:00
Torsten Schulz (local)
c6dfca7052 Enhance SaleSection component to improve UI responsiveness during sales. Reset selling state immediately after sell actions and update inventory handling to ensure user feedback is timely and accurate. 2025-12-20 21:09:10 +01:00
Torsten Schulz (local)
aaeaeeed24 Add request and SQL performance logging features to backend
- Implement request timing middleware in app.js to log slow requests and all requests based on environment variables.
- Enhance sequelize.js with optional SQL query timing and logging capabilities, allowing for performance monitoring of database queries.
2025-12-20 16:35:30 +01:00
Torsten Schulz (local)
c5804f764c Optimize getInventory method in FalukantService by replacing nested includes with a single SQL query for improved performance. Add error handling for invalid branchId input. 2025-12-20 16:13:33 +01:00
Torsten Schulz (local)
fbe0d1bcd1 Add error handling for missing branches in sell batch processing in FalukantService. Ensure that missing branch IDs trigger an error to prevent accounting mismatches. 2025-12-20 16:01:18 +01:00
Torsten Schulz (local)
2fb440f033 Implement synchronous price calculation for batch operations in FalukantService, optimizing performance by reducing database queries. Update inventory handling to batch delete items and enhance revenue calculations. Fix translation formatting in German locale for sellAllSuccess message. 2025-12-20 15:37:16 +01:00
Torsten Schulz (local)
a8a136a9ce Enhance SaleSection component to manage selling state with improved user feedback. Disable buttons during selling, show status messages for sellAll actions, and update translations for new statuses. 2025-12-20 15:35:20 +01:00
Torsten Schulz (local)
fcbb903839 Backend error fixed 2025-12-20 15:06:43 +01:00
Torsten Schulz (local)
ac45a2ba26 Refactor SQL joins in calcRegionalSellPrice function of FalukantService to use updated region type table for improved clarity and consistency in tax calculations. 2025-12-20 15:03:03 +01:00
Torsten Schulz (local)
afe15dd4f5 Refactor tax calculation in calcRegionalSellPrice function of FalukantService to convert exemptTypes Set to PostgreSQL array string for improved query performance and clarity. 2025-12-20 14:54:32 +01:00
Torsten Schulz (local)
e3df88bea0 Enhance getCumulativeTaxPercentWithExemptions function in FalukantService to first retrieve the character associated with the userId, ensuring accurate filtering of political offices by characterId for regional tax calculations. 2025-12-20 11:09:03 +01:00
Torsten Schulz (local)
c69a414f78 Fix cumulative tax calculation in FalukantService by using falukantUser.id instead of user.id for accurate regional tax assessments. 2025-12-20 11:04:21 +01:00
Torsten Schulz (local)
d08022ab94 Merge branch 'main' of ssh://tsschulz.de:2222/torsten/yourpart3 2025-12-20 10:54:58 +01:00
Torsten Schulz (local)
66e6fab663 Refactor getCumulativeTaxPercentWithExemptions function in falukantService.js to filter political offices by userId through the FalukantCharacter model. Update query structure to enhance clarity and ensure accurate data retrieval for regional tax calculations. 2025-12-20 10:48:56 +01:00
4da572822e Merge pull request 'Update dependency nodemon to v3.1.11' (#3) from renovate/nodemon-3.x-lockfile into main
Reviewed-on: #3
2025-12-19 16:16:21 +01:00
ee23bb3ba3 Merge pull request 'Replace dependency npm-run-all with npm-run-all2 ^5.0.0' (#2) from renovate/npm-run-all-replacement into main
Reviewed-on: #2
2025-12-19 16:16:12 +01:00
d002e340dd Update dependency nodemon to v3.1.11 2025-12-19 16:14:33 +01:00
0e1d87ddab Replace dependency npm-run-all with npm-run-all2 ^5.0.0 2025-12-19 16:14:29 +01:00
2a4928c1b6 Merge pull request 'Configure Renovate' (#1) from renovate/configure into main
Reviewed-on: #1
2025-12-19 16:07:26 +01:00
efe2bd57ab Add renovate.json 2025-12-19 16:00:42 +01:00
Torsten Schulz (local)
a0aa678e7d Implement logic to create tables without Foreign Key constraints in sequelize.js when referenced tables do not exist. Enhance error handling and logging to provide clear feedback during synchronization attempts, improving robustness in model management. 2025-12-19 08:37:40 +01:00
Torsten Schulz (local)
a1b6e6ab59 Enhance error handling in sequelize.js for Foreign Key Constraint Errors by adding logging for orphaned records and skipping problematic models during synchronization. Update syncDatabase.js to include cleanup logic for orphaned political_office entries, improving database integrity and user feedback during sync operations. 2025-12-19 08:34:04 +01:00
Torsten Schulz (local)
73acf1d1cd Refactor error handling in sequelize.js to skip model synchronization for cases with duplicate pg_description entries or multiple tables with the same name. Update logging to provide clearer feedback on sync failures and the reasons for skipping models, enhancing user understanding of potential issues. 2025-12-19 08:13:52 +01:00
Torsten Schulz (local)
48110e9a6f Improve error handling and logging for duplicate pg_description cleanup in sequelize.js. Update comments for clarity on permission requirements and provide detailed instructions for manual cleanup by database administrators. Enhance user feedback during synchronization attempts to address potential permission issues. 2025-12-19 07:56:07 +01:00
Torsten Schulz (local)
642e215c69 Refactor duplicate entry cleanup in sequelize.js by replacing DO $$ blocks with direct parameter substitution in SQL queries. This change enhances performance and security while maintaining the logic for cleaning up duplicate pg_description entries before and after model synchronization. 2025-12-19 07:53:34 +01:00
Torsten Schulz (local)
091b9ff70a Enhance model synchronization in sequelize.js by adding logic to clean up duplicate pg_description entries before and after sync attempts. Implement error handling for potential sync failures related to duplicate entries, improving robustness and clarity in foreign key management during model synchronization. 2025-12-18 17:53:24 +01:00
Torsten Schulz (local)
86f753c745 Refactor associations in models to include constraints: false, preventing automatic foreign key creation. Update sequelize.js to enhance foreign key management during model synchronization, ensuring associations are restored correctly after sync operations. 2025-12-18 17:44:17 +01:00
Torsten Schulz (local)
c28f8b1384 Enhance foreign key management in sequelize.js by refining schema handling and improving logging for foreign key removal during model synchronization. Add detailed console outputs for better visibility on foreign key operations and error handling. 2025-12-18 16:45:56 +01:00
Torsten Schulz (local)
9b36297171 Implement foreign key removal before model synchronization in sequelize.js to prevent conflicts during sync. Add error handling and logging for better visibility on foreign key management. 2025-12-18 16:39:34 +01:00
Torsten Schulz (local)
7beed235d7 Improve model synchronization in sequelize.js by temporarily removing associations to prevent automatic foreign key creation. Add logging for association management during the sync process, ensuring clarity in model handling. 2025-12-18 16:36:26 +01:00
Torsten Schulz (local)
a0206dc8cb Add logging for model synchronization and cache handling in syncDatabase.js
Enhance sequelize.js by adding a console log to indicate when models are being synced without constraints. Update syncDatabase.js to include important notes on caching issues with Node.js ES-Modules and log the model loading process during deployment synchronization.
2025-12-18 16:34:10 +01:00
Torsten Schulz (local)
bf0eed3b03 Update model synchronization in sequelize.js to prevent automatic foreign key creation by adding constraints: false, ensuring foreign keys are managed through migrations only. 2025-12-18 16:14:53 +01:00
Torsten Schulz (local)
c8072b8052 Refactor multiple models to remove foreign key references while maintaining required fields, enhancing data integrity and simplifying model definitions. 2025-12-18 16:08:30 +01:00
Torsten Schulz (local)
c66fbf1a62 Enhance syncDatabase function to include cleanup for orphaned child_relation entries with invalid father_character_id, mother_character_id, or child_character_id references, improving data integrity and logging consistency. 2025-12-18 15:59:35 +01:00
Torsten Schulz (local)
e13a711a60 Refactor user_house model to remove default values for houseTypeId and userId fields, and enhance syncDatabase function to include cleanup for orphaned user_house entries with invalid house_type_id or user_id references, improving data integrity and logging. 2025-12-18 15:57:39 +01:00
Torsten Schulz (local)
346a326bfd Enhance syncDatabase function to include cleanup for orphaned promotional_gift entries, removing invalid sender and recipient character references, and improve logging for orphaned entry detection. 2025-12-18 15:49:34 +01:00
Torsten Schulz (local)
addb8e9a6d Refactor Notification model to remove VIRTUAL field definition for characterName and implement a getter method for improved data handling and synchronization. 2025-12-18 15:43:54 +01:00
Torsten Schulz (local)
ea8b9e661d Refactor VIRTUAL field detection logic in sequelize.js to improve accuracy and add special handling for Notification model's characterName field, addressing a Sequelize bug related to field mapping. 2025-12-18 15:37:52 +01:00
Torsten Schulz (local)
339ae844e9 Enhance VIRTUAL field detection in sequelize.js by implementing multiple identification methods, ensuring accurate model synchronization and preventing unintended field removals. 2025-12-18 15:34:26 +01:00
Torsten Schulz (local)
a0a7e81927 Add socket notification for status bar updates in FalukantService and enhance model sync by handling VIRTUAL fields in sequelize.js 2025-12-18 15:25:24 +01:00
Torsten Schulz (local)
31c23a0c40 Refactor syncDatabase function to improve orphaned entry cleanup for knowledge and notification, ensuring data integrity and consistent logging. 2025-12-18 15:20:09 +01:00
Torsten Schulz (local)
c1f22246ea Add cleanup for orphaned notification entries in syncDatabase functions to remove invalid user_id references, improving data integrity and logging consistency. 2025-12-18 15:18:21 +01:00
Torsten Schulz (local)
0a1388bf06 Add cleanup for orphaned knowledge entries in syncDatabase functions to remove invalid character_id and product_id references, enhancing data integrity and logging. 2025-12-18 15:17:01 +01:00
Torsten Schulz (local)
1a69b83983 Refactor stock cleanup logic in syncDatabase functions to remove orphaned stock entries with invalid branch_id and streamline logging for orphaned entries. 2025-12-18 15:13:24 +01:00
Torsten Schulz (local)
63f9443b77 Implement cleanup of orphaned user_param_visibility entries before schema updates in syncDatabase functions 2025-12-18 15:11:50 +01:00
Torsten Schulz (local)
6a9b2b8d1d Add index on (user_id, shown) in notification table to optimize markNotificationsShown queries and prevent deadlocks. Implement transaction handling in markNotificationsShown method for atomic updates. 2025-12-18 15:04:37 +01:00
Torsten Schulz (local)
8e1e0968ae Refactor product model by removing unused sellCostMinNeutral and sellCostMaxNeutral fields, and simplify product insertion logic in initialization script. 2025-12-18 14:41:56 +01:00
Torsten Schulz (local)
a486292880 Activate pgcrypto extension for digest() function in database initialization and migration scripts 2025-12-18 14:11:15 +01:00
Torsten Schulz (local)
ee4b0ee7c2 Füge Spalte product_quality zur Tabelle stock hinzu und erstelle Migration für weather_type_id in production 2025-12-16 13:00:29 +01:00
Torsten Schulz (local)
43d86cce18 Implement tax handling for branches by adding tax percent to regions, updating product sell costs, and enhancing UI for tax summaries in BranchView 2025-12-09 16:16:08 +01:00
Torsten Schulz (local)
25d7c70058 Enhance transport mode handling by adding localized labels and updating related components in MapRegionsView and BranchView 2025-12-09 11:53:56 +01:00
Torsten Schulz (local)
71c62cf5e8 Enhance vehicle speed display by adding localized labels in DirectorInfo, SaleSection, and BranchView components 2025-12-09 11:45:35 +01:00
Torsten Schulz (local)
a7350282ee Enhance parameter extraction in MessagesDialog by merging nested parameters for improved notification handling 2025-12-09 00:12:05 +01:00
Torsten Schulz (local)
676629bd8d Enhance notification enrichment by recursively collecting character IDs and attaching character names 2025-12-09 00:06:09 +01:00
Torsten Schulz (local)
1892877b11 Enhance notification handling by enriching notifications with character names 2025-12-08 23:55:50 +01:00
Torsten Schulz (local)
be218aabf7 Add character_name field and trigger for notifications in Falukant module 2025-12-08 23:37:07 +01:00
Torsten Schulz (local)
856f7d56bf Enhance parameter extraction for notifications in MessagesDialog component 2025-12-08 16:12:05 +01:00
Torsten Schulz (local)
000ebbdc2b Enhance currency formatting in MoneyHistoryView component 2025-12-08 15:35:17 +01:00
Torsten Schulz (local)
791314bef2 Enhance notification display and localization in MessagesDialog component
- Updated the MessagesDialog component to display notifications with titles and descriptions, improving clarity and user experience.
- Enhanced the formatBody method to support new notification structures, including extraction and formatting of parameters for better message presentation.
- Added a new formatParams method to handle various parameter types, ensuring accurate representation of values in notifications.
- Updated localization files in both German and English to include structured titles and descriptions for random events, enriching the user experience with detailed information.
2025-12-08 14:42:17 +01:00
Torsten Schulz (local)
bcb0b01324 Enhance child management features in Falukant module
- Added new translations for gender, baptism status, and child details in both German and English localization files, improving user experience.
- Integrated ChildDetailsDialog component into FamilyView for displaying detailed information about children.
- Updated the showChildDetails method to utilize the new dialog for better user interaction.
- Modified button styles for improved visual feedback when setting heirs.
2025-12-08 13:30:11 +01:00
Torsten Schulz (local)
03e3a21a25 Add heir management functionality in Falukant module
- Implemented setHeir method in FalukantService to designate a child as heir, including validation checks for user and child relationships.
- Updated FalukantController to expose the setHeir endpoint, allowing users to set heirs via the API.
- Enhanced FalukantRouter with a new route for setting heirs.
- Modified FamilyView component to include UI elements for setting heirs, with success and error feedback.
- Updated localization files in both German and English to include new translations related to heir management, improving user experience.
2025-12-08 13:22:43 +01:00
Torsten Schulz (local)
e97a2a62c9 Enhance weather data handling in FalukantService and update localization files
- Modified the FalukantService to explicitly load weather data for all regions, ensuring accurate weather information is associated with branches.
- Updated the return logic to utilize the newly loaded weather data, improving data accuracy in branch responses.
- Added new random event messages in both German and English localization files, enhancing user experience with richer event descriptions.
2025-12-08 11:54:10 +01:00
Torsten Schulz (local)
814f972287 Update branch selection logic in BranchView component
- Enhanced the onBranchSelected method to reload branches for updated weather information and reset the selected branch after reloading.
- Improved user experience by ensuring the correct branch is selected post-refresh, maintaining data accuracy and consistency.
2025-12-08 11:34:50 +01:00
Torsten Schulz (local)
274c2a3292 Add income update success message in DirectorInfo component
- Implemented a success message display for income updates in the DirectorInfo component, enhancing user feedback after successful updates.
- Added a timeout to automatically hide the success message after 3 seconds.
- Updated localization files to include new translations for income-related messages in both German and English, improving user experience for multilingual users.
2025-12-08 11:30:31 +01:00
Torsten Schulz (local)
4dbcebfab8 Add handling for transport removal events in BranchView component
- Implemented logic to update vehicle and inventory data when a transport is removed, ensuring real-time synchronization with the selected branch.
- Enhanced the component to refresh relevant sections (vehicles, inventory, storage) based on the transport removal event, improving user experience and data accuracy.
2025-12-08 09:36:18 +01:00
Torsten Schulz (local)
fadc301d41 Add bulk vehicle repair functionality in Falukant module
- Implemented a new repairAllVehicles method in FalukantService to handle the repair of multiple vehicles at once, including cost calculation and precondition checks.
- Updated FalukantController to expose the repairAllVehicles endpoint, allowing users to initiate bulk repairs via the API.
- Enhanced FalukantRouter to include a new route for bulk vehicle repairs.
- Modified BranchView component to add UI elements for repairing all vehicles, including a dialog for confirmation and displaying repair details.
- Updated German localization files to include translations related to bulk vehicle repair actions, improving user experience for German-speaking users.
2025-12-08 08:36:21 +01:00
Torsten Schulz (local)
b1d29f2083 Enhance nobility ID validation in FalukantService
- Added checks to ensure that provided nobility IDs are valid and exist in the database, improving error handling and user feedback.
- Updated logic to use loaded nobility objects when adding invited nobilities to a party, optimizing database interactions.
2025-12-05 20:49:12 +01:00
Torsten Schulz (local)
e756b3692d Refactor availability status logic in FalukantService
- Enhanced the logic for determining the availability status of vehicles based on the 'availableFrom' date.
- Added conditions to differentiate between 'building' and 'available' statuses, improving clarity and accuracy in status reporting.
- Updated comments for better understanding of the code flow.
2025-12-05 17:23:54 +01:00
Torsten Schulz (local)
74a3d59800 Add vehicle repair functionality in Falukant module
- Implemented a new repairVehicle method in FalukantService to handle vehicle repairs, including cost calculation and precondition checks.
- Updated FalukantController to expose the repairVehicle endpoint, allowing users to initiate repairs via the API.
- Enhanced FalukantRouter to include a new route for vehicle repairs.
- Modified BranchView component to add UI elements for repairing vehicles, including a dialog for repair confirmation and displaying repair details.
- Updated German localization files to include translations related to vehicle repair actions, improving user experience for German-speaking users.
2025-12-05 14:40:55 +01:00
Torsten Schulz (local)
0544a3dfde Add transport and inventory update handling in BranchView component
- Implemented socket event listeners for 'transport_arrived' and 'inventory_updated' to manage real-time updates in the BranchView component.
- Enhanced event handling logic to refresh vehicle and inventory data based on the selected branch, improving user experience and data accuracy.
- Updated the component to ensure proper cleanup of socket listeners on component destruction, maintaining optimal performance.
2025-12-05 14:13:14 +01:00
Torsten Schulz (local)
656c821986 Enhance SaleSection component to group and display transport data
- Updated SaleSection.vue to group running transports by relevant attributes, improving data organization and readability.
- Added a new computed property to calculate vehicle counts and total quantities for grouped transports.
- Introduced a new column in the UI to display the count of vehicles associated with each transport group.
- Updated German localization file to include translation for 'runningVehicleCount', enhancing user experience for German-speaking users.
2025-12-05 13:12:24 +01:00
Torsten Schulz (local)
865ef81012 Enhance FalukantService and UI components for improved product handling
- Updated FalukantService to allow optional inclusion of productType in queries, enhancing flexibility in data retrieval.
- Modified SaleSection.vue to conditionally display product information and size, improving user experience by handling cases with no product.
- Added new German translation for 'runningNoProduct' to enhance localization support for users.
2025-12-05 13:07:31 +01:00
Torsten Schulz (local)
5ad27a87e5 Enhance vehicle transport functionality in FalukantService and update UI components
- Modified the createTransport method in FalukantService to support optional vehicleIds, allowing for more flexible vehicle selection.
- Implemented logic to ensure that either specific vehicleIds or a vehicleTypeId must be provided, improving error handling for vehicle availability.
- Updated the BranchView component to include new UI elements for sending vehicles, including buttons for sending single or multiple vehicles of the same type.
- Added a modal dialog for selecting target branches when sending vehicles, enhancing user experience and streamlining transport operations.
- Updated German localization files to include new translations related to vehicle actions and transport functionalities.
2025-12-05 12:49:37 +01:00
Torsten Schulz (local)
085b851925 Add German translation for 'townhouse' in falukant.json
- Updated the German localization file to include the translation for 'townhouse' as 'Stadthaus'.
- This addition enhances the application's multilingual support and improves user experience for German-speaking users.
2025-12-05 11:42:41 +01:00
Torsten Schulz (local)
98dea7dd39 Implement empty transport feature in DirectorInfo component
- Added functionality to allow directors to initiate empty transports without products, enhancing logistics management.
- Introduced a new transport form in the DirectorInfo component, enabling selection of vehicle types and target branches.
- Updated the i18n localization files to include new translations for the empty transport feature.
- Enhanced the BranchView to pass vehicle and branch data to the DirectorInfo component, ensuring proper functionality.
- This update aims to improve user experience and streamline transport operations within the application.
2025-12-04 14:48:55 +01:00
Torsten Schulz (local)
e5ef334f7c Update FalukantService and PoliticsView to enhance election data handling
- Modified the FalukantService to use getOpenPolitics instead of getElections for retrieving accessible elections, improving alignment with frontend data display.
- Updated the PoliticsView to handle the response from the application submission more effectively, ensuring that already applied positions remain pre-selected after submission.
- These changes aim to streamline the election data flow and enhance user experience in the application process.
2025-12-03 17:19:13 +01:00
Torsten Schulz (local)
d6ea09b3e2 Enhance RevenueSection UI and streamline price loading logic
- Updated the display of city prices in the RevenueSection component to include both city names and formatted price values, improving user experience.
- Removed unnecessary console logs from the loadPricesForAllProducts method to clean up the code and reduce clutter, while maintaining essential functionality.
- Simplified the getBetterPrices method by eliminating redundant logging, enhancing code clarity and performance.
2025-12-03 16:30:10 +01:00
Torsten Schulz (local)
a51b8a1ff6 Fix 2025-12-03 16:29:56 +01:00
Torsten Schulz (local)
3c885b6ab9 Add detailed debug logging in loadPricesForAllProducts method of RevenueSection
- Enhanced the loadPricesForAllProducts method with additional console logs to track the loading process of product prices, including the current region ID and the number of products being processed.
- Improved visibility into the state of betterPricesMap after updates and provided detailed logs for each product's price loading, facilitating easier debugging and monitoring of price retrieval.
- Aims to enhance traceability and provide clearer insights into the price handling process within the RevenueSection component.
2025-12-03 16:22:08 +01:00
Torsten Schulz (local)
6b3b30108b Refactor betterPricesMap updates in RevenueSection for Vue 3 reactivity
- Updated the handling of betterPricesMap to create a new object for state updates, ensuring reactivity in Vue 3.
- This change replaces direct assignments with spread operator syntax to maintain the integrity of the reactive system.
- Aims to improve performance and align with Vue 3 best practices for state management.
2025-12-03 16:15:01 +01:00
Torsten Schulz (local)
7fab23d22b Refactor betterPricesMap handling in RevenueSection for Vue 3 compatibility
- Removed the use of $set for updating betterPricesMap, leveraging direct assignment instead, which is now the standard in Vue 3.
- Simplified the getBetterPrices method by eliminating unnecessary logging, enhancing code clarity while maintaining functionality.
- These changes aim to improve performance and align with Vue 3 best practices for state management.
2025-12-03 16:03:06 +01:00
Torsten Schulz (local)
def88f6486 Add debug logging in RevenueSection for better price retrieval tracking
- Introduced console logs to track the number of better prices received for each product and the state of the betterPricesMap after updates.
- Enhanced the getBetterPrices method with logging to provide visibility into the prices being returned, improving traceability during price evaluations.
- These changes aim to facilitate debugging and provide clearer insights into the price handling process within the RevenueSection component.
2025-12-03 15:59:15 +01:00
Torsten Schulz (local)
1797ae3e58 Remove debug logging from getProductPricesInCities method in FalukantService
- Eliminated console logs that tracked various parameters and results within the getProductPricesInCities method, streamlining the code and reducing output clutter.
- This change aims to enhance code readability and maintain focus on essential functionality while maintaining the integrity of the price calculation process.
2025-12-03 15:55:30 +01:00
Torsten Schulz (local)
f768ba3b27 Add debug logging for priceInCity in getProductPricesInCities method of FalukantService
- Introduced a console log to capture the values of priceInCity, currentPrice, and PRICE_TOLERANCE, enhancing visibility into the price comparison process.
- This addition aims to improve traceability and facilitate debugging during price evaluations, building on previous logging enhancements.
2025-12-03 15:39:57 +01:00
Torsten Schulz (local)
b3e48a0b06 Refine price comparison logic in getProductPricesInCities method of FalukantService
- Introduced a small tolerance (0.01) for rounding errors in the price comparison, allowing for more accurate evaluations when determining if a city's price exceeds the current price.
- This change enhances the robustness of price calculations by accommodating potential floating-point inaccuracies.
2025-12-03 15:35:19 +01:00
Torsten Schulz (local)
3f56939421 Add detailed debug logging in getProductPricesInCities method of FalukantService
- Introduced console logs to trace the execution flow and key variables in the getProductPricesInCities method, enhancing visibility into product price calculations.
- Logged parameters such as productId, currentPrice, and currentRegionId at the start of the method.
- Added logs for the number of cities and town worth entries found, as well as details when skipping the current city and adding results, improving traceability during price evaluations.
- This update aims to facilitate debugging and performance monitoring by providing comprehensive insights into the pricing logic.
2025-12-03 13:39:09 +01:00
Torsten Schulz (local)
87c720c3fe Refactor RevenueSection to utilize a betterPricesMap for improved price handling
- Replaced direct product.betterPrices usage with a betterPricesMap to store prices separately, enhancing data management.
- Updated computed properties and methods to clear betterPricesMap when product list or region changes, ensuring accurate price loading.
- Introduced getBetterPrices method for cleaner access to price data, improving code readability and maintainability.
2025-12-03 13:32:02 +01:00
Torsten Schulz (local)
90fbcaf31d Refactor and remove debug logging in FalukantService and RevenueSection for cleaner code
- Eliminated console logs in the getProductPricesInCities method of FalukantService to streamline the price calculation process and reduce clutter in the output.
- Removed unnecessary debug logs in RevenueSection related to loading product prices, enhancing performance and focusing on essential functionality.
- Improved overall code readability by reducing logging noise while maintaining necessary functionality.
2025-12-03 11:35:13 +01:00
Torsten Schulz (local)
56c3569b68 Refactor debug logging in FalukantService for improved clarity and consistency
- Removed specific debug logs for the carrot product and replaced them with generalized logs for all products, enhancing the readability of the logging output.
- Updated console logs to provide clearer information about the processing of cities and the results returned, improving traceability during price calculations.
- Ensured that all relevant details are logged consistently, aiding in debugging and performance monitoring across different product types.
2025-12-03 11:32:06 +01:00
Torsten Schulz (local)
e2969c1837 Enhance RevenueSection to conditionally load product prices based on currentRegionId
- Updated watchers in RevenueSection to ensure product prices are only loaded when currentRegionId is not null.
- Added a check in loadPricesForAllProducts to skip execution if currentRegionId is null or undefined, improving performance and preventing unnecessary calls.
- Enhanced overall logic to ensure accurate price loading based on the selected region, contributing to a better user experience.
2025-12-03 09:19:46 +01:00
Torsten Schulz (local)
fe14c7b9f5 Add debug logging for product price retrieval in FalukantService and RevenueSection
- Introduced console logs in FalukantService to trace the parameters used in the getProductPricesInCities method, enhancing visibility into the product price retrieval process.
- Added logging in RevenueSection to capture the loading process and received better prices for products, improving traceability and debugging capabilities during price loading operations.
2025-12-03 08:58:07 +01:00
Torsten Schulz (local)
5d01b24c2d Add debug logging for carrot product pricing in FalukantService
- Introduced console logs to trace the price calculation process specifically for the carrot product (productId: 3).
- Enhanced visibility into the evaluation of cities and the resulting prices, aiding in debugging and performance monitoring.
- Logged details when skipping the current city, calculating prices, and adding cities to the results, improving traceability of pricing logic.
2025-12-03 08:44:45 +01:00
Torsten Schulz (local)
4eeb5021ee Enhance product price retrieval by including currentRegionId in FalukantController and FalukantService
- Updated the FalukantController to accept currentRegionId as a parameter for fetching product prices in cities.
- Modified the FalukantService to incorporate currentRegionId in the price calculation logic, allowing exclusion of the current region from results.
- Adjusted frontend components to pass currentRegionId, improving the accuracy of price comparisons and user experience.
2025-12-03 08:39:30 +01:00
Torsten Schulz (local)
6ec62af606 Add debug logging to FalukantService and RevenueSection for better price tracking
- Introduced console logs in FalukantService to trace product price calculations and city evaluations, aiding in debugging and performance monitoring.
- Added logging in RevenueSection to capture the loading process and received better prices for products, enhancing visibility into price retrieval operations.
2025-12-03 08:30:59 +01:00
Torsten Schulz (local)
3d6fdc65d2 Refine price comparison logic in FalukantService to include a tolerance for rounding errors
- Updated the price comparison condition to account for a small tolerance (0.001) when determining if the calculated price in a city exceeds the current price, improving accuracy in pricing evaluations.
2025-12-02 15:57:29 +01:00
Torsten Schulz (local)
956418f5f3 Enhance weather model and service logic; improve money history translation handling
- Added primary key to the Weather model for better data integrity.
- Updated FalukantService to include specific weather attributes in queries, enhancing data retrieval.
- Refactored money history view to utilize a dedicated translation method for improved localization handling.
2025-12-02 14:05:25 +01:00
Torsten Schulz (local)
e57de7f983 Fix typo in healthDrunkOfLife method and enhance health change logic in FalukantService; refactor health measures localization structure in English and German JSON files for better organization. 2025-12-02 13:05:39 +01:00
Torsten Schulz (local)
08e2c87de8 Enhance branch selection with weather information and localization updates
- Updated FalukantService to include weather data in branch retrieval, enhancing user context.
- Modified BranchSelection component to display current weather for selected branches, improving user experience.
- Added weather translations in both English and German localization files for better accessibility.
- Improved styling for weather information display in the frontend.
2025-12-02 12:53:02 +01:00
Torsten Schulz (local)
ba1a12402d Add product weather effects and regional pricing enhancements
- Introduced a new endpoint in FalukantController to retrieve product prices based on region and product ID.
- Implemented logic in FalukantService to calculate product prices considering user knowledge and regional factors.
- Added weather-related data models and associations to enhance product pricing accuracy based on weather conditions.
- Updated frontend components to cache and display regional product prices effectively, improving user experience.
2025-12-02 09:55:08 +01:00
Torsten Schulz (local)
39716b1f40 Add regional pricing calculation for products in FalukantService
- Introduced a new function `calcRegionalSellPrice` to compute product prices based on regional worth percentages.
- Updated existing methods to utilize the new pricing logic, ensuring revenue calculations reflect regional variations.
- Integrated retrieval of `TownProductWorth` data to enhance pricing accuracy across different regions.
2025-12-02 08:44:53 +01:00
Torsten Schulz (local)
adc7132404 Add product price retrieval feature in cities
- Implemented a new endpoint in FalukantController to fetch product prices in various cities based on product ID and current price.
- Developed the corresponding service method in FalukantService to calculate and return prices, considering user knowledge and city branches.
- Updated frontend components (RevenueSection and SaleSection) to display better prices for products, including loading logic and UI enhancements for price visibility.
- Added styling for price indicators based on branch types to improve user experience.
2025-12-01 16:42:54 +01:00
Torsten Schulz (local)
8c8841705c Implement daemon socket listener management in BranchView.vue
- Added a watcher for the daemon socket to properly register and unregister message event listeners on socket changes.
- Simplified the event listener setup for handling daemon messages, improving code clarity and maintainability.
- Ensured that listeners are removed during component unmount to prevent memory leaks.
2025-12-01 14:06:18 +01:00
Torsten Schulz (local)
f7fdd8ab08 Refactor localization structure for production notifications in English and German
- Updated the localization files to nest the "overproduction" notification under a "production" key for better organization and clarity.
- Ensured consistency in translation structure across both English and German localization files.
2025-12-01 11:51:37 +01:00
Torsten Schulz (local)
5807c6f3d3 Update daemon socket configuration and fallback logic in frontend scripts
- Changed the default value for `VITE_DAEMON_SOCKET` in `deploy-frontend.sh` and `update-frontend.sh` to connect directly to port 4551 instead of using the Apache proxy.
- Updated fallback logic in `frontend/src/store/index.js` to reflect the new direct connection to the daemon on port 4551, enhancing connection reliability.
2025-12-01 11:46:50 +01:00
Torsten Schulz (local)
7e0691eea3 Enhance message formatting and localization handling in MessagesDialog.vue
- Updated the formatBody method to support JSON formatted translation keys and improve key normalization for i18n.
- Ensured that keys are correctly prefixed with the "falukant.notifications." namespace when necessary, enhancing translation accuracy.
2025-12-01 11:26:46 +01:00
Torsten Schulz (local)
17d4d21620 Add new daemon start script and update localization for director salary
- Introduced a new script `start-daemon` in `package.json` for running the daemon server.
- Added translations for "director payed out" in both English and German localization files to enhance user notifications.
2025-12-01 10:06:06 +01:00
Torsten Schulz (local)
d19feb8bc1 Update daemon socket URL and enhance message rendering in frontend
- Changed the default value for `VITE_DAEMON_SOCKET` in `deploy-frontend.sh` and `update-frontend.sh` to use the `/ws/` path.
- Updated the message rendering logic in `MessagesDialog.vue` to utilize a new `formatBody` method for improved translation handling.
- Added a new translation for "overproduction" in both English and German localization files.
2025-12-01 09:47:16 +01:00
Torsten Schulz (local)
ab1e4bec60 Update localization for notifications in English and German
- Added new notification translations for election creation in both `falukant.json` files.
- Updated the message rendering in `MessagesDialog.vue` to include the new translation structure.
2025-12-01 09:32:59 +01:00
Torsten Schulz (local)
672cec9c2a Add localization updates for money history in English and German 2025-12-01 09:28:44 +01:00
Torsten Schulz (local)
c3ea7eecc2 Update dependencies and refactor authentication logic
- Replaced `bcrypt` with `bcryptjs` for compatibility in `authService.js` and `settingsService.js`.
- Updated package versions in `package.json` and `package-lock.json`, including `multer`, `nodemailer`, and others.
- Added storage management features in the frontend, including free storage calculation and localization updates for new terms in `falukant.json` files.
2025-11-26 18:14:36 +01:00
Torsten Schulz (local)
608e62c2bd Implement cooldown feature for nobility advancement
- Added logic in FalukantService to calculate the next available advancement date based on the user's last advancement.
- Updated the frontend to display a cooldown message indicating when the user can next advance in nobility.
- Enhanced the NobilityView component to handle and format the next advancement date appropriately.
2025-11-26 17:23:54 +01:00
Torsten Schulz (local)
c1b69389c6 Add lastNobilityAdvanceAt field and update logic in FalukantService
- Introduced a new field `lastNobilityAdvanceAt` in the FalukantUser model to track the last time a user advanced in nobility.
- Updated the `FalukantService` to enforce a one-week cooldown between nobility advancements, throwing an error if the user attempts to advance too soon.
- Ensured the `lastNobilityAdvanceAt` field is updated with the current date upon a successful nobility advancement.
2025-11-26 17:17:37 +01:00
Torsten (PC)
182f38597c update-funktion verbessert 2025-11-26 17:16:30 +01:00
Torsten Schulz (local)
06ea259dc9 Add Falukant region and transport management features
- Implemented new endpoints in AdminController for managing Falukant regions, including fetching, updating, and deleting region distances.
- Enhanced the FalukantService with methods for retrieving region distances and handling upsert operations.
- Updated the router to expose new routes for region management and transport creation.
- Introduced a transport management interface in the frontend, allowing users to create and manage transports between branches.
- Added localization for new transport-related terms and improved the vehicle management interface to include transport options.
- Enhanced the database initialization logic to support new region and transport models.
2025-11-26 16:44:27 +01:00
Torsten Schulz (local)
29dd7ec80c Refactor daemon connection logic and enhance error handling
- Simplified fallback logic for daemon URL generation, removing hardcoded values and using dynamic protocol and hostname.
- Added detailed error messages for common WebSocket connection issues, improving debugging capabilities.
- Updated reconnection warning messages to guide users on potential configuration issues with the daemon server.
2025-11-24 20:28:11 +01:00
Torsten Schulz (local)
3f043fc315 Add vehicle management features in Falukant
- Introduced vehicle types and transport management in the backend, including new models and associations for vehicles and transports.
- Implemented service methods to retrieve vehicle types and handle vehicle purchases, ensuring user validation and transaction management.
- Updated the FalukantController and router to expose new endpoints for fetching vehicle types and buying vehicles.
- Enhanced the frontend with a new transport tab in BranchView, allowing users to buy vehicles, and added localization for vehicle-related terms.
- Included initialization logic for vehicle types in the database setup.
2025-11-24 20:15:45 +01:00
Torsten Schulz (local)
5ed27e5a6a Refactor navigation and enhance director information display
- Removed the directors section from the navigation menu for a cleaner interface.
- Updated the FalukantService to include additional attributes for directors, such as knowledges and region.
- Enhanced the DirectorInfo component to display detailed information, including knowledge and income management features.
- Implemented tab navigation in BranchView for better organization of director, inventory, production, and storage sections.
- Updated localization files to reflect changes in navigation and tab labels.
2025-11-24 16:38:36 +01:00
Torsten Schulz (local)
23725c20ee Enhance mood change calculation in FalukantService
- Updated the mood change calculation to include a random bonus between 0 and 7 points, improving variability in user experience.
- Refactored the calculation logic for clarity, separating the base change value from the random bonus.
2025-11-24 15:51:27 +01:00
Torsten Schulz (local)
29b6db7ee9 Update dropdown positioning in FormattedDropdown component for improved visibility
- Changed the dropdown list positioning from normal document flow to absolute positioning, ensuring the list is reliably visible when opened.
2025-11-24 15:39:44 +01:00
Torsten Schulz (local)
6e7165fe7f Add console log to toggleDropdown method in FormattedDropdown component for debugging 2025-11-24 15:33:55 +01:00
Torsten Schulz (local)
43131250ed Fix dropdown toggle method in FormattedDropdown component to ensure proper function call 2025-11-24 15:26:44 +01:00
Torsten Schulz (local)
c3beb029e5 Refactor FormattedDropdown and enhance BranchView functionality
- Updated the FormattedDropdown component to use normal document flow for the dropdown list, ensuring visibility when opened.
- Enhanced the createBranch method in BranchView to automatically select the most recently created branch after a new branch is added, improving user experience.
2025-11-24 15:19:35 +01:00
Torsten Schulz (local)
9f10ac9e96 Enhance BranchSelection component to force re-render on branch list change
- Added a computed property `branchesKey` to generate a unique key based on branch IDs, ensuring the dropdown re-renders when the branch list updates.
- Updated the FormattedDropdown component to utilize this key for improved responsiveness to data changes.
2025-11-24 13:45:04 +01:00
Torsten Schulz (local)
d36901aa2b Refactor tab change logic in PoliticsView to simplify loading conditions
- Updated the onTabChange method to remove unnecessary checks for existing data before loading current positions, open politics, and elections.
- This change enhances the clarity of the method and ensures that data is always loaded when the respective tab is selected.
2025-11-24 12:24:31 +01:00
Torsten Schulz (local)
4510aa3d14 Implement politics overview feature in FalukantService and update UI
- Added a new method `getPoliticsOverview` in FalukantService to retrieve currently held offices, including office holders and term end dates.
- Enhanced the PoliticsView component to display the term end dates for current offices.
- Updated localization files to include a new message for applying to selected positions.
- Improved the handling of already applied positions in the open politics section, pre-selecting checkboxes accordingly.
2025-11-24 11:50:21 +01:00
Torsten Schulz (local)
3b8736acd7 Enhance WebSocketLogDialog to display enriched user information
- Updated the WebSocketLogDialog to use enriched log entries with resolved usernames for connection and target users.
- Implemented batch retrieval of user information from the API to improve user display in logs.
- Added error handling for user data fetching and fallback logic for missing usernames.
2025-11-22 13:32:44 +01:00
Torsten Schulz (local)
735075d1bd Add WebSocket Log feature to Services Status View
- Introduced a WebSocket Log section in the Services Status View, allowing users to view real-time logs.
- Updated localization files for both German and English to include WebSocket Log messages.
- Enhanced the UI with a button to open the WebSocket Log dialog, improving user interaction and monitoring capabilities.
2025-11-22 13:21:13 +01:00
Torsten Schulz (local)
dc7001a80c Implement batch user retrieval in AdminController and update routes
- Added a new method `getUsers` in AdminController to handle batch retrieval of user information based on hashed IDs.
- Updated adminRouter to include a new route for batch user retrieval.
- Enhanced AdminService with a method to fetch user details by hashed IDs, ensuring proper access control.
- Updated localization files to include the new "username" field for user connections in both German and English.
- Modified ServicesStatusView to utilize the new batch user retrieval for displaying usernames alongside connection counts.
2025-11-21 23:49:05 +01:00
Torsten Schulz (local)
8a9acf6c4a Refactor ServicesStatusView to handle daemon response structure
- Updated the handling of daemon responses to accommodate a new structure that includes user connection counts.
- Transformed the users object into an array for easier template rendering.
- Improved error handling for JSON parsing and daemon message processing.
2025-11-21 23:45:29 +01:00
Torsten Schulz (local)
5ca017950e Remove Google Chrome RPM package file 2025-11-20 15:52:04 +01:00
Torsten Schulz (local)
eadec50e30 Feature: Add Services Status page and update navigation
- Introduced a new Services Status page to monitor the status of Backend, Chat, and Daemon services.
- Updated navigation structure to include the new Services Status link for main admin users.
- Added German and English localization for the Services Status page, including titles, descriptions, and status messages.
2025-11-20 15:49:08 +01:00
Torsten Schulz (local)
e7f5918013 Enhance Vite configuration to load environment variables
- Refactored Vite configuration to load environment variables explicitly based on the current mode
- Added support for additional environment variables: VITE_DAEMON_SOCKET, VITE_API_BASE_URL, VITE_CHAT_WS_URL, and VITE_SOCKET_IO_URL
- Improved clarity and maintainability of the configuration structure
2025-11-18 08:59:20 +01:00
Torsten Schulz (local)
27b675cb19 Refactor daemon URL configuration and enhance logging
- Improved fallback logic for daemon URL based on hostname and environment
- Added detailed logging for daemon connection status and environment settings
- Streamlined handling of environment variables for better clarity
2025-11-18 08:50:25 +01:00
Torsten Schulz (local)
016a37c116 Refactor daemon connection logic and enhance logging
- Improved handling of daemon URL configuration based on environment variables
- Added detailed logging for daemon connection status and environment settings
- Streamlined fallback logic for local development and production environments
2025-11-18 08:37:02 +01:00
Torsten Schulz (local)
d8b1efc3ca Enhance StatusBar and daemon connection management
- Added image preloading for quick access in StatusBar component
- Implemented a watcher to reload images when the menu changes
- Introduced a delay before sending 'setUserId' to ensure daemon readiness
- Improved logging for WebSocket close events and errors
2025-11-17 16:19:43 +01:00
Torsten Schulz (local)
d13fe19198 Fix: Enhance daemon connection management and retry logic
- Clear socket reference on connection close and error
- Ensure reconnection attempts only occur if the user is logged in
- Improved logging for reconnection attempts and retry count
- Added maximum retry limit with extended wait time after reaching it
2025-11-16 11:33:20 +01:00
Torsten Schulz (local)
762a2e9cf0 Fix: Improve daemon connection handling and retry logic
- Reset daemon connection state on successful connection and errors
- Clear retry timer when connection is established
- Enhanced retry logic to prevent multiple simultaneous connection attempts
- Improved logging for daemon reconnection attempts
2025-10-31 16:24:35 +01:00
Torsten Schulz (local)
44a2c525e7 Fix: Restore original avatar images
- Avatar images should not be optimized as they are used for character display
- Restored original 1792x1024 resolution for proper character appearance
- Only small icons should be optimized, not character avatars
2025-10-24 23:22:22 +02:00
Torsten Schulz (local)
507b0275d3 Performance: Optimize all images and improve error handling
- Optimized Falukant shortmap icons: 1.4MB-2.9MB → 1.9KB-3.3KB (99%+ reduction)
- Optimized all large images: avatars, maps, passengers, products, etc.
- Improved error handling in getGifts method with better logging
- Fixed icon loading performance issues
- Maintained original design while dramatically improving load times
- Total space savings: ~100MB+
2025-10-24 23:18:18 +02:00
Torsten Schulz (local)
ccd8bfba0d Feature: Termine-Anzeige auf der Startseite
- Neue CSV-Datei backend/data/termine.csv für Termine-Speicherung
- Backend-Controller und Router für /api/termine Endpoint
- TermineWidget Component zur Anzeige von bevorstehenden Terminen
- Integration in LoggedInView (Startseite für eingeloggte User)
- Zeigt Datum, Titel, Beschreibung, Ort und Uhrzeit an
- Sortiert nach Datum, filtert automatisch vergangene Termine
2025-10-20 22:27:35 +02:00
Torsten Schulz (local)
47f5def67c Fix: Korrekter Tabellenname für UserRightType Model
- Ändere tableName von 'user_right_type' zu 'user_right'
- Die Tabelle heißt type.user_right, nicht type.user_right_type
- Behebt: Verwaltungsmenü wird nicht angezeigt für mainadmin
2025-10-20 21:33:12 +02:00
231 changed files with 19748 additions and 3214 deletions

230
PERFORMANCE_ANALYSIS.md Normal file
View File

@@ -0,0 +1,230 @@
# Backend Performance-Analyse: Sell-Funktionen
## Identifizierte Performance-Probleme
### 1. **N+1 Query Problem in `sellAllProducts()`**
**Problem:**
Die Funktion `sellAllProducts()` macht für jedes Inventory-Item mehrere separate Datenbankabfragen:
1. **Erste Schleife (Zeile 1702-1711):**
- `calcRegionalSellPrice()` → macht `TownProductWorth.findOne()` für jedes Item
- `getCumulativeTaxPercentWithExemptions()` → macht mehrere Queries pro Item:
- `FalukantCharacter.findOne()`
- `PoliticalOffice.findAll()` mit Includes
- Rekursive SQL-Query für Steuerberechnung
- `addSellItem()` → macht `Branch.findOne()` und `DaySell.findOne()`/`create()` für jedes Item
2. **Zweite Schleife (Zeile 1714-1724):**
- `RegionData.findOne()` für jedes Item
- `getCumulativeTaxPercent()` → rekursive SQL-Query für jedes Item
- `calcRegionalSellPrice()` → erneut `TownProductWorth.findOne()` für jedes Item
**Beispiel:** Bei 10 Items werden gemacht:
- 10x `TownProductWorth.findOne()` (2x pro Item)
- 10x `RegionData.findOne()`
- 10x `getCumulativeTaxPercentWithExemptions()` (mit mehreren Queries)
- 10x `getCumulativeTaxPercent()` (rekursive SQL)
- 10x `addSellItem()` (mit 2 Queries pro Item)
- = **~70+ Datenbankabfragen für 10 Items**
### 2. **Ineffiziente `addSellItem()` Implementierung**
**Problem:**
- Wird für jedes Item einzeln aufgerufen
- Macht `Branch.findOne()` für jedes Item (könnte gecacht werden)
- `DaySell.findOne()` und `create()`/`update()` für jedes Item
**Lösung:** Batch-Operation implementieren, die alle DaySell Einträge auf einmal verarbeitet.
### 3. **Doppelte Berechnungen in `sellAllProducts()`**
**Problem:**
- Preis wird zweimal berechnet (Zeile 1705 und 1718)
- Steuer wird zweimal berechnet (Zeile 1706 und 1717)
- `calcRegionalSellPrice()` wird zweimal aufgerufen mit denselben Parametern
### 4. **Fehlende Indizes**
**Potenzielle fehlende Indizes:**
- `falukant_data.town_product_worth(product_id, region_id)` - sollte unique sein
- `falukant_data.inventory(stock_id, product_id, quality)` - für schnelle Lookups
- `falukant_data.knowledge(character_id, product_id)` - für Knowledge-Lookups
- `falukant_data.political_office(character_id)` - für Steuerbefreiungen
### 5. **Ineffiziente `getCumulativeTaxPercentWithExemptions()`**
**Problem:**
- Lädt alle PoliticalOffices jedes Mal neu, auch wenn sich nichts geändert hat
- Macht komplexe rekursive SQL-Query für jedes Item separat
- Könnte gecacht werden (z.B. pro User+Region Kombination)
## Empfohlene Optimierungen
### 1. **Batch-Loading für `sellAllProducts()`**
```javascript
async sellAllProducts(hashedUserId, branchId) {
// ... existing code ...
// Batch-Load alle benötigten Daten VOR den Schleifen
const regionIds = [...new Set(inventory.map(item => item.stock.branch.regionId))];
const productIds = [...new Set(inventory.map(item => item.productType.id))];
// 1. Lade alle TownProductWorth Einträge auf einmal
const townWorths = await TownProductWorth.findAll({
where: {
productId: { [Op.in]: productIds },
regionId: { [Op.in]: regionIds }
}
});
const worthMap = new Map();
townWorths.forEach(tw => {
worthMap.set(`${tw.productId}-${tw.regionId}`, tw.worthPercent);
});
// 2. Lade alle RegionData auf einmal
const regions = await RegionData.findAll({
where: { id: { [Op.in]: regionIds } }
});
const regionMap = new Map(regions.map(r => [r.id, r]));
// 3. Berechne Steuern für alle Regionen auf einmal
const taxMap = new Map();
for (const regionId of regionIds) {
const tax = await getCumulativeTaxPercentWithExemptions(falukantUser.id, regionId);
taxMap.set(regionId, tax);
}
// 4. Berechne Preise und Steuern in einer Schleife
const sellItems = [];
for (const item of inventory) {
const regionId = item.stock.branch.regionId;
const worthPercent = worthMap.get(`${item.productType.id}-${regionId}`) || 50;
const knowledgeVal = item.productType.knowledges[0]?.knowledge || 0;
const pricePerUnit = calcRegionalSellPrice(item.productType, knowledgeVal, regionId, worthPercent);
const cumulativeTax = taxMap.get(regionId);
// ... rest of calculation ...
sellItems.push({ branchId: item.stock.branch.id, productId: item.productType.id, quantity: item.quantity });
}
// 5. Batch-Update DaySell Einträge
await this.addSellItemsBatch(sellItems);
// ... rest of code ...
}
```
### 2. **Batch-Operation für `addSellItem()`**
```javascript
async addSellItemsBatch(sellItems) {
// Gruppiere nach (regionId, productId, sellerId)
const grouped = new Map();
for (const item of sellItems) {
const branch = await Branch.findByPk(item.branchId);
if (!branch) continue;
const key = `${branch.regionId}-${item.productId}-${item.sellerId}`;
if (!grouped.has(key)) {
grouped.set(key, {
regionId: branch.regionId,
productId: item.productId,
sellerId: item.sellerId,
quantity: 0
});
}
grouped.get(key).quantity += item.quantity;
}
// Batch-Update oder Create
for (const [key, data] of grouped) {
const [daySell, created] = await DaySell.findOrCreate({
where: {
regionId: data.regionId,
productId: data.productId,
sellerId: data.sellerId
},
defaults: { quantity: data.quantity }
});
if (!created) {
daySell.quantity += data.quantity;
await daySell.save();
}
}
}
```
### 3. **Caching für `getCumulativeTaxPercentWithExemptions()`**
```javascript
// Cache für Steuerberechnungen (z.B. 5 Minuten)
const taxCache = new Map();
const CACHE_TTL = 5 * 60 * 1000; // 5 Minuten
async function getCumulativeTaxPercentWithExemptions(userId, regionId) {
const cacheKey = `${userId}-${regionId}`;
const cached = taxCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.value;
}
// ... existing calculation ...
taxCache.set(cacheKey, { value: tax, timestamp: Date.now() });
return tax;
}
```
### 4. **Optimierte `calcRegionalSellPrice()`**
```javascript
async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPercent = null) {
// Wenn worthPercent nicht übergeben wurde UND wir es nicht aus dem Cache haben,
// dann hole es aus der DB
if (worthPercent === null) {
const townWorth = await TownProductWorth.findOne({
where: { productId: product.id, regionId: regionId }
});
worthPercent = townWorth?.worthPercent || 50;
}
// ... rest of calculation ...
}
```
### 5. **Datenbank-Indizes hinzufügen**
```sql
-- Index für town_product_worth (sollte unique sein)
CREATE UNIQUE INDEX IF NOT EXISTS idx_town_product_worth_product_region
ON falukant_data.town_product_worth(product_id, region_id);
-- Index für inventory Lookups
CREATE INDEX IF NOT EXISTS idx_inventory_stock_product_quality
ON falukant_data.inventory(stock_id, product_id, quality);
-- Index für knowledge Lookups
CREATE INDEX IF NOT EXISTS idx_knowledge_character_product
ON falukant_data.knowledge(character_id, product_id);
-- Index für political_office Lookups
CREATE INDEX IF NOT EXISTS idx_political_office_character
ON falukant_data.political_office(character_id);
```
## Geschätzter Performance-Gewinn
- **Vorher:** ~70+ Queries für 10 Items
- **Nachher:** ~15-20 Queries für 10 Items (Batch-Loading + Caching)
- **Geschätzte Verbesserung:** 70-80% weniger Datenbankabfragen
## Priorität
1. **Hoch:** Batch-Loading für `sellAllProducts()` (größter Impact)
2. **Hoch:** Batch-Operation für `addSellItem()`
3. **Mittel:** Caching für Steuerberechnungen
4. **Mittel:** Datenbank-Indizes
5. **Niedrig:** Doppelte Berechnungen entfernen

601
SELL_OVERVIEW.md Normal file
View File

@@ -0,0 +1,601 @@
# Übersicht: Sell-Funktionen und verwendete Models/Tabellen
## Sell-Funktionen in `falukantService.js`
### 1. `sellProduct(hashedUserId, branchId, productId, quality, quantity)`
Verkauft ein einzelnes Produkt mit bestimmter Qualität.
**Ablauf:**
1. Lädt User, Branch, Character, Stock
2. Lädt Inventory mit ProductType und Knowledge
3. Berechnet Preis pro Einheit mit `calcRegionalSellPrice()`
4. Berechnet kumulative Steuer mit politischen Befreiungen
5. Passt Preis an (Inflation basierend auf Steuer)
6. Berechnet Revenue, Tax, Net
7. Aktualisiert Geld für Verkäufer und Treasury
8. Entfernt verkaufte Items aus Inventory
9. Erstellt/aktualisiert DaySell Eintrag
10. Sendet Socket-Notifications
**Verwendete Models/Tabellen:**
- `FalukantUser` (`falukant_data.falukant_user`)
- `Branch` (`falukant_data.branch`)
- `FalukantCharacter` (`falukant_data.character`)
- `FalukantStock` (`falukant_data.stock`)
- `Inventory` (`falukant_data.inventory`)
- `ProductType` (`falukant_type.product`)
- `Knowledge` (`falukant_data.knowledge`)
- `TownProductWorth` (`falukant_data.town_product_worth`)
- `RegionData` (`falukant_data.region`)
- `RegionType` (`falukant_type.region`)
- `PoliticalOffice` (`falukant_data.political_office`)
- `PoliticalOfficeType` (`falukant_type.political_office_type`)
- `DaySell` (`falukant_log.day_sell`)
- `MoneyFlow` (via `updateFalukantUserMoney`)
### 2. `sellAllProducts(hashedUserId, branchId)`
Verkauft alle Produkte eines Branches.
**Ablauf:**
1. Lädt User, Branch mit Stocks
2. Lädt alle Inventory Items mit ProductType, Knowledge, Stock, Branch
3. Für jedes Item:
- Berechnet Preis pro Einheit
- Berechnet kumulative Steuer
- Passt Preis an
- Erstellt/aktualisiert DaySell Eintrag
4. Berechnet Gesamt-Tax pro Region
5. Aktualisiert Geld für Verkäufer und Treasury
6. Löscht alle Inventory Items
7. Sendet Socket-Notifications
**Verwendete Models/Tabellen:**
- `FalukantUser` (`falukant_data.falukant_user`)
- `Branch` (`falukant_data.branch`)
- `FalukantStock` (`falukant_data.stock`)
- `FalukantStockType` (`falukant_type.stock`)
- `FalukantCharacter` (`falukant_data.character`)
- `Inventory` (`falukant_data.inventory`)
- `ProductType` (`falukant_type.product`)
- `Knowledge` (`falukant_data.knowledge`)
- `TownProductWorth` (`falukant_data.town_product_worth`)
- `RegionData` (`falukant_data.region`)
- `RegionType` (`falukant_type.region`)
- `PoliticalOffice` (`falukant_data.political_office`)
- `PoliticalOfficeType` (`falukant_type.political_office_type`)
- `DaySell` (`falukant_log.day_sell`)
- `MoneyFlow` (via `updateFalukantUserMoney`)
### 3. `addSellItem(branchId, userId, productId, quantity)`
Erstellt oder aktualisiert einen DaySell Eintrag für einen Verkauf.
**Ablauf:**
1. Lädt Branch
2. Sucht nach existierendem DaySell Eintrag
3. Erstellt neuen oder aktualisiert existierenden Eintrag
**Verwendete Models/Tabellen:**
- `Branch` (`falukant_data.branch`)
- `DaySell` (`falukant_log.day_sell`)
## Hilfsfunktionen
### `calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPercent = null)`
Berechnet den Verkaufspreis eines Produkts basierend auf:
- Basispreis (`product.sellCost`)
- Regionalem Worth-Percent (`town_product_worth.worth_percent`)
- Knowledge-Faktor (0-100)
**Verwendete Models/Tabellen:**
- `ProductType` (`falukant_type.product`)
- `TownProductWorth` (`falukant_data.town_product_worth`)
### `getCumulativeTaxPercent(regionId)`
Berechnet die kumulative Steuer für eine Region und alle Vorfahren (rekursiv).
**SQL Query:**
```sql
WITH RECURSIVE ancestors AS (
SELECT id, parent_id, tax_percent
FROM falukant_data.region r
WHERE id = :id
UNION ALL
SELECT reg.id, reg.parent_id, reg.tax_percent
FROM falukant_data.region reg
JOIN ancestors a ON reg.id = a.parent_id
)
SELECT COALESCE(SUM(tax_percent),0) AS total FROM ancestors;
```
**Verwendete Tabellen:**
- `falukant_data.region`
### `getCumulativeTaxPercentWithExemptions(userId, regionId)`
Berechnet die kumulative Steuer mit politischen Befreiungen.
**Ablauf:**
1. Lädt Character des Users
2. Lädt alle PoliticalOffices des Characters
3. Bestimmt befreite Region-Typen basierend auf Ämtern
4. Berechnet kumulative Steuer, aber schließt befreite Region-Typen aus
**SQL Query:**
```sql
WITH RECURSIVE ancestors AS (
SELECT r.id, r.parent_id, r.tax_percent, rt.label_tr as region_type
FROM falukant_data.region r
JOIN falukant_type.region rt ON rt.id = r.region_type_id
WHERE r.id = :id
UNION ALL
SELECT reg.id, reg.parent_id, reg.tax_percent, rt2.label_tr
FROM falukant_data.region reg
JOIN falukant_type.region rt2 ON rt2.id = reg.region_type_id
JOIN ancestors a ON reg.id = a.parent_id
)
SELECT COALESCE(SUM(CASE WHEN ARRAY[...] && ARRAY[region_type]::text[] THEN 0 ELSE tax_percent END),0) AS total FROM ancestors;
```
**Verwendete Models/Tabellen:**
- `FalukantCharacter` (`falukant_data.character`)
- `PoliticalOffice` (`falukant_data.political_office`)
- `PoliticalOfficeType` (`falukant_type.political_office_type`)
- `RegionData` (`falukant_data.region`)
- `RegionType` (`falukant_type.region`)
**Politische Steuerbefreiungen:**
```javascript
const POLITICAL_TAX_EXEMPTIONS = {
'council': ['city'],
'taxman': ['city', 'county'],
'treasurerer': ['city', 'county', 'shire'],
'super-state-administrator': ['city', 'county', 'shire', 'markgrave', 'duchy'],
'chancellor': ['city','county','shire','markgrave','duchy'] // = alle Typen
};
```
## Model-Definitionen
### Inventory (`falukant_data.inventory`)
```javascript
// backend/models/falukant/data/inventory.js
- id
- stockId (FK zu falukant_data.stock)
- productId (FK zu falukant_type.product)
- quantity
- quality
- producedAt
```
### DaySell (`falukant_log.day_sell`)
```javascript
// backend/models/falukant/log/daysell.js
- id
- regionId (FK zu falukant_data.region)
- productId (FK zu falukant_type.product)
- sellerId (FK zu falukant_data.falukant_user)
- quantity
- createdAt
- updatedAt
```
### TownProductWorth (`falukant_data.town_product_worth`)
```javascript
// backend/models/falukant/data/town_product_worth.js
- id
- productId (FK zu falukant_type.product)
- regionId (FK zu falukant_data.region)
- worthPercent (0-100)
```
### Knowledge (`falukant_data.knowledge`)
```javascript
// backend/models/falukant/data/product_knowledge.js
- id
- productId (FK zu falukant_type.product)
- characterId (FK zu falukant_data.character)
- knowledge (0-99)
```
## Wichtige SQL-Queries
### 1. Inventory mit ProductType und Knowledge laden
```javascript
Inventory.findAll({
where: { quality },
include: [
{
model: ProductType,
as: 'productType',
required: true,
where: { id: productId },
include: [
{
model: Knowledge,
as: 'knowledges',
required: false,
where: { characterId: character.id }
}
]
}
]
})
```
### 2. Kumulative Steuer mit Befreiungen berechnen
Siehe `getCumulativeTaxPercentWithExemptions()` oben.
## Preisberechnung
### Formel für `calcRegionalSellPrice`:
1. Basispreis = `product.sellCost * (worthPercent / 100)`
2. Min = `basePrice * 0.6`
3. Max = `basePrice`
4. Preis = `min + (max - min) * (knowledgeFactor / 100)`
### Steueranpassung:
1. `inflationFactor = cumulativeTax >= 100 ? 1 : (1 / (1 - cumulativeTax / 100))`
2. `adjustedPricePerUnit = pricePerUnit * inflationFactor`
3. `revenue = quantity * adjustedPricePerUnit`
4. `taxValue = revenue * cumulativeTax / 100`
5. `net = revenue - taxValue`
## Vollständige Code-Snippets
### `calcRegionalSellPrice()`
```javascript
async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPercent = null) {
if (worthPercent === null) {
const townWorth = await TownProductWorth.findOne({
where: { productId: product.id, regionId: regionId }
});
worthPercent = townWorth?.worthPercent || 50; // Default 50% wenn nicht gefunden
}
// Basispreis basierend auf regionalem worthPercent
const basePrice = product.sellCost * (worthPercent / 100);
// Dann Knowledge-Faktor anwenden
const min = basePrice * 0.6;
const max = basePrice;
return min + (max - min) * (knowledgeFactor / 100);
}
```
### `getCumulativeTaxPercent()`
```javascript
async function getCumulativeTaxPercent(regionId) {
if (!regionId) return 0;
const rows = await sequelize.query(
`WITH RECURSIVE ancestors AS (
SELECT id, parent_id, tax_percent
FROM falukant_data.region r
WHERE id = :id
UNION ALL
SELECT reg.id, reg.parent_id, reg.tax_percent
FROM falukant_data.region reg
JOIN ancestors a ON reg.id = a.parent_id
)
SELECT COALESCE(SUM(tax_percent),0) AS total FROM ancestors;`,
{
replacements: { id: regionId },
type: sequelize.QueryTypes.SELECT
}
);
const val = rows?.[0]?.total ?? 0;
return parseFloat(val) || 0;
}
```
### `getCumulativeTaxPercentWithExemptions()` (vereinfacht)
```javascript
async function getCumulativeTaxPercentWithExemptions(userId, regionId) {
if (!regionId) return 0;
// Character finden
const character = await FalukantCharacter.findOne({
where: { userId },
attributes: ['id']
});
if (!character) return 0;
// Politische Ämter laden
const offices = await PoliticalOffice.findAll({
where: { characterId: character.id },
include: [
{ model: PoliticalOfficeType, as: 'type', attributes: ['name'] },
{
model: RegionData,
as: 'region',
include: [{
model: RegionType,
as: 'regionType',
attributes: ['labelTr']
}]
}
]
});
// Befreite Region-Typen bestimmen
const exemptTypes = new Set();
let hasChancellor = false;
for (const o of offices) {
const name = o.type?.name;
if (!name) continue;
if (name === 'chancellor') { hasChancellor = true; break; }
const allowed = POLITICAL_TAX_EXEMPTIONS[name];
if (allowed && Array.isArray(allowed)) {
for (const t of allowed) exemptTypes.add(t);
}
}
if (hasChancellor) return 0;
// SQL Query mit Befreiungen
const exemptTypesArray = Array.from(exemptTypes);
const exemptTypesString = exemptTypesArray.length > 0
? `ARRAY[${exemptTypesArray.map(t => `'${t.replace(/'/g, "''")}'`).join(',')}]`
: `ARRAY[]::text[]`;
const rows = await sequelize.query(
`WITH RECURSIVE ancestors AS (
SELECT r.id, r.parent_id, r.tax_percent, rt.label_tr as region_type
FROM falukant_data.region r
JOIN falukant_type.region rt ON rt.id = r.region_type_id
WHERE r.id = :id
UNION ALL
SELECT reg.id, reg.parent_id, reg.tax_percent, rt2.label_tr
FROM falukant_data.region reg
JOIN falukant_type.region rt2 ON rt2.id = reg.region_type_id
JOIN ancestors a ON reg.id = a.parent_id
)
SELECT COALESCE(SUM(CASE WHEN ${exemptTypesString} && ARRAY[region_type]::text[] THEN 0 ELSE tax_percent END),0) AS total FROM ancestors;`,
{
replacements: { id: regionId },
type: sequelize.QueryTypes.SELECT
}
);
const val = rows?.[0]?.total ?? 0;
return parseFloat(val) || 0;
}
```
### `sellProduct()` (Kern-Logik)
```javascript
async sellProduct(hashedUserId, branchId, productId, quality, quantity) {
const user = await getFalukantUserOrFail(hashedUserId);
const branch = await getBranchOrFail(user.id, branchId);
const character = await FalukantCharacter.findOne({ where: { userId: user.id } });
if (!character) throw new Error('No character found for user');
const stock = await FalukantStock.findOne({ where: { branchId: branch.id } });
if (!stock) throw new Error('Stock not found');
// Inventory laden
const inventory = await Inventory.findAll({
where: { quality },
include: [
{
model: ProductType,
as: 'productType',
required: true,
where: { id: productId },
include: [
{
model: Knowledge,
as: 'knowledges',
required: false,
where: { characterId: character.id }
}
]
}
]
});
if (!inventory.length) throw new Error('No inventory found');
const available = inventory.reduce((sum, i) => sum + i.quantity, 0);
if (available < quantity) throw new Error('Not enough inventory available');
const item = inventory[0].productType;
const knowledgeVal = item.knowledges?.[0]?.knowledge || 0;
const pricePerUnit = await calcRegionalSellPrice(item, knowledgeVal, branch.regionId);
// Steuer berechnen
const cumulativeTax = await getCumulativeTaxPercentWithExemptions(user.id, branch.regionId);
const inflationFactor = cumulativeTax >= 100 ? 1 : (1 / (1 - cumulativeTax / 100));
const adjustedPricePerUnit = Math.round(pricePerUnit * inflationFactor * 100) / 100;
const revenue = quantity * adjustedPricePerUnit;
// Tax und Net berechnen
const taxValue = Math.round((revenue * cumulativeTax / 100) * 100) / 100;
const net = Math.round((revenue - taxValue) * 100) / 100;
// Geld aktualisieren
const moneyResult = await updateFalukantUserMoney(user.id, net, `Product sale (net)`, user.id);
if (!moneyResult.success) throw new Error('Failed to update money for seller');
// Steuer an Treasury buchen
const treasuryId = process.env.TREASURY_FALUKANT_USER_ID;
if (treasuryId && taxValue > 0) {
const taxResult = await updateFalukantUserMoney(parseInt(treasuryId, 10), taxValue, `Sales tax (${cumulativeTax}%)`, user.id);
if (!taxResult.success) throw new Error('Failed to update money for treasury');
}
// Inventory aktualisieren
let remaining = quantity;
for (const inv of inventory) {
if (inv.quantity <= remaining) {
remaining -= inv.quantity;
await inv.destroy();
} else {
await inv.update({ quantity: inv.quantity - remaining });
remaining = 0;
break;
}
}
// DaySell Eintrag erstellen/aktualisieren
await this.addSellItem(branchId, user.id, productId, quantity);
// Notifications senden
notifyUser(user.user.hashedId, 'falukantUpdateStatus', {});
notifyUser(user.user.hashedId, 'falukantBranchUpdate', { branchId: branch.id });
return { success: true };
}
```
### `addSellItem()`
```javascript
async addSellItem(branchId, userId, productId, quantity) {
const branch = await Branch.findOne({
where: { id: branchId },
});
const daySell = await DaySell.findOne({
where: {
regionId: branch.regionId,
productId: productId,
sellerId: userId,
}
});
if (daySell) {
daySell.quantity += quantity;
await daySell.save();
} else {
await DaySell.create({
regionId: branch.regionId,
productId: productId,
sellerId: userId,
quantity: quantity,
});
}
}
```
## Wichtige Hinweise
1. **Inventory wird nach Verkauf gelöscht/aktualisiert**: Items werden aus der Inventory entfernt oder die Menge reduziert.
2. **DaySell wird aggregiert**: Wenn bereits ein DaySell Eintrag für Region/Product/Seller existiert, wird die Menge addiert.
3. **Steuer wird an Treasury gebucht**: Wenn `TREASURY_FALUKANT_USER_ID` gesetzt ist, wird die Steuer an diesen User gebucht.
4. **Socket-Notifications**: Nach jedem Verkauf werden `falukantUpdateStatus` und `falukantBranchUpdate` Events gesendet.
5. **Politische Befreiungen**: Bestimmte politische Ämter befreien von Steuern in bestimmten Region-Typen. Chancellor befreit von allen Steuern.
6. **Preis-Inflation**: Der Preis wird basierend auf der Steuer inflatiert, damit der Netto-Betrag für den Verkäufer gleich bleibt.
## Tabellenübersicht
### `falukant_data.inventory`
- `id` (PK)
- `stock_id` (FK zu `falukant_data.stock`)
- `product_id` (FK zu `falukant_type.product`)
- `quantity` (INTEGER)
- `quality` (INTEGER)
- `produced_at` (DATE)
### `falukant_log.sell` (DaySell)
- `id` (PK)
- `region_id` (FK zu `falukant_data.region`)
- `product_id` (FK zu `falukant_type.product`)
- `seller_id` (FK zu `falukant_data.falukant_user`)
- `quantity` (INTEGER)
- `sell_timestamp` (DATE)
- **Unique Index**: `(seller_id, product_id, region_id)`
### `falukant_data.town_product_worth`
- `id` (PK)
- `product_id` (FK zu `falukant_type.product`)
- `region_id` (FK zu `falukant_data.region`)
- `worth_percent` (INTEGER, 0-100)
### `falukant_data.knowledge`
- `id` (PK)
- `product_id` (FK zu `falukant_type.product`)
- `character_id` (FK zu `falukant_data.character`)
- `knowledge` (INTEGER, 0-99)
### `falukant_data.political_office`
- `id` (PK)
- `office_type_id` (FK zu `falukant_type.political_office_type`)
- `character_id` (FK zu `falukant_data.character`)
- `region_id` (FK zu `falukant_data.region`)
- `created_at`, `updated_at`
### `falukant_type.political_office_type`
- `id` (PK)
- `name` (STRING) - z.B. 'council', 'taxman', 'treasurerer', 'super-state-administrator', 'chancellor'
- `seats_per_region` (INTEGER)
- `region_type` (STRING)
- `term_length` (INTEGER)
### `falukant_data.region`
- `id` (PK)
- `name` (STRING)
- `region_type_id` (FK zu `falukant_type.region`)
- `parent_id` (FK zu `falukant_data.region`, nullable)
- `map` (JSONB)
- `tax_percent` (DECIMAL)
### `falukant_type.region`
- `id` (PK)
- `label_tr` (STRING) - z.B. 'city', 'county', 'shire', 'markgrave', 'duchy'
- `parent_id` (FK zu `falukant_type.region`, nullable)
### `falukant_data.falukant_user`
- `id` (PK)
- `user_id` (FK zu `community.user`)
- `money` (DECIMAL)
- `credit_amount`, `today_credit_taken`, `credit_interest_rate`
- `certificate`
- `main_branch_region_id`
- `last_nobility_advance_at`
- `created_at`, `updated_at`
### `falukant_data.character`
- `id` (PK)
- `user_id` (FK zu `falukant_data.falukant_user`)
- `region_id` (FK zu `falukant_data.region`)
- `first_name`, `last_name`
- `birthdate`, `gender`, `health`
- `title_of_nobility` (FK zu `falukant_type.title_of_nobility`)
- `mood_id` (FK zu `falukant_type.mood`)
- `created_at`, `updated_at`
### `falukant_data.branch`
- `id` (PK)
- `branch_type_id` (FK zu `falukant_type.branch`)
- `region_id` (FK zu `falukant_data.region`)
- `falukant_user_id` (FK zu `falukant_data.falukant_user`)
### `falukant_data.stock`
- `id` (PK)
- `branch_id` (FK zu `falukant_data.branch`)
- `stock_type_id` (FK zu `falukant_type.stock`)
- `quantity` (INTEGER)
- `product_quality` (INTEGER, nullable)
### `falukant_type.product`
- `id` (PK)
- `label_tr` (STRING, unique)
- `category` (INTEGER)
- `production_time` (INTEGER)
- `sell_cost` (INTEGER)
## Dateipfade
- **Service**: `backend/services/falukantService.js`
- **Models**:
- `backend/models/falukant/data/inventory.js`
- `backend/models/falukant/log/daysell.js`
- `backend/models/falukant/data/town_product_worth.js`
- `backend/models/falukant/data/product_knowledge.js`
- `backend/models/falukant/data/political_office.js`
- `backend/models/falukant/type/political_office_type.js`
- `backend/models/falukant/data/region.js`
- `backend/models/falukant/type/region.js`
- `backend/models/falukant/data/character.js`
- `backend/models/falukant/data/user.js`
- `backend/models/falukant/data/branch.js`
- `backend/models/falukant/data/stock.js`
- `backend/models/falukant/type/product.js`

23
backend/README_TAX.md Normal file
View File

@@ -0,0 +1,23 @@
# Falukant Tax Migration & Configuration
This project now supports a per-region sales tax (`tax_percent`) for Falukant.
Migration
- A SQL migration was added: `backend/migrations/20260101000000-add-tax-percent-to-region.cjs`.
- It adds `tax_percent` numeric NOT NULL DEFAULT 7 to `falukant_data.region`.
Runtime configuration
- If you want taxes to be forwarded to a treasury account, set environment variable `TREASURY_FALUKANT_USER_ID` to a valid `falukant_user.id`.
- If `TREASURY_FALUKANT_USER_ID` is not set, taxes will be calculated and currently not forwarded to any account.
Implementation notes
- Backend service `sellProduct` and `sellAllProducts` now compute tax per-region and credit net to seller and tax to treasury (if configured).
- Tax arithmetic uses rounding to 2 decimals. The current implementation performs two separate DB calls (seller, treasury). For strict ledger atomicity consider implementing DB-side booking.
Cumulative tax behavior
- The system now sums `tax_percent` from the sale region and all ancestor regions (recursive up the region tree). This allows defining different tax rates on up to 6 region levels and summing them for final tax percent.
- To avoid reducing seller net by taxes, sale prices are inflated by factor = 1 / (1 - cumulativeTax/100). This way the seller receives the original net and the tax is collected separately.
Testing
- After running the migration, test with a small sale and verify `falukant_log.moneyflow` entries for seller and treasury.

View File

@@ -1,6 +1,7 @@
import express from 'express'; import express from 'express';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import crypto from 'crypto';
import chatRouter from './routers/chatRouter.js'; import chatRouter from './routers/chatRouter.js';
import authRouter from './routers/authRouter.js'; import authRouter from './routers/authRouter.js';
import navigationRouter from './routers/navigationRouter.js'; import navigationRouter from './routers/navigationRouter.js';
@@ -16,6 +17,8 @@ import match3Router from './routers/match3Router.js';
import taxiRouter from './routers/taxiRouter.js'; import taxiRouter from './routers/taxiRouter.js';
import taxiMapRouter from './routers/taxiMapRouter.js'; import taxiMapRouter from './routers/taxiMapRouter.js';
import taxiHighscoreRouter from './routers/taxiHighscoreRouter.js'; import taxiHighscoreRouter from './routers/taxiHighscoreRouter.js';
import termineRouter from './routers/termineRouter.js';
import vocabRouter from './routers/vocabRouter.js';
import cors from 'cors'; import cors from 'cors';
import './jobs/sessionCleanup.js'; import './jobs/sessionCleanup.js';
@@ -24,6 +27,25 @@ const __dirname = path.dirname(__filename);
const app = express(); const app = express();
// Request-Timing (aktivierbar per ENV)
// - LOG_SLOW_REQ_MS=200: Logge Requests, die länger dauern als X ms (Default 500)
// - LOG_ALL_REQ=1: Logge alle Requests
const LOG_ALL_REQ = process.env.LOG_ALL_REQ === '1';
const LOG_SLOW_REQ_MS = Number.parseInt(process.env.LOG_SLOW_REQ_MS || '500', 10);
app.use((req, res, next) => {
const reqId = req.headers['x-request-id'] || (crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(8).toString('hex'));
req.reqId = reqId;
res.setHeader('x-request-id', reqId);
const t0 = Date.now();
res.on('finish', () => {
const ms = Date.now() - t0;
if (LOG_ALL_REQ || ms >= LOG_SLOW_REQ_MS) {
console.log(`⏱️ REQ ${ms}ms ${res.statusCode} ${req.method} ${req.originalUrl} rid=${reqId}`);
}
});
next();
});
const corsOptions = { const corsOptions = {
origin: ['http://localhost:3000', 'http://localhost:5173', 'http://127.0.0.1:3000', 'http://127.0.0.1:5173'], origin: ['http://localhost:3000', 'http://localhost:5173', 'http://127.0.0.1:3000', 'http://127.0.0.1:5173'],
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'], methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'],
@@ -48,10 +70,12 @@ app.use('/api/taxi/highscores', taxiHighscoreRouter);
app.use('/images', express.static(path.join(__dirname, '../frontend/public/images'))); app.use('/images', express.static(path.join(__dirname, '../frontend/public/images')));
app.use('/api/contact', contactRouter); app.use('/api/contact', contactRouter);
app.use('/api/socialnetwork', socialnetworkRouter); app.use('/api/socialnetwork', socialnetworkRouter);
app.use('/api/vocab', vocabRouter);
app.use('/api/forum', forumRouter); app.use('/api/forum', forumRouter);
app.use('/api/falukant', falukantRouter); app.use('/api/falukant', falukantRouter);
app.use('/api/friendships', friendshipRouter); app.use('/api/friendships', friendshipRouter);
app.use('/api/blog', blogRouter); app.use('/api/blog', blogRouter);
app.use('/api/termine', termineRouter);
// Serve frontend SPA for non-API routes to support history mode clean URLs // Serve frontend SPA for non-API routes to support history mode clean URLs
const frontendDir = path.join(__dirname, '../frontend'); const frontendDir = path.join(__dirname, '../frontend');

View File

@@ -27,6 +27,7 @@ class AdminController {
// User administration // User administration
this.searchUsers = this.searchUsers.bind(this); this.searchUsers = this.searchUsers.bind(this);
this.getUser = this.getUser.bind(this); this.getUser = this.getUser.bind(this);
this.getUsers = this.getUsers.bind(this);
this.updateUser = this.updateUser.bind(this); this.updateUser = this.updateUser.bind(this);
// Rights // Rights
@@ -37,6 +38,14 @@ class AdminController {
// Statistics // Statistics
this.getUserStatistics = this.getUserStatistics.bind(this); this.getUserStatistics = this.getUserStatistics.bind(this);
this.getFalukantRegions = this.getFalukantRegions.bind(this);
this.updateFalukantRegionMap = this.updateFalukantRegionMap.bind(this);
this.getRegionDistances = this.getRegionDistances.bind(this);
this.upsertRegionDistance = this.upsertRegionDistance.bind(this);
this.deleteRegionDistance = this.deleteRegionDistance.bind(this);
this.createNPCs = this.createNPCs.bind(this);
this.getTitlesOfNobility = this.getTitlesOfNobility.bind(this);
this.getNPCsCreationStatus = this.getNPCsCreationStatus.bind(this);
} }
async getOpenInterests(req, res) { async getOpenInterests(req, res) {
@@ -74,6 +83,30 @@ class AdminController {
} }
} }
async getUsers(req, res) {
try {
const { userid: requester } = req.headers;
let { ids } = req.query;
if (!ids) {
return res.status(400).json({ error: 'ids query parameter is required' });
}
// Unterstütze sowohl Array-Format (ids[]=...) als auch komma-separierten String (ids=...)
let hashedIds;
if (Array.isArray(ids)) {
hashedIds = ids;
} else if (typeof ids === 'string') {
hashedIds = ids.split(',').map(id => id.trim()).filter(id => id.length > 0);
} else {
return res.status(400).json({ error: 'ids must be an array or comma-separated string' });
}
const result = await AdminService.getUsersByHashedIds(requester, hashedIds);
res.status(200).json(result);
} catch (error) {
const status = error.message === 'noaccess' ? 403 : 500;
res.status(status).json({ error: error.message });
}
}
async updateUser(req, res) { async updateUser(req, res) {
try { try {
const { userid: requester } = req.headers; const { userid: requester } = req.headers;
@@ -290,6 +323,122 @@ class AdminController {
} }
} }
async getFalukantRegions(req, res) {
try {
const { userid: userId } = req.headers;
const regions = await AdminService.getFalukantRegions(userId);
res.status(200).json(regions);
} catch (error) {
console.log(error);
const status = error.message === 'noaccess' ? 403 : 500;
res.status(status).json({ error: error.message });
}
}
async updateFalukantRegionMap(req, res) {
try {
const { userid: userId } = req.headers;
const { id } = req.params;
const { map } = req.body || {};
const region = await AdminService.updateFalukantRegionMap(userId, id, map);
res.status(200).json(region);
} catch (error) {
console.log(error);
const status = error.message === 'noaccess' ? 403 : (error.message === 'regionNotFound' ? 404 : 500);
res.status(status).json({ error: error.message });
}
}
async getRegionDistances(req, res) {
try {
const { userid: userId } = req.headers;
const distances = await AdminService.getRegionDistances(userId);
res.status(200).json(distances);
} catch (error) {
console.log(error);
const status = error.message === 'noaccess' ? 403 : 500;
res.status(status).json({ error: error.message });
}
}
async upsertRegionDistance(req, res) {
try {
const { userid: userId } = req.headers;
const record = await AdminService.upsertRegionDistance(userId, req.body || {});
res.status(200).json(record);
} catch (error) {
console.log(error);
const status = error.message === 'noaccess' ? 403 : 400;
res.status(status).json({ error: error.message });
}
}
async deleteRegionDistance(req, res) {
try {
const { userid: userId } = req.headers;
const { id } = req.params;
const result = await AdminService.deleteRegionDistance(userId, id);
res.status(200).json(result);
} catch (error) {
console.log(error);
const status = error.message === 'noaccess' ? 403 : (error.message === 'notfound' ? 404 : 500);
res.status(status).json({ error: error.message });
}
}
async createNPCs(req, res) {
try {
const { userid: userId } = req.headers;
const { regionIds, minAge, maxAge, minTitleId, maxTitleId, count } = req.body;
const countValue = parseInt(count) || 1;
if (countValue < 1 || countValue > 500) {
return res.status(400).json({ error: 'Count must be between 1 and 500' });
}
console.log('[createNPCs] Request received:', { userId, regionIds, minAge, maxAge, minTitleId, maxTitleId, count: countValue });
const result = await AdminService.createNPCs(userId, {
regionIds: regionIds && regionIds.length > 0 ? regionIds : null,
minAge: parseInt(minAge) || 0,
maxAge: parseInt(maxAge) || 100,
minTitleId: parseInt(minTitleId) || 1,
maxTitleId: parseInt(maxTitleId) || 19,
count: countValue
});
console.log('[createNPCs] Job created:', result);
res.status(200).json(result);
} catch (error) {
console.error('[createNPCs] Error:', error);
console.error('[createNPCs] Error stack:', error.stack);
const status = error.message === 'noaccess' ? 403 : 500;
res.status(status).json({ error: error.message || 'Internal server error' });
}
}
async getTitlesOfNobility(req, res) {
try {
const { userid: userId } = req.headers;
const titles = await AdminService.getTitlesOfNobility(userId);
res.status(200).json(titles);
} catch (error) {
console.log(error);
const status = error.message === 'noaccess' ? 403 : 500;
res.status(status).json({ error: error.message });
}
}
async getNPCsCreationStatus(req, res) {
try {
const { userid: userId } = req.headers;
const { jobId } = req.params;
const status = await AdminService.getNPCsCreationStatus(userId, jobId);
res.status(200).json(status);
} catch (error) {
console.log(error);
const status = error.message === 'noaccess' || error.message === 'Access denied' ? 403 :
error.message === 'Job not found' ? 404 : 500;
res.status(status).json({ error: error.message });
}
}
async getRoomTypes(req, res) { async getRoomTypes(req, res) {
try { try {
const userId = req.headers.userid; const userId = req.headers.userid;

View File

@@ -30,6 +30,7 @@ class FalukantController {
this.createBranch = this._wrapWithUser((userId, req) => this.service.createBranch(userId, req.body.cityId, req.body.branchTypeId)); this.createBranch = this._wrapWithUser((userId, req) => this.service.createBranch(userId, req.body.cityId, req.body.branchTypeId));
this.getBranchTypes = this._wrapWithUser((userId) => this.service.getBranchTypes(userId)); this.getBranchTypes = this._wrapWithUser((userId) => this.service.getBranchTypes(userId));
this.getBranch = this._wrapWithUser((userId, req) => this.service.getBranch(userId, req.params.branch)); this.getBranch = this._wrapWithUser((userId, req) => this.service.getBranch(userId, req.params.branch));
this.upgradeBranch = this._wrapWithUser((userId, req) => this.service.upgradeBranch(userId, req.body.branchId));
this.createProduction = this._wrapWithUser((userId, req) => { this.createProduction = this._wrapWithUser((userId, req) => {
const { branchId, productId, quantity } = req.body; const { branchId, productId, quantity } = req.body;
return this.service.createProduction(userId, branchId, productId, quantity); return this.service.createProduction(userId, branchId, productId, quantity);
@@ -91,6 +92,9 @@ class FalukantController {
if (!result) throw { status: 404, message: 'No family data found' }; if (!result) throw { status: 404, message: 'No family data found' };
return result; return result;
}); });
this.setHeir = this._wrapWithUser((userId, req) => this.service.setHeir(userId, req.body.childCharacterId));
this.getPotentialHeirs = this._wrapWithUser((userId) => this.service.getPotentialHeirs(userId));
this.selectHeir = this._wrapWithUser((userId, req) => this.service.selectHeir(userId, req.body.heirId));
this.acceptMarriageProposal = this._wrapWithUser((userId, req) => this.service.acceptMarriageProposal(userId, req.body.proposalId)); this.acceptMarriageProposal = this._wrapWithUser((userId, req) => this.service.acceptMarriageProposal(userId, req.body.proposalId));
this.getGifts = this._wrapWithUser((userId) => { this.getGifts = this._wrapWithUser((userId) => {
console.log('🔍 getGifts called with userId:', userId); console.log('🔍 getGifts called with userId:', userId);
@@ -114,6 +118,12 @@ class FalukantController {
}, { successStatus: 201 }); }, { successStatus: 201 });
this.getParties = this._wrapWithUser((userId) => this.service.getParties(userId)); this.getParties = this._wrapWithUser((userId) => this.service.getParties(userId));
this.getReputationActions = this._wrapWithUser((userId) => this.service.getReputationActions(userId));
this.executeReputationAction = this._wrapWithUser((userId, req) => {
const { actionTypeId } = req.body;
return this.service.executeReputationAction(userId, actionTypeId);
}, { successStatus: 201 });
this.getNotBaptisedChildren = this._wrapWithUser((userId) => this.service.getNotBaptisedChildren(userId)); this.getNotBaptisedChildren = this._wrapWithUser((userId) => this.service.getNotBaptisedChildren(userId));
this.baptise = this._wrapWithUser((userId, req) => { this.baptise = this._wrapWithUser((userId, req) => {
const { characterId: childId, firstName } = req.body; const { characterId: childId, firstName } = req.body;
@@ -143,6 +153,24 @@ class FalukantController {
this.applyForElections = this._wrapWithUser((userId, req) => this.service.applyForElections(userId, req.body.electionIds)); this.applyForElections = this._wrapWithUser((userId, req) => this.service.applyForElections(userId, req.body.electionIds));
this.getRegions = this._wrapWithUser((userId) => this.service.getRegions(userId)); this.getRegions = this._wrapWithUser((userId) => this.service.getRegions(userId));
this.getBranchTaxes = this._wrapWithUser((userId, req) => this.service.getBranchTaxes(userId, req.params.branchId));
this.getProductPriceInRegion = this._wrapWithUser((userId, req) => {
const productId = parseInt(req.query.productId, 10);
const regionId = parseInt(req.query.regionId, 10);
if (Number.isNaN(productId) || Number.isNaN(regionId)) {
throw new Error('productId and regionId are required');
}
return this.service.getProductPriceInRegion(userId, productId, regionId);
});
this.getProductPricesInCities = this._wrapWithUser((userId, req) => {
const productId = parseInt(req.query.productId, 10);
const currentPrice = parseFloat(req.query.currentPrice);
const currentRegionId = req.query.currentRegionId ? parseInt(req.query.currentRegionId, 10) : null;
if (Number.isNaN(productId) || Number.isNaN(currentPrice)) {
throw new Error('productId and currentPrice are required');
}
return this.service.getProductPricesInCities(userId, productId, currentPrice, currentRegionId);
});
this.renovate = this._wrapWithUser((userId, req) => this.service.renovate(userId, req.body.element)); this.renovate = this._wrapWithUser((userId, req) => this.service.renovate(userId, req.body.element));
this.renovateAll = this._wrapWithUser((userId) => this.service.renovateAll(userId)); this.renovateAll = this._wrapWithUser((userId) => this.service.renovateAll(userId));
@@ -181,6 +209,33 @@ class FalukantController {
}); });
}); });
this.getVehicleTypes = this._wrapWithUser((userId) => this.service.getVehicleTypes(userId));
this.buyVehicles = this._wrapWithUser(
(userId, req) => this.service.buyVehicles(userId, req.body),
{ successStatus: 201 }
);
this.getVehicles = this._wrapWithUser(
(userId, req) => this.service.getVehicles(userId, req.query.regionId)
);
this.createTransport = this._wrapWithUser(
(userId, req) => this.service.createTransport(userId, req.body),
{ successStatus: 201 }
);
this.getTransportRoute = this._wrapWithUser(
(userId, req) => this.service.getTransportRoute(userId, req.query)
);
this.getBranchTransports = this._wrapWithUser(
(userId, req) => this.service.getBranchTransports(userId, req.params.branchId)
);
this.repairVehicle = this._wrapWithUser(
(userId, req) => this.service.repairVehicle(userId, req.params.vehicleId),
{ successStatus: 200 }
);
this.repairAllVehicles = this._wrapWithUser(
(userId, req) => this.service.repairAllVehicles(userId, req.body.vehicleIds),
{ successStatus: 200 }
);
} }

View File

@@ -4,6 +4,7 @@ import UserRight from '../models/community/user_right.js';
import UserRightType from '../models/type/user_right.js'; import UserRightType from '../models/type/user_right.js';
import UserParamType from '../models/type/user_param.js'; import UserParamType from '../models/type/user_param.js';
import FalukantUser from '../models/falukant/data/user.js'; import FalukantUser from '../models/falukant/data/user.js';
import VocabService from '../services/vocabService.js';
const menuStructure = { const menuStructure = {
home: { home: {
@@ -49,6 +50,11 @@ const menuStructure = {
visible: ["all"], visible: ["all"],
path: "/socialnetwork/gallery" path: "/socialnetwork/gallery"
}, },
vocabtrainer: {
visible: ["all"],
path: "/socialnetwork/vocab",
children: {}
},
blockedUsers: { blockedUsers: {
visible: ["all"], visible: ["all"],
path: "/socialnetwork/blocked" path: "/socialnetwork/blocked"
@@ -117,10 +123,6 @@ const menuStructure = {
visible: ["hasfalukantaccount"], visible: ["hasfalukantaccount"],
path: "/falukant/branch" path: "/falukant/branch"
}, },
directors: {
visible: ["hasfalukantaccount"],
path: "/falukant/directors"
},
family: { family: {
visible: ["hasfalukantaccount"], visible: ["hasfalukantaccount"],
path: "/falukant/family" path: "/falukant/family"
@@ -251,10 +253,14 @@ const menuStructure = {
visible: ["mainadmin", "chatrooms"], visible: ["mainadmin", "chatrooms"],
path: "/admin/chatrooms" path: "/admin/chatrooms"
}, },
servicesStatus: {
visible: ["mainadmin"],
path: "/admin/services/status"
},
interests: { interests: {
visible: ["mainadmin", "interests"], visible: ["mainadmin", "interests"],
path: "/admin/interests" path: "/admin/interests"
}, },
falukant: { falukant: {
visible: ["mainadmin", "falukant"], visible: ["mainadmin", "falukant"],
children: { children: {
@@ -270,6 +276,14 @@ const menuStructure = {
visible: ["mainadmin", "falukant"], visible: ["mainadmin", "falukant"],
path: "/admin/falukant/database" path: "/admin/falukant/database"
}, },
mapEditor: {
visible: ["mainadmin", "falukant"],
path: "/admin/falukant/map"
},
createNPC: {
visible: ["mainadmin", "falukant"],
path: "/admin/falukant/create-npc"
},
} }
}, },
minigames: { minigames: {
@@ -292,6 +306,7 @@ const menuStructure = {
class NavigationController { class NavigationController {
constructor() { constructor() {
this.menu = this.menu.bind(this); this.menu = this.menu.bind(this);
this.vocabService = new VocabService();
} }
calculateAge(birthDate) { calculateAge(birthDate) {
@@ -361,6 +376,24 @@ class NavigationController {
const age = this.calculateAge(birthDate); const age = this.calculateAge(birthDate);
const rights = userRights.map(ur => ur.rightType?.title).filter(Boolean); const rights = userRights.map(ur => ur.rightType?.title).filter(Boolean);
const filteredMenu = await this.filterMenu(menuStructure, rights, age, user.id); const filteredMenu = await this.filterMenu(menuStructure, rights, age, user.id);
// Dynamisches Submenü: Treffpunkt → Vokabeltrainer → (Neue Sprache + abonnierte/angelegte)
// Wichtig: "Neue Sprache" soll IMMER sichtbar sein auch wenn die DB-Abfrage (noch) fehlschlägt.
if (filteredMenu?.socialnetwork?.children?.vocabtrainer) {
const children = {
newLanguage: { path: '/socialnetwork/vocab/new' },
};
try {
const langs = await this.vocabService.listLanguagesForMenu(user.id);
for (const l of langs) {
children[`lang_${l.id}`] = { path: `/socialnetwork/vocab/${l.id}`, label: l.name };
}
} catch (e) {
console.warn('[menu] Konnte Vokabeltrainer-Sprachen nicht laden:', e?.message || e);
}
filteredMenu.socialnetwork.children.vocabtrainer.children = children;
}
res.status(200).json(filteredMenu); res.status(200).json(filteredMenu);
} catch (error) { } catch (error) {
console.error('Error fetching menu:', error); console.error('Error fetching menu:', error);

View File

@@ -0,0 +1,43 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
class TermineController {
async getTermine(req, res) {
try {
const csvPath = path.join(__dirname, '../data/termine.csv');
const csvContent = fs.readFileSync(csvPath, 'utf-8');
const lines = csvContent.trim().split('\n');
const headers = lines[0].split(',');
const termine = lines.slice(1).map(line => {
const values = line.split(',');
const termin = {};
headers.forEach((header, index) => {
termin[header] = values[index] || '';
});
return termin;
});
// Sortiere nach Datum
termine.sort((a, b) => new Date(a.datum) - new Date(b.datum));
// Filtere nur zukünftige Termine
const heute = new Date();
heute.setHours(0, 0, 0, 0);
const zukuenftigeTermine = termine.filter(t => new Date(t.datum) >= heute);
res.status(200).json(zukuenftigeTermine);
} catch (error) {
console.error('Error reading termine.csv:', error);
res.status(500).json({ error: 'Could not load termine' });
}
}
}
export default new TermineController();

View File

@@ -0,0 +1,46 @@
import VocabService from '../services/vocabService.js';
function extractHashedUserId(req) {
return req.headers?.userid;
}
class VocabController {
constructor() {
this.service = new VocabService();
this.listLanguages = this._wrapWithUser((userId) => this.service.listLanguages(userId));
this.createLanguage = this._wrapWithUser((userId, req) => this.service.createLanguage(userId, req.body), { successStatus: 201 });
this.subscribe = this._wrapWithUser((userId, req) => this.service.subscribeByShareCode(userId, req.body), { successStatus: 201 });
this.getLanguage = this._wrapWithUser((userId, req) => this.service.getLanguage(userId, req.params.languageId));
this.listChapters = this._wrapWithUser((userId, req) => this.service.listChapters(userId, req.params.languageId));
this.createChapter = this._wrapWithUser((userId, req) => this.service.createChapter(userId, req.params.languageId, req.body), { successStatus: 201 });
this.listLanguageVocabs = this._wrapWithUser((userId, req) => this.service.listLanguageVocabs(userId, req.params.languageId));
this.searchVocabs = this._wrapWithUser((userId, req) => this.service.searchVocabs(userId, req.params.languageId, req.query));
this.getChapter = this._wrapWithUser((userId, req) => this.service.getChapter(userId, req.params.chapterId));
this.listChapterVocabs = this._wrapWithUser((userId, req) => this.service.listChapterVocabs(userId, req.params.chapterId));
this.addVocabToChapter = this._wrapWithUser((userId, req) => this.service.addVocabToChapter(userId, req.params.chapterId, req.body), { successStatus: 201 });
}
_wrapWithUser(fn, { successStatus = 200 } = {}) {
return async (req, res) => {
try {
const hashedUserId = extractHashedUserId(req);
if (!hashedUserId) {
return res.status(400).json({ error: 'Missing user identifier' });
}
const result = await fn(hashedUserId, req, res);
res.status(successStatus).json(result);
} catch (error) {
console.error('Controller error:', error);
const status = error.status && typeof error.status === 'number' ? error.status : 500;
res.status(status).json({ error: error.message || 'Internal error' });
}
};
}
}
export default VocabController;

121
backend/daemonServer.js Normal file
View File

@@ -0,0 +1,121 @@
import WebSocket, { WebSocketServer } from 'ws';
import https from 'https';
import fs from 'fs';
const PORT = Number.parseInt(process.env.DAEMON_PORT || '4551', 10);
const USE_TLS = process.env.DAEMON_TLS === '1';
const TLS_KEY_PATH = process.env.DAEMON_TLS_KEY_PATH;
const TLS_CERT_PATH = process.env.DAEMON_TLS_CERT_PATH;
const TLS_CA_PATH = process.env.DAEMON_TLS_CA_PATH; // optional
// Einfache In-Memory-Struktur für Verbindungen (für spätere Erweiterungen)
const connections = new Set();
function createServer() {
let wss;
if (USE_TLS) {
if (!TLS_KEY_PATH || !TLS_CERT_PATH) {
console.error('[Daemon] DAEMON_TLS=1 gesetzt, aber DAEMON_TLS_KEY_PATH/DAEMON_TLS_CERT_PATH fehlen.');
process.exit(1);
}
const httpsServer = https.createServer({
key: fs.readFileSync(TLS_KEY_PATH),
cert: fs.readFileSync(TLS_CERT_PATH),
ca: TLS_CA_PATH ? fs.readFileSync(TLS_CA_PATH) : undefined,
});
wss = new WebSocketServer({ server: httpsServer });
httpsServer.listen(PORT, '0.0.0.0', () => {
console.log(`[Daemon] WSS (TLS) Server gestartet auf Port ${PORT}`);
});
} else {
wss = new WebSocketServer({ port: PORT });
console.log(`[Daemon] WS (ohne TLS) Server startet auf Port ${PORT} ...`);
}
wss.on('connection', (ws, req) => {
const peer = req.socket.remoteAddress + ':' + req.socket.remotePort;
ws.isAlive = true;
ws.userId = null;
connections.add(ws);
console.log(`[Daemon] Neue Verbindung von ${peer}`);
ws.on('message', (message) => {
try {
if (message.toString() === 'pong') {
// Client-Pong für unser Ping
ws.isAlive = true;
return;
}
const data = JSON.parse(message.toString());
// Vom Frontend gesendet nach Verbindungsaufbau
if (data.event === 'setUserId' && data.data?.userId) {
ws.userId = data.data.userId;
console.log(`[Daemon] setUserId erhalten: ${ws.userId}`);
return;
}
// Admin-Dialog: WebSocket-Log anfordern
if (data.event === 'getWebsocketLog') {
const response = {
event: 'getWebsocketLogResponse',
entries: [] // aktuell keine Log-Historie implementiert
};
ws.send(JSON.stringify(response));
return;
}
// Platzhalter für spätere Events
// console.log('[Daemon] Unbekanntes Event:', data);
} catch (err) {
console.error('[Daemon] Fehler beim Verarbeiten einer Nachricht:', err);
}
});
ws.on('close', () => {
connections.delete(ws);
console.log('[Daemon] Verbindung geschlossen');
});
ws.on('error', (err) => {
console.error('[Daemon] WebSocket-Fehler (Verbindung):', err);
});
});
// Einfache Ping/Pong-Mechanik, damit Verbindungen sauber erkannt werden
const interval = setInterval(() => {
for (const ws of connections) {
if (ws.isAlive === false) {
console.log('[Daemon] Verbindung wegen fehlendem Pong beendet');
ws.terminate();
connections.delete(ws);
continue;
}
ws.isAlive = false;
try {
ws.send('ping');
} catch (err) {
console.error('[Daemon] Fehler beim Senden von Ping:', err);
}
}
}, 30000);
wss.on('close', () => {
clearInterval(interval);
connections.clear();
console.log('[Daemon] Server gestoppt');
});
wss.on('error', (err) => {
console.error('[Daemon] Server-Fehler:', err);
});
return wss;
}
createServer();

7
backend/data/termine.csv Normal file
View File

@@ -0,0 +1,7 @@
datum,titel,beschreibung,ort,uhrzeit
2025-10-07,Vereinsmeisterschaften 2025 Doppel,Die Vereinsmeisterschaften 2025 im Doppel finden im Rahmen des Erwachsenentrainings statt.,,,
2026-01-17,Vereinsmeisterschaften 2025 Einzel,Die Vereinsmeisterschaften 2025 im Einzel finden in der Schulturnhalle statt. Bitte vormerken!,,10:00
2025-12-18,Weihnachtsfeier 2025,Die Weihnachtsfeier 2025 findet im Gasthaus „Zum Einhorn" in FFM-Bonames statt. Beginn 19:00 Uhr (bitte vormerken),Gasthaus „Zum Einhorn" FFM-Bonames,19:00
2025-09-14,VR-Cup,Zwei VR-Cups am 14.09.2025 (jeweils 12 und 16 Uhr),,12:00 und 16:00
2025-10-19,VR-Cup,Zwei VR-Cups am 19.10.2025 (jeweils 12 und 16 Uhr),,12:00 und 16:00
Can't render this file because it contains an unexpected character in line 4 and column 91.

View File

@@ -0,0 +1,34 @@
import { sequelize } from './utils/sequelize.js';
async function fixPgCryptoExtension() {
try {
console.log('🔧 Aktiviere pgcrypto Erweiterung...');
await sequelize.query('CREATE EXTENSION IF NOT EXISTS pgcrypto;');
console.log('✅ pgcrypto Erweiterung erfolgreich aktiviert');
// Prüfe ob die Erweiterung aktiviert ist
const result = await sequelize.query(`
SELECT EXISTS(
SELECT 1 FROM pg_extension WHERE extname = 'pgcrypto'
) as extension_exists;
`, { type: sequelize.QueryTypes.SELECT });
if (result[0]?.extension_exists) {
console.log('✅ Bestätigung: pgcrypto Erweiterung ist aktiviert');
} else {
console.warn('⚠️ Warnung: pgcrypto Erweiterung konnte nicht aktiviert werden');
}
process.exit(0);
} catch (error) {
console.error('❌ Fehler beim Aktivieren der pgcrypto Erweiterung:', error.message);
console.error('Stack:', error.stack);
process.exit(1);
}
}
fixPgCryptoExtension();

View File

@@ -0,0 +1,29 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn(
{
tableName: 'falukant_user',
schema: 'falukant_data'
},
'last_nobility_advance_at',
{
type: Sequelize.DATE,
allowNull: true
}
);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn(
{
tableName: 'falukant_user',
schema: 'falukant_data'
},
'last_nobility_advance_at'
);
}
};

View File

@@ -0,0 +1,135 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
// 1) Add character_name column to notification table
await queryInterface.sequelize.query(`
ALTER TABLE IF EXISTS falukant_log.notification
ADD COLUMN IF NOT EXISTS character_name text;
`);
// 1b) Add character_id column so triggers and application can set a reference
await queryInterface.sequelize.query(`
ALTER TABLE IF EXISTS falukant_log.notification
ADD COLUMN IF NOT EXISTS character_id integer;
`);
// Create an index on character_id to speed lookups (if not exists)
await queryInterface.sequelize.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'i' AND c.relname = 'idx_notification_character_id' AND n.nspname = 'falukant_log'
) THEN
CREATE INDEX idx_notification_character_id ON falukant_log.notification (character_id);
END IF;
END$$;
`);
// 2) Create helper function to populate character_name from character_id or user_id
// - Resolve name via character_id if present
// - Fallback to a character for the same user_id when character_id is NULL
// - Only set NEW.character_name when the column exists and is NULL
await queryInterface.sequelize.query(`
CREATE OR REPLACE FUNCTION falukant_log.populate_notification_character_name()
RETURNS TRIGGER AS $function$
DECLARE
v_first_name TEXT;
v_last_name TEXT;
v_char_id INTEGER;
v_column_exists BOOLEAN;
BEGIN
-- check if target column exists in the notification table
SELECT EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'falukant_log' AND table_name = 'notification' AND column_name = 'character_name'
) INTO v_column_exists;
IF NOT v_column_exists THEN
-- Nothing to do when target column absent
RETURN NEW;
END IF;
-- only populate when column is NULL
IF NEW.character_name IS NOT NULL THEN
RETURN NEW;
END IF;
-- prefer explicit character_id
v_char_id := NEW.character_id;
-- when character_id is null, try to find a character for the user_id
IF v_char_id IS NULL AND NEW.user_id IS NOT NULL THEN
-- choose a representative character: the one with highest id for this user (change if different policy required)
SELECT id INTO v_char_id
FROM falukant_data.character
WHERE user_id = NEW.user_id
ORDER BY id DESC
LIMIT 1;
END IF;
IF v_char_id IS NOT NULL THEN
SELECT pf.name, pl.name
INTO v_first_name, v_last_name
FROM falukant_data.character c
LEFT JOIN falukant_predefine.firstname pf ON pf.id = c.first_name
LEFT JOIN falukant_predefine.lastname pl ON pl.id = c.last_name
WHERE c.id = v_char_id;
IF v_first_name IS NOT NULL OR v_last_name IS NOT NULL THEN
NEW.character_name := COALESCE(v_first_name, '') || CASE WHEN v_first_name IS NOT NULL AND v_last_name IS NOT NULL THEN ' ' ELSE '' END || COALESCE(v_last_name, '');
ELSE
NEW.character_name := ('#' || v_char_id::text);
END IF;
ELSE
-- last resort fallback: use user_id as identifier if present
IF NEW.user_id IS NOT NULL THEN
NEW.character_name := ('#u' || NEW.user_id::text);
END IF;
END IF;
RETURN NEW;
END;
$function$ LANGUAGE plpgsql;
`);
// 3) Create trigger that runs before insert to populate the column
await queryInterface.sequelize.query(`
DROP TRIGGER IF EXISTS trg_populate_notification_character_name ON falukant_log.notification;
CREATE TRIGGER trg_populate_notification_character_name
BEFORE INSERT ON falukant_log.notification
FOR EACH ROW
EXECUTE FUNCTION falukant_log.populate_notification_character_name();
`);
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
DROP TRIGGER IF EXISTS trg_populate_notification_character_name ON falukant_log.notification;
`);
await queryInterface.sequelize.query(`
DROP FUNCTION IF EXISTS falukant_log.populate_notification_character_name();
`);
await queryInterface.sequelize.query(`
-- drop index if exists
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'i' AND c.relname = 'idx_notification_character_id' AND n.nspname = 'falukant_log'
) THEN
EXECUTE 'DROP INDEX falukant_log.idx_notification_character_id';
END IF;
END$$;
`);
await queryInterface.sequelize.query(`
ALTER TABLE IF EXISTS falukant_log.notification
DROP COLUMN IF EXISTS character_name;
`);
await queryInterface.sequelize.query(`
ALTER TABLE IF EXISTS falukant_log.notification
DROP COLUMN IF EXISTS character_id;
`);
}
};

View File

@@ -0,0 +1,68 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
// Add nullable weather_type_id column
await queryInterface.sequelize.query(`
ALTER TABLE IF EXISTS falukant_data.production
ADD COLUMN IF NOT EXISTS weather_type_id integer;
`);
// Add foreign key constraint if not exists
await queryInterface.sequelize.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu ON kcu.constraint_name = tc.constraint_name AND kcu.constraint_schema = tc.constraint_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.constraint_schema = 'falukant_data'
AND tc.table_name = 'production'
AND kcu.column_name = 'weather_type_id'
) THEN
ALTER TABLE falukant_data.production
ADD CONSTRAINT fk_production_weather_type
FOREIGN KEY (weather_type_id) REFERENCES falukant_type.weather(id);
END IF;
END$$;
`);
// create index to speed lookups
await queryInterface.sequelize.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'i' AND c.relname = 'idx_production_weather_type_id' AND n.nspname = 'falukant_data'
) THEN
CREATE INDEX idx_production_weather_type_id ON falukant_data.production (weather_type_id);
END IF;
END$$;
`);
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
ALTER TABLE IF EXISTS falukant_data.production
DROP CONSTRAINT IF EXISTS fk_production_weather_type;
`);
await queryInterface.sequelize.query(`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'i' AND c.relname = 'idx_production_weather_type_id' AND n.nspname = 'falukant_data'
) THEN
EXECUTE 'DROP INDEX falukant_data.idx_production_weather_type_id';
END IF;
END$$;
`);
await queryInterface.sequelize.query(`
ALTER TABLE IF EXISTS falukant_data.production
DROP COLUMN IF EXISTS weather_type_id;
`);
}
};

View File

@@ -0,0 +1,17 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
ALTER TABLE IF EXISTS falukant_data.stock
ADD COLUMN IF NOT EXISTS product_quality integer;
`);
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
ALTER TABLE IF EXISTS falukant_data.stock
DROP COLUMN IF EXISTS product_quality;
`);
}
};

View File

@@ -0,0 +1,79 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
// falukant_data.character.reputation (integer, default random 20..80)
// Wichtig: Schema explizit angeben
// Vorgehen:
// - Spalte anlegen (falls noch nicht vorhanden)
// - bestehende Zeilen initialisieren (random 20..80)
// - DEFAULT setzen (random 20..80)
// - NOT NULL + CHECK 0..100 erzwingen
await queryInterface.sequelize.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'falukant_data'
AND table_name = 'character'
AND column_name = 'reputation'
) THEN
ALTER TABLE falukant_data."character"
ADD COLUMN reputation integer;
END IF;
END$$;
`);
// Backfill: nur NULLs initialisieren (damit bestehende Werte nicht überschrieben werden)
await queryInterface.sequelize.query(`
UPDATE falukant_data."character"
SET reputation = (floor(random()*61)+20)::int
WHERE reputation IS NULL;
`);
// DEFAULT + NOT NULL (nach Backfill)
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data."character"
ALTER COLUMN reputation SET DEFAULT (floor(random()*61)+20)::int;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data."character"
ALTER COLUMN reputation SET NOT NULL;
`);
// Enforce 0..100 at DB level (percent)
// (IF NOT EXISTS pattern, because deployments can be re-run)
await queryInterface.sequelize.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE c.conname = 'character_reputation_0_100_chk'
AND n.nspname = 'falukant_data'
AND t.relname = 'character'
) THEN
ALTER TABLE falukant_data."character"
ADD CONSTRAINT character_reputation_0_100_chk
CHECK (reputation >= 0 AND reputation <= 100);
END IF;
END$$;
`);
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data."character"
DROP CONSTRAINT IF EXISTS character_reputation_0_100_chk;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data."character"
DROP COLUMN IF EXISTS reputation;
`);
},
};

View File

@@ -0,0 +1,47 @@
/* eslint-disable */
module.exports = {
async up(queryInterface, Sequelize) {
// Typ-Tabelle (konfigurierbar ohne Code): falukant_type.reputation_action
await queryInterface.sequelize.query(`
CREATE TABLE IF NOT EXISTS falukant_type.reputation_action (
id serial PRIMARY KEY,
tr text NOT NULL UNIQUE,
cost integer NOT NULL CHECK (cost >= 0),
base_gain integer NOT NULL CHECK (base_gain >= 0),
decay_factor double precision NOT NULL CHECK (decay_factor > 0 AND decay_factor <= 1),
min_gain integer NOT NULL DEFAULT 0 CHECK (min_gain >= 0),
decay_window_days integer NOT NULL DEFAULT 7 CHECK (decay_window_days >= 1 AND decay_window_days <= 365)
);
`);
// Log-Tabelle: falukant_log.reputation_action
await queryInterface.sequelize.query(`
CREATE TABLE IF NOT EXISTS falukant_log.reputation_action (
id serial PRIMARY KEY,
falukant_user_id integer NOT NULL,
action_type_id integer NOT NULL,
cost integer NOT NULL CHECK (cost >= 0),
base_gain integer NOT NULL CHECK (base_gain >= 0),
gain integer NOT NULL CHECK (gain >= 0),
times_used_before integer NOT NULL CHECK (times_used_before >= 0),
action_timestamp timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
await queryInterface.sequelize.query(`
CREATE INDEX IF NOT EXISTS reputation_action_log_user_type_idx
ON falukant_log.reputation_action (falukant_user_id, action_type_id);
`);
await queryInterface.sequelize.query(`
CREATE INDEX IF NOT EXISTS reputation_action_log_ts_idx
ON falukant_log.reputation_action (action_timestamp);
`);
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS falukant_log.reputation_action;`);
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS falukant_type.reputation_action;`);
},
};

View File

@@ -0,0 +1,46 @@
/* eslint-disable */
module.exports = {
async up(queryInterface, Sequelize) {
// Für bereits existierende Installationen: Spalte sicherstellen + Backfill
await queryInterface.sequelize.query(`
ALTER TABLE falukant_type.reputation_action
ADD COLUMN IF NOT EXISTS decay_window_days integer;
`);
await queryInterface.sequelize.query(`
UPDATE falukant_type.reputation_action
SET decay_window_days = 7
WHERE decay_window_days IS NULL;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_type.reputation_action
ALTER COLUMN decay_window_days SET DEFAULT 7;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_type.reputation_action
ALTER COLUMN decay_window_days SET NOT NULL;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_type.reputation_action
DROP CONSTRAINT IF EXISTS reputation_action_decay_window_days_chk;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_type.reputation_action
ADD CONSTRAINT reputation_action_decay_window_days_chk
CHECK (decay_window_days >= 1 AND decay_window_days <= 365);
`);
},
async down(queryInterface, Sequelize) {
// optional: wieder entfernen
await queryInterface.sequelize.query(`
ALTER TABLE falukant_type.reputation_action
DROP CONSTRAINT IF EXISTS reputation_action_decay_window_days_chk;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_type.reputation_action
DROP COLUMN IF EXISTS decay_window_days;
`);
},
};

View File

@@ -0,0 +1,50 @@
/* eslint-disable */
module.exports = {
async up(queryInterface, Sequelize) {
// Idempotentes Seed: legt Ruf-Aktionen an bzw. aktualisiert sie anhand "tr"
await queryInterface.sequelize.query(`
INSERT INTO falukant_type.reputation_action
(tr, cost, base_gain, decay_factor, min_gain, decay_window_days)
VALUES
('soup_kitchen', 500, 2, 0.85, 0, 7),
('library_donation', 5000, 4, 0.88, 0, 7),
('well_build', 8000, 4, 0.87, 0, 7),
('scholarships', 10000, 5, 0.87, 0, 7),
('church_hospice', 12000, 5, 0.87, 0, 7),
('school_funding', 15000, 6, 0.88, 0, 7),
('orphanage_build', 20000, 7, 0.90, 0, 7),
('bridge_build', 25000, 7, 0.90, 0, 7),
('hospital_donation', 30000, 8, 0.90, 0, 7),
('patronage', 40000, 9, 0.91, 0, 7),
('statue_build', 50000, 10, 0.92, 0, 7)
ON CONFLICT (tr) DO UPDATE SET
cost = EXCLUDED.cost,
base_gain = EXCLUDED.base_gain,
decay_factor = EXCLUDED.decay_factor,
min_gain = EXCLUDED.min_gain,
decay_window_days = EXCLUDED.decay_window_days;
`);
},
async down(queryInterface, Sequelize) {
// Entfernt nur die gesetzten Seeds (tr-basiert)
await queryInterface.sequelize.query(`
DELETE FROM falukant_type.reputation_action
WHERE tr IN (
'soup_kitchen',
'library_donation',
'well_build',
'scholarships',
'church_hospice',
'school_funding',
'orphanage_build',
'bridge_build',
'hospital_donation',
'patronage',
'statue_build'
);
`);
},
};

View File

@@ -0,0 +1,60 @@
/* eslint-disable */
module.exports = {
async up(queryInterface, Sequelize) {
// Ensure column exists
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.vehicle
ADD COLUMN IF NOT EXISTS condition integer;
`);
// Backfill nulls (legacy data)
await queryInterface.sequelize.query(`
UPDATE falukant_data.vehicle
SET condition = 100
WHERE condition IS NULL;
`);
// Clamp out-of-range values defensively
await queryInterface.sequelize.query(`
UPDATE falukant_data.vehicle
SET condition = GREATEST(0, LEAST(100, condition))
WHERE condition < 0 OR condition > 100;
`);
// Default + NOT NULL
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.vehicle
ALTER COLUMN condition SET DEFAULT 100;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.vehicle
ALTER COLUMN condition SET NOT NULL;
`);
// Check constraint 0..100
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.vehicle
DROP CONSTRAINT IF EXISTS vehicle_condition_0_100_chk;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.vehicle
ADD CONSTRAINT vehicle_condition_0_100_chk
CHECK (condition >= 0 AND condition <= 100);
`);
},
async down(queryInterface, Sequelize) {
// Keep the column, but remove constraint/default to be reversible
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.vehicle
DROP CONSTRAINT IF EXISTS vehicle_condition_0_100_chk;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.vehicle
ALTER COLUMN condition DROP DEFAULT;
`);
// NOT NULL not reverted to avoid introducing NULLs on rollback; can be adjusted if needed
},
};

View File

@@ -0,0 +1,32 @@
/* eslint-disable */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.director
ADD COLUMN IF NOT EXISTS may_repair_vehicles boolean;
`);
await queryInterface.sequelize.query(`
UPDATE falukant_data.director
SET may_repair_vehicles = true
WHERE may_repair_vehicles IS NULL;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.director
ALTER COLUMN may_repair_vehicles SET DEFAULT true;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.director
ALTER COLUMN may_repair_vehicles SET NOT NULL;
`);
},
async down(queryInterface, Sequelize) {
// optional rollback: drop column
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.director
DROP COLUMN IF EXISTS may_repair_vehicles;
`);
},
};

View File

@@ -0,0 +1,61 @@
/* eslint-disable */
'use strict';
module.exports = {
async up(queryInterface) {
// Sprache / Set, das geteilt werden kann
await queryInterface.sequelize.query(`
CREATE TABLE IF NOT EXISTS community.vocab_language (
id SERIAL PRIMARY KEY,
owner_user_id INTEGER NOT NULL,
name TEXT NOT NULL,
share_code TEXT NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT vocab_language_owner_fk
FOREIGN KEY (owner_user_id)
REFERENCES community."user"(id)
ON DELETE CASCADE,
CONSTRAINT vocab_language_share_code_uniq UNIQUE (share_code)
);
`);
// Abos (Freunde)
await queryInterface.sequelize.query(`
CREATE TABLE IF NOT EXISTS community.vocab_language_subscription (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
language_id INTEGER NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT vocab_language_subscription_user_fk
FOREIGN KEY (user_id)
REFERENCES community."user"(id)
ON DELETE CASCADE,
CONSTRAINT vocab_language_subscription_language_fk
FOREIGN KEY (language_id)
REFERENCES community.vocab_language(id)
ON DELETE CASCADE,
CONSTRAINT vocab_language_subscription_uniq UNIQUE (user_id, language_id)
);
`);
await queryInterface.sequelize.query(`
CREATE INDEX IF NOT EXISTS vocab_language_owner_idx
ON community.vocab_language(owner_user_id);
`);
await queryInterface.sequelize.query(`
CREATE INDEX IF NOT EXISTS vocab_language_subscription_user_idx
ON community.vocab_language_subscription(user_id);
`);
await queryInterface.sequelize.query(`
CREATE INDEX IF NOT EXISTS vocab_language_subscription_language_idx
ON community.vocab_language_subscription(language_id);
`);
},
async down(queryInterface) {
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS community.vocab_language_subscription;`);
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS community.vocab_language;`);
}
};

View File

@@ -0,0 +1,106 @@
/* eslint-disable */
'use strict';
module.exports = {
async up(queryInterface) {
// Kapitel innerhalb einer Sprache
await queryInterface.sequelize.query(`
CREATE TABLE IF NOT EXISTS community.vocab_chapter (
id SERIAL PRIMARY KEY,
language_id INTEGER NOT NULL,
title TEXT NOT NULL,
created_by_user_id INTEGER NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT vocab_chapter_language_fk
FOREIGN KEY (language_id)
REFERENCES community.vocab_language(id)
ON DELETE CASCADE,
CONSTRAINT vocab_chapter_creator_fk
FOREIGN KEY (created_by_user_id)
REFERENCES community."user"(id)
ON DELETE CASCADE
);
`);
await queryInterface.sequelize.query(`
CREATE INDEX IF NOT EXISTS vocab_chapter_language_idx
ON community.vocab_chapter(language_id);
`);
// Lexeme/Wörter (wir deduplizieren pro Sprache über normalized)
await queryInterface.sequelize.query(`
CREATE TABLE IF NOT EXISTS community.vocab_lexeme (
id SERIAL PRIMARY KEY,
language_id INTEGER NOT NULL,
text TEXT NOT NULL,
normalized TEXT NOT NULL,
created_by_user_id INTEGER NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT vocab_lexeme_language_fk
FOREIGN KEY (language_id)
REFERENCES community.vocab_language(id)
ON DELETE CASCADE,
CONSTRAINT vocab_lexeme_creator_fk
FOREIGN KEY (created_by_user_id)
REFERENCES community."user"(id)
ON DELETE CASCADE,
CONSTRAINT vocab_lexeme_unique_per_language UNIQUE (language_id, normalized)
);
`);
await queryInterface.sequelize.query(`
CREATE INDEX IF NOT EXISTS vocab_lexeme_language_idx
ON community.vocab_lexeme(language_id);
`);
// n:m Zuordnung pro Kapitel: Lernwort ↔ Referenzwort (Mehrdeutigkeiten möglich)
await queryInterface.sequelize.query(`
CREATE TABLE IF NOT EXISTS community.vocab_chapter_lexeme (
id SERIAL PRIMARY KEY,
chapter_id INTEGER NOT NULL,
learning_lexeme_id INTEGER NOT NULL,
reference_lexeme_id INTEGER NOT NULL,
created_by_user_id INTEGER NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT vocab_chlex_chapter_fk
FOREIGN KEY (chapter_id)
REFERENCES community.vocab_chapter(id)
ON DELETE CASCADE,
CONSTRAINT vocab_chlex_learning_fk
FOREIGN KEY (learning_lexeme_id)
REFERENCES community.vocab_lexeme(id)
ON DELETE CASCADE,
CONSTRAINT vocab_chlex_reference_fk
FOREIGN KEY (reference_lexeme_id)
REFERENCES community.vocab_lexeme(id)
ON DELETE CASCADE,
CONSTRAINT vocab_chlex_creator_fk
FOREIGN KEY (created_by_user_id)
REFERENCES community."user"(id)
ON DELETE CASCADE,
CONSTRAINT vocab_chlex_unique UNIQUE (chapter_id, learning_lexeme_id, reference_lexeme_id)
);
`);
await queryInterface.sequelize.query(`
CREATE INDEX IF NOT EXISTS vocab_chlex_chapter_idx
ON community.vocab_chapter_lexeme(chapter_id);
`);
await queryInterface.sequelize.query(`
CREATE INDEX IF NOT EXISTS vocab_chlex_learning_idx
ON community.vocab_chapter_lexeme(learning_lexeme_id);
`);
await queryInterface.sequelize.query(`
CREATE INDEX IF NOT EXISTS vocab_chlex_reference_idx
ON community.vocab_chapter_lexeme(reference_lexeme_id);
`);
},
async down(queryInterface) {
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS community.vocab_chapter_lexeme;`);
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS community.vocab_lexeme;`);
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS community.vocab_chapter;`);
}
};

View File

@@ -0,0 +1,17 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
ALTER TABLE IF EXISTS falukant_data.region
ADD COLUMN IF NOT EXISTS tax_percent numeric NOT NULL DEFAULT 7;
`);
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
ALTER TABLE IF EXISTS falukant_data.region
DROP COLUMN IF EXISTS tax_percent;
`);
}
};

View File

@@ -0,0 +1,50 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
// 1) add backup column for original sell_cost (idempotent)
await queryInterface.sequelize.query(`
ALTER TABLE IF EXISTS falukant_type.product
ADD COLUMN IF NOT EXISTS original_sell_cost numeric;
`);
// 2) if original_sell_cost is not set, copy current sell_cost into it
await queryInterface.sequelize.query(`
UPDATE falukant_type.product
SET original_sell_cost = sell_cost
WHERE original_sell_cost IS NULL;
`);
// 3) compute max cumulative tax across regions and increase sell_cost accordingly
// We use the maximum cumulative tax (worst-case) so sellers are neutral across regions.
// Formula: neutral_sell = CEIL(original_sell_cost * (1 / (1 - max_total/100)))
await queryInterface.sequelize.query(`
WITH RECURSIVE ancestors AS (
SELECT id AS start_id, id, parent_id, tax_percent FROM falukant_data.region
UNION ALL
SELECT a.start_id, r.id, r.parent_id, r.tax_percent
FROM falukant_data.region r
JOIN ancestors a ON r.id = a.parent_id
), totals AS (
SELECT start_id, COALESCE(SUM(tax_percent), 0) AS total FROM ancestors GROUP BY start_id
), mm AS (
SELECT COALESCE(MAX(total),0) AS max_total FROM totals
)
UPDATE falukant_type.product
SET sell_cost = CEIL(original_sell_cost * (CASE WHEN (1 - mm.max_total/100) <= 0 THEN 1 ELSE (1 / (1 - mm.max_total/100)) END))
FROM mm
WHERE original_sell_cost IS NOT NULL;
`);
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
ALTER TABLE IF EXISTS falukant_type.product
DROP COLUMN IF EXISTS sell_cost_min_neutral;
`);
await queryInterface.sequelize.query(`
ALTER TABLE IF EXISTS falukant_type.product
DROP COLUMN IF EXISTS sell_cost_max_neutral;
`);
}
};

View File

@@ -0,0 +1,30 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
// Create index on (user_id, shown) to optimize markNotificationsShown queries
// This prevents deadlocks by allowing fast lookups and reducing lock contention
await queryInterface.sequelize.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'i'
AND c.relname = 'idx_notification_user_id_shown'
AND n.nspname = 'falukant_log'
) THEN
CREATE INDEX idx_notification_user_id_shown
ON falukant_log.notification (user_id, shown);
END IF;
END$$;
`);
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
DROP INDEX IF EXISTS falukant_log.idx_notification_user_id_shown;
`);
}
};

View File

@@ -0,0 +1,20 @@
-- Migration: Add condition and available_from columns to vehicle table
-- Date: 2024-12-02
ALTER TABLE falukant_data.vehicle
ADD COLUMN IF NOT EXISTS condition INTEGER NOT NULL DEFAULT 100;
ALTER TABLE falukant_data.vehicle
ADD COLUMN IF NOT EXISTS available_from TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP;
COMMENT ON COLUMN falukant_data.vehicle.condition IS 'Current condition of the vehicle (0-100)';
COMMENT ON COLUMN falukant_data.vehicle.available_from IS 'Timestamp when the vehicle becomes available for use';
-- Migration: Add build_time_minutes column to vehicle type table
-- Date: 2024-12-03
ALTER TABLE falukant_type.vehicle
ADD COLUMN IF NOT EXISTS build_time_minutes INTEGER NOT NULL DEFAULT 0;
COMMENT ON COLUMN falukant_type.vehicle.build_time_minutes IS 'Time to construct the vehicle, in minutes';

View File

@@ -0,0 +1,9 @@
-- Migration: Add is_heir column to child_relation table
-- Date: 2025-12-08
-- Description: Adds a boolean field to mark a child as the heir
ALTER TABLE falukant_data.child_relation
ADD COLUMN IF NOT EXISTS is_heir BOOLEAN DEFAULT false;
COMMENT ON COLUMN falukant_data.child_relation.is_heir IS 'Marks whether this child is set as the heir';

View File

@@ -2,6 +2,11 @@
module.exports = { module.exports = {
up: async (queryInterface, Sequelize) => { up: async (queryInterface, Sequelize) => {
// Aktiviere die pgcrypto Erweiterung, die die digest() Funktion bereitstellt
await queryInterface.sequelize.query(`
CREATE EXTENSION IF NOT EXISTS pgcrypto;
`);
await queryInterface.sequelize.query(` await queryInterface.sequelize.query(`
CREATE OR REPLACE FUNCTION community.update_hashed_id() RETURNS TRIGGER AS $$ CREATE OR REPLACE FUNCTION community.update_hashed_id() RETURNS TRIGGER AS $$
BEGIN BEGIN

View File

@@ -0,0 +1,7 @@
-- Migration: Make productId and size nullable in transport table
-- This allows empty transports (moving vehicles without products)
ALTER TABLE falukant_data.transport
ALTER COLUMN product_id DROP NOT NULL,
ALTER COLUMN size DROP NOT NULL;

View File

@@ -95,6 +95,13 @@ import PoliticalOfficeHistory from './falukant/log/political_office_history.js';
import ElectionHistory from './falukant/log/election_history.js'; import ElectionHistory from './falukant/log/election_history.js';
import Underground from './falukant/data/underground.js'; import Underground from './falukant/data/underground.js';
import UndergroundType from './falukant/type/underground.js'; import UndergroundType from './falukant/type/underground.js';
import VehicleType from './falukant/type/vehicle.js';
import Vehicle from './falukant/data/vehicle.js';
import Transport from './falukant/data/transport.js';
import RegionDistance from './falukant/data/region_distance.js';
import WeatherType from './falukant/type/weather.js';
import Weather from './falukant/data/weather.js';
import ProductWeatherEffect from './falukant/type/product_weather_effect.js';
import Blog from './community/blog.js'; import Blog from './community/blog.js';
import BlogPost from './community/blog_post.js'; import BlogPost from './community/blog_post.js';
import Campaign from './match3/campaign.js'; import Campaign from './match3/campaign.js';
@@ -284,6 +291,21 @@ export default function setupAssociations() {
RegionData.belongsTo(RegionType, { foreignKey: 'regionTypeId', as: 'regionType' }); RegionData.belongsTo(RegionType, { foreignKey: 'regionTypeId', as: 'regionType' });
RegionType.hasMany(RegionData, { foreignKey: 'regionTypeId', as: 'regions' }); RegionType.hasMany(RegionData, { foreignKey: 'regionTypeId', as: 'regions' });
Weather.belongsTo(RegionData, { foreignKey: 'regionId', as: 'region' });
RegionData.hasOne(Weather, { foreignKey: 'regionId', as: 'weather' });
Weather.belongsTo(WeatherType, { foreignKey: 'weatherTypeId', as: 'weatherType' });
WeatherType.hasMany(Weather, { foreignKey: 'weatherTypeId', as: 'weathers' });
ProductWeatherEffect.belongsTo(ProductType, { foreignKey: 'productId', as: 'product' });
ProductType.hasMany(ProductWeatherEffect, { foreignKey: 'productId', as: 'weatherEffects' });
ProductWeatherEffect.belongsTo(WeatherType, { foreignKey: 'weatherTypeId', as: 'weatherType' });
WeatherType.hasMany(ProductWeatherEffect, { foreignKey: 'weatherTypeId', as: 'productEffects' });
Production.belongsTo(WeatherType, { foreignKey: 'weatherTypeId', as: 'weatherType' });
WeatherType.hasMany(Production, { foreignKey: 'weatherTypeId', as: 'productions' });
FalukantUser.belongsTo(RegionData, { foreignKey: 'mainBranchRegionId', as: 'mainBranchRegion' }); FalukantUser.belongsTo(RegionData, { foreignKey: 'mainBranchRegionId', as: 'mainBranchRegion' });
RegionData.hasMany(FalukantUser, { foreignKey: 'mainBranchRegionId', as: 'users' }); RegionData.hasMany(FalukantUser, { foreignKey: 'mainBranchRegionId', as: 'users' });
@@ -421,6 +443,89 @@ export default function setupAssociations() {
PromotionalGiftLog.belongsTo(FalukantCharacter, { foreignKey: 'recipientCharacterId', as: 'recipient' }); PromotionalGiftLog.belongsTo(FalukantCharacter, { foreignKey: 'recipientCharacterId', as: 'recipient' });
FalukantCharacter.hasMany(PromotionalGiftLog, { foreignKey: 'recipientCharacterId', as: 'giftlogs' }); FalukantCharacter.hasMany(PromotionalGiftLog, { foreignKey: 'recipientCharacterId', as: 'giftlogs' });
// Vehicles & Transports
VehicleType.hasMany(Vehicle, {
foreignKey: 'vehicleTypeId',
as: 'vehicles',
});
Vehicle.belongsTo(VehicleType, {
foreignKey: 'vehicleTypeId',
as: 'type',
});
FalukantUser.hasMany(Vehicle, {
foreignKey: 'falukantUserId',
as: 'vehicles',
});
Vehicle.belongsTo(FalukantUser, {
foreignKey: 'falukantUserId',
as: 'owner',
});
RegionData.hasMany(Vehicle, {
foreignKey: 'regionId',
as: 'vehicles',
});
Vehicle.belongsTo(RegionData, {
foreignKey: 'regionId',
as: 'region',
});
// Region distances
RegionData.hasMany(RegionDistance, {
foreignKey: 'sourceRegionId',
as: 'distancesFrom',
});
RegionData.hasMany(RegionDistance, {
foreignKey: 'targetRegionId',
as: 'distancesTo',
});
RegionDistance.belongsTo(RegionData, {
foreignKey: 'sourceRegionId',
as: 'sourceRegion',
});
RegionDistance.belongsTo(RegionData, {
foreignKey: 'targetRegionId',
as: 'targetRegion',
});
Transport.belongsTo(RegionData, {
foreignKey: 'sourceRegionId',
as: 'sourceRegion',
});
Transport.belongsTo(RegionData, {
foreignKey: 'targetRegionId',
as: 'targetRegion',
});
RegionData.hasMany(Transport, {
foreignKey: 'sourceRegionId',
as: 'outgoingTransports',
});
RegionData.hasMany(Transport, {
foreignKey: 'targetRegionId',
as: 'incomingTransports',
});
Transport.belongsTo(ProductType, {
foreignKey: 'productId',
as: 'productType',
});
ProductType.hasMany(Transport, {
foreignKey: 'productId',
as: 'transports',
});
Transport.belongsTo(Vehicle, {
foreignKey: 'vehicleId',
as: 'vehicle',
});
Vehicle.hasMany(Transport, {
foreignKey: 'vehicleId',
as: 'transports',
});
PromotionalGift.hasMany(PromotionalGiftCharacterTrait, { foreignKey: 'gift_id', as: 'characterTraits' }); PromotionalGift.hasMany(PromotionalGiftCharacterTrait, { foreignKey: 'gift_id', as: 'characterTraits' });
PromotionalGift.hasMany(PromotionalGiftMood, { foreignKey: 'gift_id', as: 'promotionalgiftmoods' }); PromotionalGift.hasMany(PromotionalGiftMood, { foreignKey: 'gift_id', as: 'promotionalgiftmoods' });
@@ -493,44 +598,52 @@ export default function setupAssociations() {
Learning.belongsTo(LearnRecipient, { Learning.belongsTo(LearnRecipient, {
foreignKey: 'learningRecipientId', foreignKey: 'learningRecipientId',
as: 'recipient' as: 'recipient',
constraints: false
} }
); );
LearnRecipient.hasMany(Learning, { LearnRecipient.hasMany(Learning, {
foreignKey: 'learningRecipientId', foreignKey: 'learningRecipientId',
as: 'learnings' as: 'learnings',
constraints: false
}); });
Learning.belongsTo(FalukantUser, { Learning.belongsTo(FalukantUser, {
foreignKey: 'associatedFalukantUserId', foreignKey: 'associatedFalukantUserId',
as: 'learner' as: 'learner',
constraints: false
} }
); );
FalukantUser.hasMany(Learning, { FalukantUser.hasMany(Learning, {
foreignKey: 'associatedFalukantUserId', foreignKey: 'associatedFalukantUserId',
as: 'learnings' as: 'learnings',
constraints: false
}); });
Learning.belongsTo(ProductType, { Learning.belongsTo(ProductType, {
foreignKey: 'productId', foreignKey: 'productId',
as: 'productType' as: 'productType',
constraints: false
}); });
ProductType.hasMany(Learning, { ProductType.hasMany(Learning, {
foreignKey: 'productId', foreignKey: 'productId',
as: 'learnings' as: 'learnings',
constraints: false
}); });
Learning.belongsTo(FalukantCharacter, { Learning.belongsTo(FalukantCharacter, {
foreignKey: 'associatedLearningCharacterId', foreignKey: 'associatedLearningCharacterId',
as: 'learningCharacter' as: 'learningCharacter',
constraints: false
}); });
FalukantCharacter.hasMany(Learning, { FalukantCharacter.hasMany(Learning, {
foreignKey: 'associatedLearningCharacterId', foreignKey: 'associatedLearningCharacterId',
as: 'learningsCharacter' as: 'learningsCharacter',
constraints: false
}); });
FalukantUser.hasMany(Credit, { FalukantUser.hasMany(Credit, {

View File

@@ -8,16 +8,12 @@ const Folder = sequelize.define('folder', {
allowNull: false}, allowNull: false},
parentId: { parentId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: true, allowNull: true
references: { },
model: 'folder',
key: 'id'}},
userId: { userId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false
references: { }}, {
model: 'user',
key: 'id'}}}, {
tableName: 'folder', tableName: 'folder',
schema: 'community', schema: 'community',
underscored: true, underscored: true,

View File

@@ -10,22 +10,11 @@ const FolderImageVisibility = sequelize.define('folder_image_visibility', {
}, },
folderId: { folderId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false
references: {
model: 'folder',
key: 'id'
}
}, },
visibilityTypeId: { visibilityTypeId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false
references: {
model: {
schema: 'type',
tableName: 'image_visibility_type'
},
key: 'id'
}
} }
}, { }, {
tableName: 'folder_image_visibility', tableName: 'folder_image_visibility',

View File

@@ -10,19 +10,11 @@ const FolderVisibilityUser = sequelize.define('folder_visibility_user', {
}, },
folderId: { folderId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false
references: {
model: 'folder',
key: 'id'
}
}, },
visibilityUserId: { visibilityUserId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false
references: {
model: 'image_visibility_user',
key: 'id'
}
} }
}, { }, {
tableName: 'folder_visibility_user', tableName: 'folder_visibility_user',

View File

@@ -10,19 +10,11 @@ const GuestbookEntry = sequelize.define('guestbook_entry', {
allowNull: false}, allowNull: false},
recipientId: { recipientId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false
references: {
model: User,
key: 'id'
}
}, },
senderId: { senderId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: true, allowNull: true
references: {
model: User,
key: 'id'
}
}, },
senderUsername: { senderUsername: {
type: DataTypes.STRING, type: DataTypes.STRING,

View File

@@ -18,16 +18,12 @@ const Image = sequelize.define('image', {
unique: true}, unique: true},
folderId: { folderId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false
references: { },
model: 'folder',
key: 'id'}},
userId: { userId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false
references: { }}, {
model: 'user',
key: 'id'}}}, {
tableName: 'image', tableName: 'image',
schema: 'community', schema: 'community',
underscored: true, underscored: true,

View File

@@ -10,22 +10,11 @@ const ImageImageVisibility = sequelize.define('image_image_visibility', {
}, },
imageId: { imageId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false
references: {
model: 'image',
key: 'id'
}
}, },
visibilityTypeId: { visibilityTypeId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false
references: {
model: {
schema: 'type',
tableName: 'image_visibility_type'
},
key: 'id'
}
} }
}, { }, {
tableName: 'image_image_visibility', tableName: 'image_image_visibility',

View File

@@ -7,19 +7,11 @@ import { encrypt, decrypt } from '../../utils/encryption.js';
const UserParam = sequelize.define('user_param', { const UserParam = sequelize.define('user_param', {
userId: { userId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false
references: {
model: User,
key: 'id',
},
}, },
paramTypeId: { paramTypeId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false
references: {
model: UserParamType,
key: 'id',
},
}, },
value: { value: {
type: DataTypes.STRING, type: DataTypes.STRING,

View File

@@ -6,19 +6,11 @@ import UserRightType from '../type/user_right.js';
const UserRight = sequelize.define('user_right', { const UserRight = sequelize.define('user_right', {
userId: { userId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false
references: {
model: User,
key: 'id'
}
}, },
rightTypeId: { rightTypeId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false
references: {
model: UserRightType,
key: 'id'
}
}}, { }}, {
tableName: 'user_right', tableName: 'user_right',
schema: 'community', schema: 'community',

View File

@@ -34,6 +34,18 @@ FalukantCharacter.init(
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false,
defaultValue: 1} defaultValue: 1}
,
reputation: {
type: DataTypes.INTEGER,
allowNull: false,
// Initialisierung: zufällig 20..80 (Prozent)
// DB-seitig per DEFAULT umgesetzt, damit es auch ohne App-Logic gilt.
defaultValue: sequelize.literal('(floor(random()*61)+20)'),
validate: {
min: 0,
max: 100
}
}
}, },
{ {
sequelize, sequelize,

View File

@@ -29,6 +29,10 @@ Director.init({
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
allowNull: false, allowNull: false,
defaultValue: true}, defaultValue: true},
mayRepairVehicles: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true},
lastSalaryPayout: { lastSalaryPayout: {
type: DataTypes.DATE, type: DataTypes.DATE,
allowNull: false, allowNull: false,

View File

@@ -1,5 +1,6 @@
import { Model, DataTypes } from 'sequelize'; import { Model, DataTypes } from 'sequelize';
import { sequelize } from '../../../utils/sequelize.js'; import { sequelize } from '../../../utils/sequelize.js';
import WeatherType from '../type/weather.js';
class Production extends Model { } class Production extends Model { }
@@ -13,6 +14,11 @@ Production.init({
quantity: { quantity: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false}, allowNull: false},
weatherTypeId: {
type: DataTypes.INTEGER,
allowNull: true,
comment: 'Wetter zum Zeitpunkt der Produktionserstellung'
},
startTimestamp: { startTimestamp: {
type: DataTypes.DATE, type: DataTypes.DATE,
allowNull: false, allowNull: false,

View File

@@ -10,26 +10,24 @@ RegionData.init({
allowNull: false}, allowNull: false},
regionTypeId: { regionTypeId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false
references: {
model: RegionType,
key: 'id',
schema: 'falukant_type'
}
}, },
parentId: { parentId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: true, allowNull: true
references: {
model: 'region',
key: 'id',
schema: 'falukant_data'}
}, },
map: { map: {
type: DataTypes.JSONB, type: DataTypes.JSONB,
allowNull: true, allowNull: true,
defaultValue: {} defaultValue: {}
} }
,
taxPercent: {
type: DataTypes.DECIMAL,
allowNull: false,
defaultValue: 7,
field: 'tax_percent'
}
}, { }, {
sequelize, sequelize,
modelName: 'RegionData', modelName: 'RegionData',

View File

@@ -0,0 +1,41 @@
import { Model, DataTypes } from 'sequelize';
import { sequelize } from '../../../utils/sequelize.js';
import RegionData from './region.js';
class RegionDistance extends Model {}
RegionDistance.init(
{
sourceRegionId: {
type: DataTypes.INTEGER,
allowNull: false
},
targetRegionId: {
type: DataTypes.INTEGER,
allowNull: false
},
transportMode: {
// e.g. 'land', 'water', 'air' should match VehicleType.transportMode
type: DataTypes.STRING,
allowNull: false,
},
distance: {
// distance between regions (e.g. in abstract units, used for travel time etc.)
type: DataTypes.DOUBLE,
allowNull: false,
},
},
{
sequelize,
modelName: 'RegionDistance',
tableName: 'region_distance',
schema: 'falukant_data',
timestamps: false,
underscored: true,
}
);
export default RegionDistance;

View File

@@ -8,18 +8,10 @@ Relationship.init(
{ {
character1Id: { character1Id: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false},
references: {
model: FalukantCharacter,
key: 'id'},
onDelete: 'CASCADE'},
character2Id: { character2Id: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false},
references: {
model: FalukantCharacter,
key: 'id'},
onDelete: 'CASCADE'},
relationshipTypeId: { relationshipTypeId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false,

View File

@@ -6,15 +6,20 @@ class FalukantStock extends Model { }
FalukantStock.init({ FalukantStock.init({
branchId: { branchId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false
defaultValue: 0
}, },
stockTypeId: { stockTypeId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false}, allowNull: false},
quantity: { quantity: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false}}, { allowNull: false},
productQuality: {
type: DataTypes.INTEGER,
allowNull: true,
comment: 'Quality of the stored product (0-100)'
}
}, {
sequelize, sequelize,
modelName: 'StockData', modelName: 'StockData',
tableName: 'stock', tableName: 'stock',

View File

@@ -0,0 +1,41 @@
import { Model, DataTypes } from 'sequelize';
import { sequelize } from '../../../utils/sequelize.js';
class Transport extends Model {}
Transport.init(
{
sourceRegionId: {
type: DataTypes.INTEGER,
allowNull: false,
},
targetRegionId: {
type: DataTypes.INTEGER,
allowNull: false,
},
productId: {
type: DataTypes.INTEGER,
allowNull: true, // Nullable für leere Transporte (nur Fahrzeuge bewegen)
},
size: {
type: DataTypes.INTEGER,
allowNull: true, // Nullable für leere Transporte (nur Fahrzeuge bewegen)
},
vehicleId: {
type: DataTypes.INTEGER,
allowNull: false,
},
},
{
sequelize,
modelName: 'Transport',
tableName: 'transport',
schema: 'falukant_data',
timestamps: true,
underscored: true,
}
);
export default Transport;

View File

@@ -8,13 +8,6 @@ FalukantUser.init({
userId: { userId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false,
references: {
model: {
tableName: 'user',
schema: 'community'
},
key: 'id'
},
unique: true}, unique: true},
money: { money: {
type: DataTypes.DECIMAL(10, 2), type: DataTypes.DECIMAL(10, 2),
@@ -38,12 +31,11 @@ FalukantUser.init({
defaultValue: 1}, defaultValue: 1},
mainBranchRegionId: { mainBranchRegionId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: true, allowNull: true
references: { },
model: RegionData, lastNobilityAdvanceAt: {
key: 'id', type: DataTypes.DATE,
schema: 'falukant_data' allowNull: true
}
} }
}, { }, {
sequelize, sequelize,

View File

@@ -26,13 +26,11 @@ UserHouse.init({
}, },
houseTypeId: { houseTypeId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false
defaultValue: 1
}, },
userId: { userId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false
defaultValue: 1
} }
}, { }, {
sequelize, sequelize,

View File

@@ -0,0 +1,45 @@
import { Model, DataTypes } from 'sequelize';
import { sequelize } from '../../../utils/sequelize.js';
class Vehicle extends Model {}
Vehicle.init(
{
vehicleTypeId: {
type: DataTypes.INTEGER,
allowNull: false,
},
falukantUserId: {
type: DataTypes.INTEGER,
allowNull: false,
},
regionId: {
type: DataTypes.INTEGER,
allowNull: false,
},
condition: {
// current condition of the vehicle (0100)
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 100,
},
availableFrom: {
// timestamp when the vehicle becomes available for use
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
},
},
{
sequelize,
modelName: 'Vehicle',
tableName: 'vehicle',
schema: 'falukant_data',
timestamps: true,
underscored: true,
}
);
export default Vehicle;

View File

@@ -0,0 +1,30 @@
import { Model, DataTypes } from 'sequelize';
import { sequelize } from '../../../utils/sequelize.js';
import RegionData from './region.js';
import WeatherType from '../type/weather.js';
class Weather extends Model {}
Weather.init(
{
regionId: {
type: DataTypes.INTEGER,
primaryKey: true,
allowNull: false
},
weatherTypeId: {
type: DataTypes.INTEGER,
allowNull: false
}
},
{
sequelize,
modelName: 'Weather',
tableName: 'weather',
schema: 'falukant_data',
timestamps: false,
underscored: true}
);
export default Weather;

View File

@@ -1,7 +1,12 @@
import { Model, DataTypes } from 'sequelize'; import { Model, DataTypes } from 'sequelize';
import { sequelize } from '../../../utils/sequelize.js'; import { sequelize } from '../../../utils/sequelize.js';
class Notification extends Model { } class Notification extends Model {
// Getter für characterName - wird nicht synchronisiert, da es kein Datenbankfeld ist
get characterName() {
return this.getDataValue('character_name') || null;
}
}
Notification.init({ Notification.init({
userId: { userId: {
@@ -10,6 +15,11 @@ Notification.init({
tr: { tr: {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: false}, allowNull: false},
character_name: {
type: DataTypes.STRING,
allowNull: true,
field: 'character_name'
},
shown: { shown: {
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
allowNull: false, allowNull: false,

View File

@@ -0,0 +1,59 @@
import { Model, DataTypes } from 'sequelize';
import { sequelize } from '../../../utils/sequelize.js';
class ReputationActionLog extends Model {}
ReputationActionLog.init(
{
falukantUserId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'falukant_user_id',
},
actionTypeId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'action_type_id',
},
cost: {
type: DataTypes.INTEGER,
allowNull: false,
},
baseGain: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'base_gain',
},
gain: {
type: DataTypes.INTEGER,
allowNull: false,
},
timesUsedBefore: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'times_used_before',
},
actionTimestamp: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: sequelize.literal('CURRENT_TIMESTAMP'),
field: 'action_timestamp',
},
},
{
sequelize,
modelName: 'ReputationActionLog',
tableName: 'reputation_action',
schema: 'falukant_log',
timestamps: false,
underscored: true,
indexes: [
{ fields: ['falukant_user_id', 'action_type_id'] },
{ fields: ['action_timestamp'] },
],
}
);
export default ReputationActionLog;

View File

@@ -10,13 +10,11 @@ PromotionalGiftCharacterTrait.init(
giftId: { giftId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
field: 'gift_id', field: 'gift_id',
references: { model: PromotionalGift, key: 'id' },
allowNull: false allowNull: false
}, },
traitId: { traitId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
field: 'trait_id', field: 'trait_id',
references: { model: CharacterTrait, key: 'id' },
allowNull: false allowNull: false
}, },
suitability: { suitability: {

View File

@@ -10,19 +10,11 @@ PromotionalGiftMood.init(
giftId: { giftId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
field: 'gift_id', field: 'gift_id',
references: {
model: PromotionalGift,
key: 'id'
},
allowNull: false allowNull: false
}, },
moodId: { moodId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
field: 'mood_id', field: 'mood_id',
references: {
model: Mood,
key: 'id'
},
allowNull: false allowNull: false
}, },
suitability: { suitability: {

View File

@@ -0,0 +1,41 @@
import { Model, DataTypes } from 'sequelize';
import { sequelize } from '../../../utils/sequelize.js';
import ProductType from './product.js';
import WeatherType from './weather.js';
class ProductWeatherEffect extends Model {}
ProductWeatherEffect.init(
{
productId: {
type: DataTypes.INTEGER,
allowNull: false
},
weatherTypeId: {
type: DataTypes.INTEGER,
allowNull: false
},
qualityEffect: {
type: DataTypes.INTEGER,
allowNull: false,
comment: 'Effekt auf Qualität: -2 (sehr negativ), -1 (negativ), 0 (neutral), 1 (positiv), 2 (sehr positiv)'
}
},
{
sequelize,
modelName: 'ProductWeatherEffect',
tableName: 'product_weather_effect',
schema: 'falukant_type',
timestamps: false,
underscored: true,
indexes: [
{
unique: true,
fields: ['product_id', 'weather_type_id']
}
]
}
);
export default ProductWeatherEffect;

View File

@@ -9,11 +9,7 @@ RegionType.init({
allowNull: false}, allowNull: false},
parentId: { parentId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: true, allowNull: true
references: {
model: 'region',
key: 'id',
schema: 'falukant_type'}
} }
}, { }, {
sequelize, sequelize,

View File

@@ -0,0 +1,51 @@
import { Model, DataTypes } from 'sequelize';
import { sequelize } from '../../../utils/sequelize.js';
class ReputationActionType extends Model {}
ReputationActionType.init(
{
tr: {
type: DataTypes.STRING,
allowNull: false,
},
cost: {
type: DataTypes.INTEGER,
allowNull: false,
},
baseGain: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'base_gain',
},
decayFactor: {
type: DataTypes.FLOAT,
allowNull: false,
field: 'decay_factor',
},
minGain: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'min_gain',
},
decayWindowDays: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 7,
field: 'decay_window_days',
},
},
{
sequelize,
modelName: 'ReputationActionType',
tableName: 'reputation_action',
schema: 'falukant_type',
timestamps: false,
underscored: true,
}
);
export default ReputationActionType;

View File

@@ -0,0 +1,52 @@
import { Model, DataTypes } from 'sequelize';
import { sequelize } from '../../../utils/sequelize.js';
class VehicleType extends Model {}
VehicleType.init(
{
tr: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
},
cost: {
// base purchase cost of the vehicle
type: DataTypes.INTEGER,
allowNull: false,
},
buildTimeMinutes: {
// time to construct the vehicle, in minutes
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
capacity: {
// transport capacity (e.g. in units of goods)
type: DataTypes.INTEGER,
allowNull: false,
},
transportMode: {
// e.g. 'land', 'water', 'air'
type: DataTypes.STRING,
allowNull: false,
},
speed: {
// abstract speed value, higher = faster
type: DataTypes.INTEGER,
allowNull: false,
},
},
{
sequelize,
modelName: 'VehicleType',
tableName: 'vehicle',
schema: 'falukant_type',
timestamps: false,
underscored: true,
}
);
export default VehicleType;

View File

@@ -0,0 +1,25 @@
import { Model, DataTypes } from 'sequelize';
import { sequelize } from '../../../utils/sequelize.js';
class WeatherType extends Model {}
WeatherType.init(
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true},
tr: {
type: DataTypes.STRING,
allowNull: false}},
{
sequelize,
modelName: 'WeatherType',
tableName: 'weather',
schema: 'falukant_type',
timestamps: false,
underscored: true}
);
export default WeatherType;

View File

@@ -4,19 +4,11 @@ import { DataTypes } from 'sequelize';
const ForumForumPermission = sequelize.define('forum_forum_permission', { const ForumForumPermission = sequelize.define('forum_forum_permission', {
forumId: { forumId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false
references: {
model: 'forum',
key: 'id'
}
}, },
permissionId: { permissionId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false
references: {
model: 'forum_permission',
key: 'id'
}
} }
}, { }, {
tableName: 'forum_forum_permission', tableName: 'forum_forum_permission',

View File

@@ -79,6 +79,8 @@ import Party from './falukant/data/party.js';
import MusicType from './falukant/type/music.js'; import MusicType from './falukant/type/music.js';
import BanquetteType from './falukant/type/banquette.js'; import BanquetteType from './falukant/type/banquette.js';
import PartyInvitedNobility from './falukant/data/partyInvitedNobility.js'; import PartyInvitedNobility from './falukant/data/partyInvitedNobility.js';
import ReputationActionType from './falukant/type/reputation_action.js';
import ReputationActionLog from './falukant/log/reputation_action.js';
import ChildRelation from './falukant/data/child_relation.js'; import ChildRelation from './falukant/data/child_relation.js';
import LearnRecipient from './falukant/type/learn_recipient.js'; import LearnRecipient from './falukant/type/learn_recipient.js';
import Learning from './falukant/data/learning.js'; import Learning from './falukant/data/learning.js';
@@ -113,6 +115,13 @@ import PoliticalOfficeHistory from './falukant/log/political_office_history.js';
import ElectionHistory from './falukant/log/election_history.js'; import ElectionHistory from './falukant/log/election_history.js';
import UndergroundType from './falukant/type/underground.js'; import UndergroundType from './falukant/type/underground.js';
import Underground from './falukant/data/underground.js'; import Underground from './falukant/data/underground.js';
import VehicleType from './falukant/type/vehicle.js';
import Vehicle from './falukant/data/vehicle.js';
import Transport from './falukant/data/transport.js';
import RegionDistance from './falukant/data/region_distance.js';
import WeatherType from './falukant/type/weather.js';
import Weather from './falukant/data/weather.js';
import ProductWeatherEffect from './falukant/type/product_weather_effect.js';
import Room from './chat/room.js'; import Room from './chat/room.js';
import ChatUser from './chat/user.js'; import ChatUser from './chat/user.js';
@@ -201,12 +210,18 @@ const models = {
BanquetteType, BanquetteType,
Party, Party,
PartyInvitedNobility, PartyInvitedNobility,
ReputationActionType,
ReputationActionLog,
ChildRelation, ChildRelation,
LearnRecipient, LearnRecipient,
Learning, Learning,
Credit, Credit,
DebtorsPrism, DebtorsPrism,
HealthActivity, HealthActivity,
RegionDistance,
VehicleType,
Vehicle,
Transport,
PoliticalOfficeType, PoliticalOfficeType,
PoliticalOfficeRequirement, PoliticalOfficeRequirement,
PoliticalOfficeBenefitType, PoliticalOfficeBenefitType,
@@ -220,6 +235,9 @@ const models = {
ElectionHistory, ElectionHistory,
UndergroundType, UndergroundType,
Underground, Underground,
WeatherType,
Weather,
ProductWeatherEffect,
Room, Room,
ChatUser, ChatUser,
ChatRight, ChatRight,

View File

@@ -9,11 +9,7 @@ const Match3Level = sequelize.define('Match3Level', {
}, },
campaignId: { campaignId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false
references: {
model: 'match3_campaigns',
key: 'id'
}
}, },
name: { name: {
type: DataTypes.STRING(255), type: DataTypes.STRING(255),

View File

@@ -10,19 +10,11 @@ const Match3LevelTileType = sequelize.define('Match3LevelTileType', {
levelId: { levelId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false,
references: {
model: 'match3_levels',
key: 'id'
},
comment: 'Referenz auf den Level' comment: 'Referenz auf den Level'
}, },
tileTypeId: { tileTypeId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false,
references: {
model: 'match3_tile_types',
key: 'id'
},
comment: 'Referenz auf den Tile-Typ' comment: 'Referenz auf den Tile-Typ'
}, },
weight: { weight: {

View File

@@ -9,14 +9,7 @@ const TaxiHighscore = sequelize.define('TaxiHighscore', {
}, },
userId: { userId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: true, // Kann null sein, falls User gelöscht wird allowNull: true // Kann null sein, falls User gelöscht wird
references: {
model: {
tableName: 'user',
schema: 'community'
},
key: 'id'
}
}, },
nickname: { nickname: {
type: DataTypes.STRING(100), type: DataTypes.STRING(100),
@@ -44,13 +37,6 @@ const TaxiHighscore = sequelize.define('TaxiHighscore', {
mapId: { mapId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: true, allowNull: true,
references: {
model: {
tableName: 'taxi_map',
schema: 'taxi'
},
key: 'id'
},
comment: 'ID der gespielten Map' comment: 'ID der gespielten Map'
}, },
mapName: { mapName: {

View File

@@ -378,16 +378,19 @@ export async function createTriggers() {
tp.election_id, tp.election_id,
tp.tp_office_type_id, tp.tp_office_type_id,
tp.tp_election_date, tp.tp_election_date,
( COALESCE(
SELECT json_agg(vr) (
FROM votes vr SELECT json_agg(vr)
WHERE vr.election_id = tp.election_id FROM votes vr
WHERE vr.election_id = tp.election_id
),
'[]'::json -- oder '{}'::json, wenn dir ein Objekt lieber ist
), ),
NOW() AS created_at, NOW() AS created_at,
NOW() AS updated_at NOW() AS updated_at
FROM to_process tp FROM to_process tp
), ),
-- 10) Cleanup: Stimmen, Kandidaten und Wahlen löschen -- 10) Cleanup: Stimmen, Kandidaten und Wahlen löschen
_del_votes AS ( _del_votes AS (
DELETE FROM falukant_data.vote DELETE FROM falukant_data.vote

View File

@@ -13,13 +13,7 @@ const interestTranslation = sequelize.define('interest_translation_type', {
}, },
interestsId: { interestsId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false
references: {
model: Interest,
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE'
}}, { }}, {
tableName: 'interest_translation', tableName: 'interest_translation',
schema: 'type', schema: 'type',

View File

@@ -21,11 +21,7 @@ const UserParamType = sequelize.define('user_param_type', {
}, },
settingsId: { settingsId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false
references: {
model: 'settings',
key: 'id'
}
}, },
orderId: { orderId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,

View File

@@ -7,7 +7,7 @@ const UserRightType = sequelize.define('user_right_type', {
allowNull: false allowNull: false
} }
}, { }, {
tableName: 'user_right_type', tableName: 'user_right',
schema: 'type', schema: 'type',
underscored: true underscored: true
}); });

1726
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",
"dev": "NODE_ENV=development node server.js", "dev": "NODE_ENV=development node server.js",
"start-daemon": "node daemonServer.js",
"sync-db": "node sync-database.js", "sync-db": "node sync-database.js",
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1"
}, },
@@ -15,7 +16,7 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"amqplib": "^0.10.4", "amqplib": "^0.10.4",
"bcrypt": "^5.1.1", "bcryptjs": "^2.4.3",
"connect-redis": "^7.1.1", "connect-redis": "^7.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
@@ -26,9 +27,9 @@
"i18n": "^0.15.1", "i18n": "^0.15.1",
"joi": "^17.13.3", "joi": "^17.13.3",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"multer": "^1.4.5-lts.1", "multer": "^2.0.0",
"mysql2": "^3.10.3", "mysql2": "^3.10.3",
"nodemailer": "^6.9.14", "nodemailer": "^7.0.11",
"pg": "^8.12.0", "pg": "^8.12.0",
"pg-hstore": "^2.3.4", "pg-hstore": "^2.3.4",
"redis": "^4.7.0", "redis": "^4.7.0",

View File

@@ -18,6 +18,7 @@ router.delete('/chat/rooms/:id', authenticate, adminController.deleteRoom);
// --- Users Admin --- // --- Users Admin ---
router.get('/users/search', authenticate, adminController.searchUsers); router.get('/users/search', authenticate, adminController.searchUsers);
router.get('/users/statistics', authenticate, adminController.getUserStatistics); router.get('/users/statistics', authenticate, adminController.getUserStatistics);
router.get('/users/batch', authenticate, adminController.getUsers);
router.get('/users/:id', authenticate, adminController.getUser); router.get('/users/:id', authenticate, adminController.getUser);
router.put('/users/:id', authenticate, adminController.updateUser); router.put('/users/:id', authenticate, adminController.updateUser);
@@ -40,6 +41,14 @@ router.get('/falukant/branches/:falukantUserId', authenticate, adminController.g
router.put('/falukant/stock/:stockId', authenticate, adminController.updateFalukantStock); router.put('/falukant/stock/:stockId', authenticate, adminController.updateFalukantStock);
router.post('/falukant/stock', authenticate, adminController.addFalukantStock); router.post('/falukant/stock', authenticate, adminController.addFalukantStock);
router.get('/falukant/stock-types', authenticate, adminController.getFalukantStockTypes); router.get('/falukant/stock-types', authenticate, adminController.getFalukantStockTypes);
router.get('/falukant/regions', authenticate, adminController.getFalukantRegions);
router.put('/falukant/regions/:id/map', authenticate, adminController.updateFalukantRegionMap);
router.get('/falukant/region-distances', authenticate, adminController.getRegionDistances);
router.post('/falukant/region-distances', authenticate, adminController.upsertRegionDistance);
router.delete('/falukant/region-distances/:id', authenticate, adminController.deleteRegionDistance);
router.post('/falukant/npcs/create', authenticate, adminController.createNPCs);
router.get('/falukant/npcs/status/:jobId', authenticate, adminController.getNPCsCreationStatus);
router.get('/falukant/titles', authenticate, adminController.getTitlesOfNobility);
// --- Minigames Admin --- // --- Minigames Admin ---
router.get('/minigames/match3/campaigns', authenticate, adminController.getMatch3Campaigns); router.get('/minigames/match3/campaigns', authenticate, adminController.getMatch3Campaigns);

View File

@@ -15,6 +15,7 @@ router.get('/branches/types', falukantController.getBranchTypes);
router.get('/branches/:branch', falukantController.getBranch); router.get('/branches/:branch', falukantController.getBranch);
router.get('/branches', falukantController.getBranches); router.get('/branches', falukantController.getBranches);
router.post('/branches', falukantController.createBranch); router.post('/branches', falukantController.createBranch);
router.post('/branches/upgrade', falukantController.upgradeBranch);
router.get('/productions', falukantController.getAllProductions); router.get('/productions', falukantController.getAllProductions);
router.post('/production', falukantController.createProduction); router.post('/production', falukantController.createProduction);
router.get('/production/:branchId', falukantController.getProduction); router.get('/production/:branchId', falukantController.getProduction);
@@ -37,6 +38,9 @@ router.get('/director/:branchId', falukantController.getDirectorForBranch);
router.get('/directors', falukantController.getAllDirectors); router.get('/directors', falukantController.getAllDirectors);
router.post('/directors', falukantController.updateDirector); router.post('/directors', falukantController.updateDirector);
router.post('/family/acceptmarriageproposal', falukantController.acceptMarriageProposal); router.post('/family/acceptmarriageproposal', falukantController.acceptMarriageProposal);
router.post('/family/set-heir', falukantController.setHeir);
router.get('/heirs/potential', falukantController.getPotentialHeirs);
router.post('/heirs/select', falukantController.selectHeir);
router.get('/family/gifts', falukantController.getGifts); router.get('/family/gifts', falukantController.getGifts);
router.get('/family/children', falukantController.getChildren); router.get('/family/children', falukantController.getChildren);
router.post('/family/gift', falukantController.sendGift); router.post('/family/gift', falukantController.sendGift);
@@ -51,6 +55,8 @@ router.post('/houses', falukantController.buyUserHouse);
router.get('/party/types', falukantController.getPartyTypes); router.get('/party/types', falukantController.getPartyTypes);
router.post('/party', falukantController.createParty); router.post('/party', falukantController.createParty);
router.get('/party', falukantController.getParties); router.get('/party', falukantController.getParties);
router.get('/reputation/actions', falukantController.getReputationActions);
router.post('/reputation/actions', falukantController.executeReputationAction);
router.get('/family/notbaptised', falukantController.getNotBaptisedChildren); router.get('/family/notbaptised', falukantController.getNotBaptisedChildren);
router.post('/church/baptise', falukantController.baptise); router.post('/church/baptise', falukantController.baptise);
router.get('/education', falukantController.getEducation); router.get('/education', falukantController.getEducation);
@@ -69,6 +75,17 @@ router.post('/politics/elections', falukantController.vote);
router.get('/politics/open', falukantController.getOpenPolitics); router.get('/politics/open', falukantController.getOpenPolitics);
router.post('/politics/open', falukantController.applyForElections); router.post('/politics/open', falukantController.applyForElections);
router.get('/cities', falukantController.getRegions); router.get('/cities', falukantController.getRegions);
router.get('/products/price-in-region', falukantController.getProductPriceInRegion);
router.get('/products/prices-in-cities', falukantController.getProductPricesInCities);
router.get('/branches/:branchId/taxes', falukantController.getBranchTaxes);
router.get('/vehicles/types', falukantController.getVehicleTypes);
router.post('/vehicles', falukantController.buyVehicles);
router.get('/vehicles', falukantController.getVehicles);
router.post('/vehicles/:vehicleId/repair', falukantController.repairVehicle);
router.post('/vehicles/repair-all', falukantController.repairAllVehicles);
router.post('/transports', falukantController.createTransport);
router.get('/transports/route', falukantController.getTransportRoute);
router.get('/transports/branch/:branchId', falukantController.getBranchTransports);
router.get('/underground/types', falukantController.getUndergroundTypes); router.get('/underground/types', falukantController.getUndergroundTypes);
router.get('/notifications', falukantController.getNotifications); router.get('/notifications', falukantController.getNotifications);
router.get('/notifications/all', falukantController.getAllNotifications); router.get('/notifications/all', falukantController.getAllNotifications);

View File

@@ -0,0 +1,9 @@
import express from 'express';
import termineController from '../controllers/termineController.js';
const router = express.Router();
router.get('/', termineController.getTermine);
export default router;

View File

@@ -0,0 +1,27 @@
import express from 'express';
import { authenticate } from '../middleware/authMiddleware.js';
import VocabController from '../controllers/vocabController.js';
const router = express.Router();
const vocabController = new VocabController();
router.use(authenticate);
router.get('/languages', vocabController.listLanguages);
router.post('/languages', vocabController.createLanguage);
router.post('/subscribe', vocabController.subscribe);
router.get('/languages/:languageId', vocabController.getLanguage);
// Kapitel
router.get('/languages/:languageId/chapters', vocabController.listChapters);
router.post('/languages/:languageId/chapters', vocabController.createChapter);
router.get('/languages/:languageId/vocabs', vocabController.listLanguageVocabs);
router.get('/languages/:languageId/search', vocabController.searchVocabs);
router.get('/chapters/:chapterId', vocabController.getChapter);
router.get('/chapters/:chapterId/vocabs', vocabController.listChapterVocabs);
router.post('/chapters/:chapterId/vocabs', vocabController.addVocabToChapter);
export default router;

View File

@@ -10,7 +10,7 @@ import UserParamType from "../models/type/user_param.js";
import ContactMessage from "../models/service/contactmessage.js"; import ContactMessage from "../models/service/contactmessage.js";
import ContactService from "./ContactService.js"; import ContactService from "./ContactService.js";
import { sendAnswerEmail } from './emailService.js'; import { sendAnswerEmail } from './emailService.js';
import { Op } from 'sequelize'; import { Op, Sequelize } from 'sequelize';
import FalukantUser from "../models/falukant/data/user.js"; import FalukantUser from "../models/falukant/data/user.js";
import FalukantCharacter from "../models/falukant/data/character.js"; import FalukantCharacter from "../models/falukant/data/character.js";
import FalukantPredefineFirstname from "../models/falukant/predefine/firstname.js"; import FalukantPredefineFirstname from "../models/falukant/predefine/firstname.js";
@@ -19,9 +19,15 @@ import Branch from "../models/falukant/data/branch.js";
import FalukantStock from "../models/falukant/data/stock.js"; import FalukantStock from "../models/falukant/data/stock.js";
import FalukantStockType from "../models/falukant/type/stock.js"; import FalukantStockType from "../models/falukant/type/stock.js";
import RegionData from "../models/falukant/data/region.js"; import RegionData from "../models/falukant/data/region.js";
import RegionType from "../models/falukant/type/region.js";
import BranchType from "../models/falukant/type/branch.js"; import BranchType from "../models/falukant/type/branch.js";
import RegionDistance from "../models/falukant/data/region_distance.js";
import Room from '../models/chat/room.js'; import Room from '../models/chat/room.js';
import UserParam from '../models/community/user_param.js'; import UserParam from '../models/community/user_param.js';
import TitleOfNobility from "../models/falukant/type/title_of_nobility.js";
import { sequelize } from '../utils/sequelize.js';
import npcCreationJobService from './npcCreationJobService.js';
import { v4 as uuidv4 } from 'uuid';
class AdminService { class AdminService {
async hasUserAccess(userId, section) { async hasUserAccess(userId, section) {
@@ -298,6 +304,115 @@ class AdminService {
} }
} }
async getFalukantRegions(userId) {
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
throw new Error('noaccess');
}
const regions = await RegionData.findAll({
attributes: ['id', 'name', 'map'],
include: [
{
model: RegionType,
as: 'regionType',
where: { labelTr: 'city' },
attributes: ['labelTr'],
},
],
order: [['name', 'ASC']],
});
return regions;
}
async getTitlesOfNobility(userId) {
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
throw new Error('noaccess');
}
const titles = await TitleOfNobility.findAll({
order: [['id', 'ASC']],
attributes: ['id', 'labelTr', 'level']
});
return titles;
}
async updateFalukantRegionMap(userId, regionId, map) {
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
throw new Error('noaccess');
}
const region = await RegionData.findByPk(regionId);
if (!region) {
throw new Error('regionNotFound');
}
region.map = map || {};
await region.save();
return region;
}
async getRegionDistances(userId) {
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
throw new Error('noaccess');
}
const distances = await RegionDistance.findAll();
return distances;
}
async upsertRegionDistance(userId, { sourceRegionId, targetRegionId, transportMode, distance }) {
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
throw new Error('noaccess');
}
if (!sourceRegionId || !targetRegionId || !transportMode) {
throw new Error('missingParameters');
}
const src = await RegionData.findByPk(sourceRegionId);
const tgt = await RegionData.findByPk(targetRegionId);
if (!src || !tgt) {
throw new Error('regionNotFound');
}
const mode = String(transportMode);
const dist = Number(distance);
if (!Number.isFinite(dist) || dist <= 0) {
throw new Error('invalidDistance');
}
const [record] = await RegionDistance.findOrCreate({
where: {
sourceRegionId: src.id,
targetRegionId: tgt.id,
transportMode: mode,
},
defaults: {
distance: dist,
},
});
if (record.distance !== dist) {
record.distance = dist;
await record.save();
}
return record;
}
async deleteRegionDistance(userId, id) {
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
throw new Error('noaccess');
}
const record = await RegionDistance.findByPk(id);
if (!record) {
throw new Error('notfound');
}
await record.destroy();
return { success: true };
}
async updateFalukantStock(userId, stockId, quantity) { async updateFalukantStock(userId, stockId, quantity) {
if (!(await this.hasUserAccess(userId, 'falukantusers'))) { if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
throw new Error('noaccess'); throw new Error('noaccess');
@@ -441,6 +556,30 @@ class AdminService {
return { id: user.hashedId, username: user.username, active: user.active, registrationDate: user.registrationDate }; return { id: user.hashedId, username: user.username, active: user.active, registrationDate: user.registrationDate };
} }
async getUsersByHashedIds(requestingHashedUserId, targetHashedIds) {
if (!(await this.hasUserAccess(requestingHashedUserId, 'useradministration'))) {
throw new Error('noaccess');
}
if (!Array.isArray(targetHashedIds) || targetHashedIds.length === 0) {
return [];
}
const users = await User.findAll({
where: { hashedId: { [Op.in]: targetHashedIds } },
attributes: ['id', 'hashedId', 'username', 'active', 'registrationDate']
});
// Erstelle ein Map für schnellen Zugriff
const userMap = {};
users.forEach(user => {
userMap[user.hashedId] = {
id: user.hashedId,
username: user.username,
active: user.active,
registrationDate: user.registrationDate
};
});
return userMap;
}
async updateUser(requestingHashedUserId, targetHashedId, data) { async updateUser(requestingHashedUserId, targetHashedId, data) {
if (!(await this.hasUserAccess(requestingHashedUserId, 'useradministration'))) { if (!(await this.hasUserAccess(requestingHashedUserId, 'useradministration'))) {
throw new Error('noaccess'); throw new Error('noaccess');
@@ -961,6 +1100,216 @@ class AdminService {
ageGroups ageGroups
}; };
} }
async createNPCs(userId, options) {
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
throw new Error('noaccess');
}
const {
regionIds, // Array von Region-IDs oder null für alle Städte
minAge, // Mindestalter in Jahren
maxAge, // Maximalalter in Jahren
minTitleId, // Minimale Title-ID
maxTitleId, // Maximale Title-ID
count // Anzahl der zu erstellenden NPCs
} = options;
// Berechne zuerst die Gesamtanzahl, um den Job richtig zu initialisieren
// WICHTIG: Nur Städte (city) verwenden, keine anderen Region-Typen
let targetRegions = [];
if (regionIds && regionIds.length > 0) {
targetRegions = await RegionData.findAll({
where: {
id: { [Op.in]: regionIds }
},
include: [{
model: RegionType,
as: 'regionType',
where: { labelTr: 'city' },
required: true // INNER JOIN - nur Regionen mit city-Type
}]
});
} else {
targetRegions = await RegionData.findAll({
include: [{
model: RegionType,
as: 'regionType',
where: { labelTr: 'city' },
required: true // INNER JOIN - nur Regionen mit city-Type
}]
});
}
// Zusätzliche Sicherheit: Filtere explizit nach city-Type
targetRegions = targetRegions.filter(region => {
return region.regionType && region.regionType.labelTr === 'city';
});
console.log(`[createNPCs] Found ${targetRegions.length} cities (filtered)`);
if (targetRegions.length > 0) {
console.log(`[createNPCs] City names: ${targetRegions.map(r => r.name).join(', ')}`);
}
if (targetRegions.length === 0) {
throw new Error('No cities found');
}
const titles = await TitleOfNobility.findAll({
where: {
id: {
[Op.between]: [minTitleId, maxTitleId]
}
},
order: [['id', 'ASC']]
});
if (titles.length === 0) {
throw new Error('No titles found in specified range');
}
const totalNPCs = targetRegions.length * titles.length * count;
// Erstelle Job-ID
const jobId = uuidv4();
npcCreationJobService.createJob(userId, jobId);
npcCreationJobService.updateProgress(jobId, 0, totalNPCs);
npcCreationJobService.setStatus(jobId, 'running');
// Starte asynchronen Prozess
this._createNPCsAsync(jobId, userId, {
regionIds,
minAge,
maxAge,
minTitleId,
maxTitleId,
count,
targetRegions,
titles
}).catch(error => {
console.error('Error in _createNPCsAsync:', error);
const errorMessage = error?.message || error?.toString() || 'Unknown error occurred';
npcCreationJobService.setError(jobId, errorMessage);
});
return { jobId };
}
async _createNPCsAsync(jobId, userId, options) {
try {
const {
regionIds,
minAge,
maxAge,
minTitleId,
maxTitleId,
count,
targetRegions,
titles
} = options;
const genders = ['male', 'female'];
const createdNPCs = [];
const totalNPCs = targetRegions.length * titles.length * count;
let currentNPC = 0;
console.log(`[NPC Creation Job ${jobId}] Starting creation of ${totalNPCs} NPCs`);
// Erstelle NPCs in einer Transaktion
// Für jede Stadt-Titel-Kombination wird die angegebene Anzahl erstellt
await sequelize.transaction(async (t) => {
for (const region of targetRegions) {
for (const title of titles) {
// Erstelle 'count' NPCs für diese Stadt-Titel-Kombination
for (let i = 0; i < count; i++) {
// Zufälliges Geschlecht
const gender = genders[Math.floor(Math.random() * genders.length)];
// Zufälliger Vorname
const firstName = await FalukantPredefineFirstname.findAll({
where: { gender },
order: sequelize.fn('RANDOM'),
limit: 1,
transaction: t
});
if (firstName.length === 0) {
throw new Error(`No first names found for gender: ${gender}`);
}
const fnObj = firstName[0];
// Zufälliger Nachname
const lastName = await FalukantPredefineLastname.findAll({
order: sequelize.fn('RANDOM'),
limit: 1,
transaction: t
});
if (lastName.length === 0) {
throw new Error('No last names found');
}
const lnObj = lastName[0];
// Zufälliges Alter (in Jahren, wird in Tage umgerechnet)
const randomAge = Math.floor(Math.random() * (maxAge - minAge + 1)) + minAge;
const birthdate = new Date();
birthdate.setDate(birthdate.getDate() - randomAge); // 5 Tage = 5 Jahre alt
// Erstelle den NPC-Charakter (ohne userId = NPC)
const npc = await FalukantCharacter.create({
userId: null, // Wichtig: null = NPC
regionId: region.id,
firstName: fnObj.id,
lastName: lnObj.id,
gender: gender,
birthdate: birthdate,
titleOfNobility: title.id,
health: 100,
moodId: 1
}, { transaction: t });
createdNPCs.push({
id: npc.id,
firstName: fnObj.name,
lastName: lnObj.name,
gender: gender,
age: randomAge,
region: region.name,
title: title.labelTr
});
// Update Progress
currentNPC++;
npcCreationJobService.updateProgress(jobId, currentNPC, totalNPCs);
}
}
}
});
console.log(`[NPC Creation Job ${jobId}] Completed: ${createdNPCs.length} NPCs created`);
// Job abschließen
npcCreationJobService.setResult(jobId, {
success: true,
count: createdNPCs.length,
countPerCombination: count,
totalCombinations: targetRegions.length * titles.length,
npcs: createdNPCs
});
} catch (error) {
console.error(`[NPC Creation Job ${jobId}] Error:`, error);
throw error; // Re-throw für den catch-Block in createNPCs
}
}
getNPCsCreationStatus(userId, jobId) {
const job = npcCreationJobService.getJob(jobId);
if (!job) {
throw new Error('Job not found');
}
if (job.userId !== userId) {
throw new Error('Access denied');
}
return job;
}
} }
export default new AdminService(); export default new AdminService();

View File

@@ -1,4 +1,4 @@
import bcrypt from 'bcrypt'; import bcrypt from 'bcryptjs';
import crypto from 'crypto'; import crypto from 'crypto';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import User from '../models/community/user.js'; import User from '../models/community/user.js';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,86 @@
// In-Memory Job-Status-Service für NPC-Erstellung
// Für Produktion sollte man Redis oder eine Datenbank verwenden
const jobs = new Map();
class NPCCreationJobService {
createJob(userId, jobId) {
jobs.set(jobId, {
userId,
status: 'pending',
progress: 0,
total: 0,
current: 0,
startTime: Date.now(),
estimatedTimeRemaining: null,
error: null,
result: null
});
return jobId;
}
getJob(jobId) {
return jobs.get(jobId);
}
updateProgress(jobId, current, total) {
const job = jobs.get(jobId);
if (!job) return;
job.current = current;
job.total = total;
job.progress = total > 0 ? Math.round((current / total) * 100) : 0;
// Berechne verbleibende Zeit basierend auf bisheriger Geschwindigkeit
if (current > 0 && job.progress < 100) {
const elapsed = Date.now() - job.startTime;
const avgTimePerItem = elapsed / current;
const remaining = total - current;
job.estimatedTimeRemaining = Math.round(remaining * avgTimePerItem);
}
}
setStatus(jobId, status) {
const job = jobs.get(jobId);
if (!job) return;
job.status = status;
}
setError(jobId, error) {
const job = jobs.get(jobId);
if (!job) return;
job.status = 'error';
job.error = error;
}
setResult(jobId, result) {
const job = jobs.get(jobId);
if (!job) return;
job.status = 'completed';
job.result = result;
job.progress = 100;
job.estimatedTimeRemaining = 0;
}
deleteJob(jobId) {
jobs.delete(jobId);
}
// Cleanup alte Jobs (älter als 1 Stunde)
cleanupOldJobs() {
const oneHourAgo = Date.now() - (60 * 60 * 1000);
for (const [jobId, job] of jobs.entries()) {
if (job.startTime < oneHourAgo) {
jobs.delete(jobId);
}
}
}
}
// Cleanup alle 10 Minuten
setInterval(() => {
const service = new NPCCreationJobService();
service.cleanupOldJobs();
}, 10 * 60 * 1000);
export default new NPCCreationJobService();

View File

@@ -328,7 +328,7 @@ class SettingsService extends BaseService{
} }
// Verify old password // Verify old password
const bcrypt = await import('bcrypt'); const bcrypt = await import('bcryptjs');
const match = await bcrypt.compare(settings.oldpassword, user.password); const match = await bcrypt.compare(settings.oldpassword, user.password);
if (!match) { if (!match) {
throw new Error('Old password is incorrect'); throw new Error('Old password is incorrect');

View File

@@ -0,0 +1,532 @@
import crypto from 'crypto';
import User from '../models/community/user.js';
import { sequelize } from '../utils/sequelize.js';
import { notifyUser } from '../utils/socket.js';
export default class VocabService {
async _getUserByHashedId(hashedUserId) {
const user = await User.findOne({ where: { hashedId: hashedUserId } });
if (!user) {
const err = new Error('User not found');
err.status = 404;
throw err;
}
return user;
}
_normalizeLexeme(text) {
return String(text || '')
.trim()
.toLowerCase()
.replace(/\s+/g, ' ');
}
async _getLanguageAccess(userId, languageId) {
const id = Number.parseInt(languageId, 10);
if (!Number.isFinite(id)) {
const err = new Error('Invalid language id');
err.status = 400;
throw err;
}
const [row] = await sequelize.query(
`
SELECT
l.id,
(l.owner_user_id = :userId) AS "isOwner"
FROM community.vocab_language l
WHERE l.id = :languageId
AND (
l.owner_user_id = :userId
OR EXISTS (
SELECT 1
FROM community.vocab_language_subscription s
WHERE s.user_id = :userId AND s.language_id = l.id
)
)
LIMIT 1
`,
{
replacements: { userId, languageId: id },
type: sequelize.QueryTypes.SELECT,
}
);
if (!row) {
const err = new Error('Language not found or no access');
err.status = 404;
throw err;
}
return row;
}
async _getChapterAccess(userId, chapterId) {
const id = Number.parseInt(chapterId, 10);
if (!Number.isFinite(id)) {
const err = new Error('Invalid chapter id');
err.status = 400;
throw err;
}
const [row] = await sequelize.query(
`
SELECT
c.id,
c.language_id AS "languageId",
c.title,
(l.owner_user_id = :userId) AS "isOwner"
FROM community.vocab_chapter c
JOIN community.vocab_language l ON l.id = c.language_id
WHERE c.id = :chapterId
AND (
l.owner_user_id = :userId
OR EXISTS (
SELECT 1
FROM community.vocab_language_subscription s
WHERE s.user_id = :userId AND s.language_id = l.id
)
)
LIMIT 1
`,
{
replacements: { userId, chapterId: id },
type: sequelize.QueryTypes.SELECT,
}
);
if (!row) {
const err = new Error('Chapter not found or no access');
err.status = 404;
throw err;
}
return row;
}
async listLanguages(hashedUserId) {
const user = await this._getUserByHashedId(hashedUserId);
const rows = await sequelize.query(
`
SELECT
l.id,
l.name,
l.share_code AS "shareCode",
TRUE AS "isOwner"
FROM community.vocab_language l
WHERE l.owner_user_id = :userId
UNION ALL
SELECT
l.id,
l.name,
NULL::text AS "shareCode",
FALSE AS "isOwner"
FROM community.vocab_language_subscription s
JOIN community.vocab_language l ON l.id = s.language_id
WHERE s.user_id = :userId
ORDER BY name ASC
`,
{
replacements: { userId: user.id },
type: sequelize.QueryTypes.SELECT,
}
);
return { languages: rows };
}
async listLanguagesForMenu(userId) {
// userId ist die numerische community.user.id
const rows = await sequelize.query(
`
SELECT l.id, l.name
FROM community.vocab_language l
WHERE l.owner_user_id = :userId
UNION
SELECT l.id, l.name
FROM community.vocab_language_subscription s
JOIN community.vocab_language l ON l.id = s.language_id
WHERE s.user_id = :userId
ORDER BY name ASC
`,
{
replacements: { userId },
type: sequelize.QueryTypes.SELECT,
}
);
return rows;
}
async createLanguage(hashedUserId, { name }) {
const user = await this._getUserByHashedId(hashedUserId);
const cleanName = typeof name === 'string' ? name.trim() : '';
if (!cleanName || cleanName.length < 2 || cleanName.length > 60) {
const err = new Error('Invalid language name');
err.status = 400;
throw err;
}
// 16 hex chars => ausreichend kurz, gut teilbar
const shareCode = crypto.randomBytes(8).toString('hex');
const [created] = await sequelize.query(
`
INSERT INTO community.vocab_language (owner_user_id, name, share_code)
VALUES (:ownerUserId, :name, :shareCode)
RETURNING id, name, share_code AS "shareCode"
`,
{
replacements: { ownerUserId: user.id, name: cleanName, shareCode },
type: sequelize.QueryTypes.SELECT,
}
);
// Menü dynamisch nachladen (bei allen offenen Tabs/Clients)
try {
notifyUser(user.hashedId, 'reloadmenu', {});
} catch (_) {}
return created;
}
async subscribeByShareCode(hashedUserId, { shareCode }) {
const user = await this._getUserByHashedId(hashedUserId);
const code = typeof shareCode === 'string' ? shareCode.trim() : '';
if (!code || code.length < 6 || code.length > 128) {
const err = new Error('Invalid share code');
err.status = 400;
throw err;
}
const [lang] = await sequelize.query(
`
SELECT id, owner_user_id AS "ownerUserId", name
FROM community.vocab_language
WHERE share_code = :shareCode
LIMIT 1
`,
{
replacements: { shareCode: code },
type: sequelize.QueryTypes.SELECT,
}
);
if (!lang) {
const err = new Error('Language not found');
err.status = 404;
throw err;
}
// Owner braucht kein Abo
if (lang.ownerUserId === user.id) {
return { subscribed: false, message: 'Already owner', languageId: lang.id };
}
await sequelize.query(
`
INSERT INTO community.vocab_language_subscription (user_id, language_id)
VALUES (:userId, :languageId)
ON CONFLICT (user_id, language_id) DO NOTHING
`,
{
replacements: { userId: user.id, languageId: lang.id },
type: sequelize.QueryTypes.INSERT,
}
);
try {
notifyUser(user.hashedId, 'reloadmenu', {});
} catch (_) {}
return { subscribed: true, languageId: lang.id, name: lang.name };
}
async getLanguage(hashedUserId, languageId) {
const user = await this._getUserByHashedId(hashedUserId);
const id = Number.parseInt(languageId, 10);
if (!Number.isFinite(id)) {
const err = new Error('Invalid language id');
err.status = 400;
throw err;
}
const [row] = await sequelize.query(
`
SELECT
l.id,
l.name,
CASE WHEN l.owner_user_id = :userId THEN l.share_code ELSE NULL END AS "shareCode",
(l.owner_user_id = :userId) AS "isOwner"
FROM community.vocab_language l
WHERE l.id = :languageId
AND (
l.owner_user_id = :userId
OR EXISTS (
SELECT 1
FROM community.vocab_language_subscription s
WHERE s.user_id = :userId AND s.language_id = l.id
)
)
LIMIT 1
`,
{
replacements: { userId: user.id, languageId: id },
type: sequelize.QueryTypes.SELECT,
}
);
if (!row) {
const err = new Error('Language not found or no access');
err.status = 404;
throw err;
}
return row;
}
async listChapters(hashedUserId, languageId) {
const user = await this._getUserByHashedId(hashedUserId);
const access = await this._getLanguageAccess(user.id, languageId);
const rows = await sequelize.query(
`
SELECT
c.id,
c.title,
c.created_at AS "createdAt",
(
SELECT COUNT(*)
FROM community.vocab_chapter_lexeme cl
WHERE cl.chapter_id = c.id
)::int AS "vocabCount"
FROM community.vocab_chapter c
WHERE c.language_id = :languageId
ORDER BY c.title ASC
`,
{
replacements: { languageId: access.id },
type: sequelize.QueryTypes.SELECT,
}
);
return { chapters: rows, isOwner: access.isOwner };
}
async createChapter(hashedUserId, languageId, { title }) {
const user = await this._getUserByHashedId(hashedUserId);
const access = await this._getLanguageAccess(user.id, languageId);
if (!access.isOwner) {
const err = new Error('Only owner can create chapters');
err.status = 403;
throw err;
}
const cleanTitle = typeof title === 'string' ? title.trim() : '';
if (!cleanTitle || cleanTitle.length < 2 || cleanTitle.length > 80) {
const err = new Error('Invalid chapter title');
err.status = 400;
throw err;
}
const [created] = await sequelize.query(
`
INSERT INTO community.vocab_chapter (language_id, title, created_by_user_id)
VALUES (:languageId, :title, :userId)
RETURNING id, title, created_at AS "createdAt"
`,
{
replacements: { languageId: access.id, title: cleanTitle, userId: user.id },
type: sequelize.QueryTypes.SELECT,
}
);
return created;
}
async getChapter(hashedUserId, chapterId) {
const user = await this._getUserByHashedId(hashedUserId);
const ch = await this._getChapterAccess(user.id, chapterId);
return { id: ch.id, languageId: ch.languageId, title: ch.title, isOwner: ch.isOwner };
}
async listChapterVocabs(hashedUserId, chapterId) {
const user = await this._getUserByHashedId(hashedUserId);
const ch = await this._getChapterAccess(user.id, chapterId);
const rows = await sequelize.query(
`
SELECT
cl.id,
l1.text AS "learning",
l2.text AS "reference",
cl.created_at AS "createdAt"
FROM community.vocab_chapter_lexeme cl
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
WHERE cl.chapter_id = :chapterId
ORDER BY l1.text ASC, l2.text ASC
`,
{
replacements: { chapterId: ch.id },
type: sequelize.QueryTypes.SELECT,
}
);
return { chapter: { id: ch.id, title: ch.title, languageId: ch.languageId, isOwner: ch.isOwner }, vocabs: rows };
}
async listLanguageVocabs(hashedUserId, languageId) {
const user = await this._getUserByHashedId(hashedUserId);
const access = await this._getLanguageAccess(user.id, languageId);
const rows = await sequelize.query(
`
SELECT
cl.id,
c.id AS "chapterId",
c.title AS "chapterTitle",
l1.text AS "learning",
l2.text AS "reference",
cl.created_at AS "createdAt"
FROM community.vocab_chapter_lexeme cl
JOIN community.vocab_chapter c ON c.id = cl.chapter_id
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
WHERE c.language_id = :languageId
ORDER BY c.title ASC, l1.text ASC, l2.text ASC
`,
{
replacements: { languageId: access.id },
type: sequelize.QueryTypes.SELECT,
}
);
return { languageId: access.id, isOwner: access.isOwner, vocabs: rows };
}
async searchVocabs(hashedUserId, languageId, { q = '', learning = '', motherTongue = '' } = {}) {
const user = await this._getUserByHashedId(hashedUserId);
const access = await this._getLanguageAccess(user.id, languageId);
const query = typeof q === 'string' ? q.trim() : '';
// Abwärtskompatibel: falls alte Parameter genutzt werden, zusammenfassen
const learningTerm = typeof learning === 'string' ? learning.trim() : '';
const motherTerm = typeof motherTongue === 'string' ? motherTongue.trim() : '';
const effective = query || learningTerm || motherTerm;
if (!effective) {
const err = new Error('Missing search term');
err.status = 400;
throw err;
}
const like = `%${effective}%`;
const rows = await sequelize.query(
`
SELECT
cl.id,
c.id AS "chapterId",
c.title AS "chapterTitle",
l1.text AS "learning",
l2.text AS "motherTongue"
FROM community.vocab_chapter_lexeme cl
JOIN community.vocab_chapter c ON c.id = cl.chapter_id
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
WHERE c.language_id = :languageId
AND (l1.text ILIKE :like OR l2.text ILIKE :like)
ORDER BY l2.text ASC, l1.text ASC, c.title ASC
LIMIT 200
`,
{
replacements: {
languageId: access.id,
like,
},
type: sequelize.QueryTypes.SELECT,
}
);
return { languageId: access.id, results: rows };
}
async addVocabToChapter(hashedUserId, chapterId, { learning, reference }) {
const user = await this._getUserByHashedId(hashedUserId);
const ch = await this._getChapterAccess(user.id, chapterId);
if (!ch.isOwner) {
const err = new Error('Only owner can add vocab');
err.status = 403;
throw err;
}
const learningText = typeof learning === 'string' ? learning.trim() : '';
const referenceText = typeof reference === 'string' ? reference.trim() : '';
if (!learningText || !referenceText) {
const err = new Error('Invalid vocab');
err.status = 400;
throw err;
}
const learningNorm = this._normalizeLexeme(learningText);
const referenceNorm = this._normalizeLexeme(referenceText);
// Transaktion: Lexeme upserten + Zuordnung setzen
return await sequelize.transaction(async (t) => {
const [learningLex] = await sequelize.query(
`
INSERT INTO community.vocab_lexeme (language_id, text, normalized, created_by_user_id)
VALUES (:languageId, :text, :normalized, :userId)
ON CONFLICT (language_id, normalized) DO UPDATE SET text = EXCLUDED.text
RETURNING id
`,
{
replacements: { languageId: ch.languageId, text: learningText, normalized: learningNorm, userId: user.id },
type: sequelize.QueryTypes.SELECT,
transaction: t,
}
);
const [referenceLex] = await sequelize.query(
`
INSERT INTO community.vocab_lexeme (language_id, text, normalized, created_by_user_id)
VALUES (:languageId, :text, :normalized, :userId)
ON CONFLICT (language_id, normalized) DO UPDATE SET text = EXCLUDED.text
RETURNING id
`,
{
replacements: { languageId: ch.languageId, text: referenceText, normalized: referenceNorm, userId: user.id },
type: sequelize.QueryTypes.SELECT,
transaction: t,
}
);
const [mapping] = await sequelize.query(
`
INSERT INTO community.vocab_chapter_lexeme (chapter_id, learning_lexeme_id, reference_lexeme_id, created_by_user_id)
VALUES (:chapterId, :learningId, :referenceId, :userId)
ON CONFLICT (chapter_id, learning_lexeme_id, reference_lexeme_id) DO NOTHING
RETURNING id
`,
{
replacements: {
chapterId: ch.id,
learningId: learningLex.id,
referenceId: referenceLex.id,
userId: user.id,
},
type: sequelize.QueryTypes.SELECT,
transaction: t,
}
);
return { created: Boolean(mapping?.id) };
});
}
}

View File

@@ -0,0 +1,88 @@
-- Migration script: add_character_name_to_notification.sql
-- Fügt character_name und character_id zur falukant_log.notification Tabelle hinzu,
-- legt Index an, erzeugt die Helper-Funktion und den Trigger.
-- Idempotent und mit Down-Schritten zum Entfernen.
BEGIN;
-- 1) Spalten anlegen
ALTER TABLE IF EXISTS falukant_log.notification
ADD COLUMN IF NOT EXISTS character_name text;
ALTER TABLE IF EXISTS falukant_log.notification
ADD COLUMN IF NOT EXISTS character_id integer;
-- 2) Index (idempotent)
CREATE INDEX IF NOT EXISTS idx_notification_character_id
ON falukant_log.notification (character_id);
-- 3) Trigger-Funktion anlegen (idempotent)
CREATE OR REPLACE FUNCTION falukant_log.populate_notification_character_name()
RETURNS TRIGGER AS $function$
DECLARE
v_first_name TEXT;
v_last_name TEXT;
v_char_id INTEGER;
v_column_exists BOOLEAN;
BEGIN
-- prüfen, ob Zielspalte existiert
SELECT EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'falukant_log' AND table_name = 'notification' AND column_name = 'character_name'
) INTO v_column_exists;
IF NOT v_column_exists THEN
RETURN NEW;
END IF;
IF NEW.character_name IS NOT NULL THEN
RETURN NEW;
END IF;
v_char_id := NEW.character_id;
IF v_char_id IS NULL AND NEW.user_id IS NOT NULL THEN
SELECT id INTO v_char_id
FROM falukant_data.character
WHERE user_id = NEW.user_id
ORDER BY id DESC
LIMIT 1;
END IF;
IF v_char_id IS NOT NULL THEN
SELECT pf.name, pl.name
INTO v_first_name, v_last_name
FROM falukant_data.character c
LEFT JOIN falukant_predefine.firstname pf ON pf.id = c.first_name
LEFT JOIN falukant_predefine.lastname pl ON pl.id = c.last_name
WHERE c.id = v_char_id;
IF v_first_name IS NOT NULL OR v_last_name IS NOT NULL THEN
NEW.character_name := COALESCE(v_first_name, '') || CASE WHEN v_first_name IS NOT NULL AND v_last_name IS NOT NULL THEN ' ' ELSE '' END || COALESCE(v_last_name, '');
ELSE
NEW.character_name := ('#' || v_char_id::text);
END IF;
ELSE
IF NEW.user_id IS NOT NULL THEN
NEW.character_name := ('#u' || NEW.user_id::text);
END IF;
END IF;
RETURN NEW;
END;
$function$ LANGUAGE plpgsql;
-- 4) Trigger anlegen (BEFORE INSERT)
DROP TRIGGER IF EXISTS trg_populate_notification_character_name ON falukant_log.notification;
CREATE TRIGGER trg_populate_notification_character_name
BEFORE INSERT ON falukant_log.notification
FOR EACH ROW
EXECUTE FUNCTION falukant_log.populate_notification_character_name();
COMMIT;
-- Down / Rollback (falls benötigt):
-- Die folgenden Statements entfernen Trigger, Funktion, Index und Spalten.
-- Hinweis: Ausführbar separat; zur Anwendung einfach die folgenden Zeilen verwenden:
-- BEGIN; DROP TRIGGER IF EXISTS trg_populate_notification_character_name ON falukant_log.notification; DROP FUNCTION IF EXISTS falukant_log.populate_notification_character_name(); DROP INDEX IF EXISTS falukant_log.idx_notification_character_id; ALTER TABLE IF EXISTS falukant_log.notification DROP COLUMN IF EXISTS character_name; ALTER TABLE IF EXISTS falukant_log.notification DROP COLUMN IF EXISTS character_id; COMMIT;

View File

@@ -0,0 +1,11 @@
-- Migration script: add_product_quality_to_stock.sql
-- Fügt die Spalte product_quality zur Tabelle falukant_data.stock hinzu (nullable, idempotent)
BEGIN;
ALTER TABLE IF EXISTS falukant_data.stock
ADD COLUMN IF NOT EXISTS product_quality integer;
COMMIT;
-- Ende

View File

@@ -0,0 +1,38 @@
-- Migration script: add_weather_type_to_production.sql
-- Legt die Spalte weather_type_id in falukant_data.production an,
-- fügt optional einen Foreign Key zu falukant_type.weather(id) hinzu
-- und erstellt einen Index. Idempotent (mehrfaches Ausführen ist unproblematisch).
BEGIN;
-- 1) Spalte anlegen (nullable, idempotent)
ALTER TABLE IF EXISTS falukant_data.production
ADD COLUMN IF NOT EXISTS weather_type_id integer;
-- 2) Fremdschlüssel nur hinzufügen, falls noch kein FK für diese Spalte existiert
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON kcu.constraint_name = tc.constraint_name
AND kcu.constraint_schema = tc.constraint_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.constraint_schema = 'falukant_data'
AND tc.table_name = 'production'
AND kcu.column_name = 'weather_type_id'
) THEN
ALTER TABLE falukant_data.production
ADD CONSTRAINT fk_production_weather_type
FOREIGN KEY (weather_type_id) REFERENCES falukant_type.weather(id);
END IF;
END$$;
-- 3) Index (Postgres: CREATE INDEX IF NOT EXISTS)
CREATE INDEX IF NOT EXISTS idx_production_weather_type_id
ON falukant_data.production (weather_type_id);
COMMIT;
-- Ende

View File

@@ -0,0 +1,23 @@
-- Cleanup script: Entfernt verwaiste Einträge aus user_param_visibility
-- Diese Einträge verweisen auf nicht existierende user_param Einträge
-- und verhindern das Hinzufügen des Foreign Key Constraints
BEGIN;
-- Lösche alle user_param_visibility Einträge, deren param_id nicht mehr in user_param existiert
DELETE FROM community.user_param_visibility
WHERE param_id NOT IN (
SELECT id FROM community.user_param
);
-- Zeige an, wie viele Einträge gelöscht wurden
DO $$
DECLARE
deleted_count INTEGER;
BEGIN
GET DIAGNOSTICS deleted_count = ROW_COUNT;
RAISE NOTICE 'Gelöschte verwaiste Einträge: %', deleted_count;
END $$;
COMMIT;

View File

@@ -0,0 +1,69 @@
-- Backup original sell_cost values (just in case)
-- Run this once: will add a column original_sell_cost and copy existing sell_cost into it
ALTER TABLE IF EXISTS falukant_type.product
ADD COLUMN IF NOT EXISTS original_sell_cost numeric;
UPDATE falukant_type.product
SET sell_cost = sell_cost * ((6 * 7 / 100) + 100);
-- Compute min and max cumulative tax across all regions
WITH RECURSIVE ancestors AS (
SELECT id AS start_id, id, parent_id, tax_percent FROM falukant_data.region
UNION ALL
SELECT a.start_id, r.id, r.parent_id, r.tax_percent
FROM falukant_data.region r
JOIN ancestors a ON r.id = a.parent_id
), totals AS (
SELECT start_id, COALESCE(SUM(tax_percent),0) AS total FROM ancestors GROUP BY start_id
), mm AS (
SELECT COALESCE(MIN(total),0) AS min_total, COALESCE(MAX(total),0) AS max_total FROM totals
)
SELECT * FROM mm;
-- Choose one of the following update blocks to run:
-- 1) MIN-STRATEGY: increase sell_cost so that taxes at the minimal cumulative tax have no effect
-- (this will set sell_cost = CEIL(original_sell_cost * (1 / (1 - min_total/100))))
-- BEGIN MIN-STRATEGY
-- WITH mm AS (
-- WITH RECURSIVE ancestors AS (
-- SELECT id AS start_id, id, parent_id, tax_percent FROM falukant_data.region
-- UNION ALL
-- SELECT a.start_id, r.id, r.parent_id, r.tax_percent
-- FROM falukant_data.region r
-- JOIN ancestors a ON r.id = a.parent_id
-- ), totals AS (
-- SELECT start_id, COALESCE(SUM(tax_percent),0) AS total FROM ancestors GROUP BY start_id
-- )
-- SELECT COALESCE(MIN(total),0) AS min_total FROM totals
-- )
-- UPDATE falukant_type.product
-- SET sell_cost = CEIL(original_sell_cost * (CASE WHEN (1 - (SELECT min_total FROM mm)/100) <= 0 THEN 1 ELSE (1 / (1 - (SELECT min_total FROM mm)/100)) END));
-- END MIN-STRATEGY
-- 2) MAX-STRATEGY: increase sell_cost so that taxes at the maximal cumulative tax have no effect
-- (this will set sell_cost = CEIL(original_sell_cost * (1 / (1 - max_total/100))))
-- BEGIN MAX-STRATEGY
-- WITH mm AS (
-- WITH RECURSIVE ancestors AS (
-- SELECT id AS start_id, id, parent_id, tax_percent FROM falukant_data.region
-- UNION ALL
-- SELECT a.start_id, r.id, r.parent_id, r.tax_percent
-- FROM falukant_data.region r
-- JOIN ancestors a ON r.id = a.parent_id
-- ), totals AS (
-- SELECT start_id, COALESCE(SUM(tax_percent),0) AS total FROM ancestors GROUP BY start_id
-- )
-- SELECT COALESCE(MAX(total),0) AS max_total FROM totals
-- )
-- UPDATE falukant_type.product
-- SET sell_cost = CEIL(original_sell_cost * (CASE WHEN (1 - (SELECT max_total FROM mm)/100) <= 0 THEN 1 ELSE (1 / (1 - (SELECT max_total FROM mm)/100)) END));
-- END MAX-STRATEGY
-- Notes:
-- 1) Uncomment exactly one strategy block (MIN or MAX) and run the script.
-- 2) The script creates `original_sell_cost` as a backup; keep it for safety.
-- 3) CEIL is used to avoid undercompensating due to rounding. If you prefer ROUND use ROUND(...).
-- 4) Test on a staging DB first.

View File

@@ -228,10 +228,29 @@ async function initializeFalukantStockTypes() {
} }
async function initializeFalukantProducts() { async function initializeFalukantProducts() {
await ProductType.bulkCreate([ // compute min/max cumulative tax across regions
const taxRows = await sequelize.query(`
WITH RECURSIVE ancestors AS (
SELECT id AS start_id, id, parent_id, tax_percent FROM falukant_data.region
UNION ALL
SELECT a.start_id, r.id, r.parent_id, r.tax_percent
FROM falukant_data.region r
JOIN ancestors a ON r.id = a.parent_id
), totals AS (
SELECT start_id, COALESCE(SUM(tax_percent), 0) AS total FROM ancestors GROUP BY start_id
)
SELECT COALESCE(MIN(total),0) AS min_total, COALESCE(MAX(total),0) AS max_total FROM totals
`, { type: sequelize.QueryTypes.SELECT });
const minTax = parseFloat(taxRows?.[0]?.min_total) || 0;
const maxTax = parseFloat(taxRows?.[0]?.max_total) || 0;
const factorMin = (minTax >= 100) ? 1 : (1 / (1 - minTax / 100));
const factorMax = (maxTax >= 100) ? 1 : (1 / (1 - maxTax / 100));
const baseProducts = [
{ labelTr: 'wheat', category: 1, productionTime: 2, sellCost: 7 }, { labelTr: 'wheat', category: 1, productionTime: 2, sellCost: 7 },
{ labelTr: 'grain', category: 1, productionTime: 2, sellCost: 7 }, { labelTr: 'grain', category: 1, productionTime: 2, sellCost: 7 },
{ labelTr: 'carrot', category: 1, productionTime: 1, sellCost: 46}, { labelTr: 'carrot', category: 1, productionTime: 1, sellCost: 5},
{ labelTr: 'fish', category: 1, productionTime: 2, sellCost: 7 }, { labelTr: 'fish', category: 1, productionTime: 2, sellCost: 7 },
{ labelTr: 'meat', category: 1, productionTime: 2, sellCost: 7 }, { labelTr: 'meat', category: 1, productionTime: 2, sellCost: 7 },
{ labelTr: 'leather', category: 1, productionTime: 2, sellCost: 7 }, { labelTr: 'leather', category: 1, productionTime: 2, sellCost: 7 },
@@ -261,7 +280,11 @@ async function initializeFalukantProducts() {
{ labelTr: 'shield', category: 4, productionTime: 5, sellCost: 60 }, { labelTr: 'shield', category: 4, productionTime: 5, sellCost: 60 },
{ labelTr: 'horse', category: 5, productionTime: 5, sellCost: 60 }, { labelTr: 'horse', category: 5, productionTime: 5, sellCost: 60 },
{ labelTr: 'ox', category: 5, productionTime: 5, sellCost: 60 }, { labelTr: 'ox', category: 5, productionTime: 5, sellCost: 60 },
], { ];
const productsToInsert = baseProducts;
await ProductType.bulkCreate(productsToInsert, {
ignoreDuplicates: true, ignoreDuplicates: true,
}); });
} }

View File

@@ -12,11 +12,17 @@ import TitleOfNobility from "../../models/falukant/type/title_of_nobility.js";
import PartyType from "../../models/falukant/type/party.js"; import PartyType from "../../models/falukant/type/party.js";
import MusicType from "../../models/falukant/type/music.js"; import MusicType from "../../models/falukant/type/music.js";
import BanquetteType from "../../models/falukant/type/banquette.js"; import BanquetteType from "../../models/falukant/type/banquette.js";
import ReputationActionType from "../../models/falukant/type/reputation_action.js";
import VehicleType from "../../models/falukant/type/vehicle.js";
import LearnRecipient from "../../models/falukant/type/learn_recipient.js"; import LearnRecipient from "../../models/falukant/type/learn_recipient.js";
import PoliticalOfficeType from "../../models/falukant/type/political_office_type.js"; import PoliticalOfficeType from "../../models/falukant/type/political_office_type.js";
import PoliticalOfficeBenefitType from "../../models/falukant/type/political_office_benefit_type.js"; import PoliticalOfficeBenefitType from "../../models/falukant/type/political_office_benefit_type.js";
import PoliticalOfficePrerequisite from "../../models/falukant/predefine/political_office_prerequisite.js"; import PoliticalOfficePrerequisite from "../../models/falukant/predefine/political_office_prerequisite.js";
import UndergroundType from "../../models/falukant/type/underground.js"; import UndergroundType from "../../models/falukant/type/underground.js";
import WeatherType from "../../models/falukant/type/weather.js";
import Weather from "../../models/falukant/data/weather.js";
import ProductWeatherEffect from "../../models/falukant/type/product_weather_effect.js";
import ProductType from "../../models/falukant/type/product.js";
// Debug-Flag: Nur wenn DEBUG_FALUKANT=1 gesetzt ist, werden ausführliche Logs ausgegeben. // Debug-Flag: Nur wenn DEBUG_FALUKANT=1 gesetzt ist, werden ausführliche Logs ausgegeben.
const falukantDebug = process.env.DEBUG_FALUKANT === '1'; const falukantDebug = process.env.DEBUG_FALUKANT === '1';
@@ -36,13 +42,67 @@ export const initializeFalukantTypes = async () => {
await initializeFalukantPartyTypes(); await initializeFalukantPartyTypes();
await initializeFalukantMusicTypes(); await initializeFalukantMusicTypes();
await initializeFalukantBanquetteTypes(); await initializeFalukantBanquetteTypes();
await initializeFalukantReputationActions();
await initializeLearnerTypes(); await initializeLearnerTypes();
await initializePoliticalOfficeBenefitTypes(); await initializePoliticalOfficeBenefitTypes();
await initializePoliticalOfficeTypes(); await initializePoliticalOfficeTypes();
await initializePoliticalOfficePrerequisites(); await initializePoliticalOfficePrerequisites();
await initializeUndergroundTypes(); await initializeUndergroundTypes();
await initializeVehicleTypes();
await initializeFalukantWeatherTypes();
await initializeFalukantWeathers();
await initializeFalukantProductWeatherEffects();
}; };
const reputationActions = [
// Günstig / häufig: schnelle Abnutzung
{ tr: "soup_kitchen", cost: 500, baseGain: 2, decayFactor: 0.85, minGain: 0, decayWindowDays: 7 },
// Mittel: gesellschaftlich anerkannt
{ tr: "library_donation", cost: 5000, baseGain: 4, decayFactor: 0.88, minGain: 0, decayWindowDays: 7 },
{ tr: "scholarships", cost: 10000, baseGain: 5, decayFactor: 0.87, minGain: 0, decayWindowDays: 7 },
{ tr: "church_hospice", cost: 12000, baseGain: 5, decayFactor: 0.87, minGain: 0, decayWindowDays: 7 },
{ tr: "school_funding", cost: 15000, baseGain: 6, decayFactor: 0.88, minGain: 0, decayWindowDays: 7 },
// Großprojekte: teurer, langsamerer Rufverfall
{ tr: "orphanage_build", cost: 20000, baseGain: 7, decayFactor: 0.90, minGain: 0, decayWindowDays: 7 },
{ tr: "bridge_build", cost: 25000, baseGain: 7, decayFactor: 0.90, minGain: 0, decayWindowDays: 7 },
{ tr: "hospital_donation", cost: 30000, baseGain: 8, decayFactor: 0.90, minGain: 0, decayWindowDays: 7 },
{ tr: "patronage", cost: 40000, baseGain: 9, decayFactor: 0.91, minGain: 0, decayWindowDays: 7 },
{ tr: "statue_build", cost: 50000, baseGain: 10, decayFactor: 0.92, minGain: 0, decayWindowDays: 7 },
{ tr: "well_build", cost: 8000, baseGain: 4, decayFactor: 0.87, minGain: 0, decayWindowDays: 7 },
];
async function initializeFalukantReputationActions() {
// robustes Upsert ohne FK/Constraints-Ärger:
// - finde existierende nach tr
// - update bei Änderungen
// - create wenn fehlt
const existing = await ReputationActionType.findAll({ attributes: ['id', 'tr', 'cost', 'baseGain', 'decayFactor', 'minGain'] });
const existingByTr = new Map(existing.map(e => [e.tr, e]));
for (const a of reputationActions) {
const found = existingByTr.get(a.tr);
if (!found) {
await ReputationActionType.create(a);
continue;
}
const needsUpdate =
Number(found.cost) !== Number(a.cost) ||
Number(found.baseGain) !== Number(a.baseGain) ||
Number(found.decayFactor) !== Number(a.decayFactor) ||
Number(found.minGain) !== Number(a.minGain) ||
Number(found.decayWindowDays || 7) !== Number(a.decayWindowDays || 7);
if (needsUpdate) {
await found.update({
cost: a.cost,
baseGain: a.baseGain,
decayFactor: a.decayFactor,
minGain: a.minGain,
decayWindowDays: a.decayWindowDays ?? 7,
});
}
}
}
const regionTypes = []; const regionTypes = [];
const regionTypeTrs = [ const regionTypeTrs = [
"country", "country",
@@ -273,6 +333,17 @@ const learnerTypes = [
{ tr: 'director', }, { tr: 'director', },
]; ];
const vehicleTypes = [
// build times (in minutes): 60, 90, 180, 300, 720, 120, 1440
{ tr: 'cargo_cart', name: 'Lastkarren', cost: 100, capacity: 20, transportMode: 'land', speed: 1, buildTimeMinutes: 60 },
{ tr: 'ox_cart', name: 'Ochsenkarren', cost: 200, capacity: 50, transportMode: 'land', speed: 2, buildTimeMinutes: 90 },
{ tr: 'small_carriage', name: 'kleine Pferdekutsche', cost: 300, capacity: 35, transportMode: 'land', speed: 3, buildTimeMinutes: 180 },
{ tr: 'large_carriage', name: 'große Pferdekutsche', cost: 1000, capacity: 100, transportMode: 'land', speed: 3, buildTimeMinutes: 300 },
{ tr: 'four_horse_carriage', name: 'Vierspänner', cost: 5000, capacity: 200, transportMode: 'land', speed: 4, buildTimeMinutes: 720 },
{ tr: 'raft', name: 'Floß', cost: 100, capacity: 25, transportMode: 'water', speed: 1, buildTimeMinutes: 120 },
{ tr: 'sailing_ship', name: 'Segelschiff', cost: 500, capacity: 200, transportMode: 'water', speed: 3, buildTimeMinutes: 1440 },
];
const politicalOfficeBenefitTypes = [ const politicalOfficeBenefitTypes = [
{ tr: 'salary' }, { tr: 'salary' },
{ tr: 'reputation' }, { tr: 'reputation' },
@@ -282,6 +353,7 @@ const politicalOfficeBenefitTypes = [
{ tr: 'tax_exemption' }, { tr: 'tax_exemption' },
{ tr: 'guard_protection' }, { tr: 'guard_protection' },
{ tr: 'court_immunity' }, { tr: 'court_immunity' },
{ tr: 'set_regionl_tax' },
]; ];
const politicalOffices = [ const politicalOffices = [
@@ -883,6 +955,31 @@ export const initializeLearnerTypes = async () => {
} }
}; };
export const initializeVehicleTypes = async () => {
for (const v of vehicleTypes) {
const existing = await VehicleType.findOne({ where: { tr: v.tr } });
if (!existing) {
await VehicleType.create({
tr: v.tr,
cost: v.cost,
capacity: v.capacity,
transportMode: v.transportMode,
speed: v.speed,
buildTimeMinutes: v.buildTimeMinutes,
});
} else {
// ensure new fields like cost/buildTime are updated if missing
await existing.update({
cost: v.cost,
capacity: v.capacity,
transportMode: v.transportMode,
speed: v.speed,
buildTimeMinutes: v.buildTimeMinutes,
});
}
}
};
export const initializePoliticalOfficeBenefitTypes = async () => { export const initializePoliticalOfficeBenefitTypes = async () => {
for (const benefitType of politicalOfficeBenefitTypes) { for (const benefitType of politicalOfficeBenefitTypes) {
await PoliticalOfficeBenefitType.findOrCreate({ await PoliticalOfficeBenefitType.findOrCreate({
@@ -972,3 +1069,445 @@ export const initializeFalukantTitlesOfNobility = async () => {
throw error; throw error;
} }
}; };
const weatherTypes = [
{ tr: "sunny" },
{ tr: "cloudy" },
{ tr: "rainy" },
{ tr: "stormy" },
{ tr: "snowy" },
{ tr: "foggy" },
{ tr: "windy" },
{ tr: "clear" }
];
export const initializeFalukantWeatherTypes = async () => {
try {
for (const weatherType of weatherTypes) {
await WeatherType.findOrCreate({
where: { tr: weatherType.tr },
});
}
console.log(`[Falukant] Wettertypen initialisiert: ${weatherTypes.length} Typen`);
} catch (error) {
console.error('❌ Fehler beim Initialisieren der Falukant-Wettertypen:', error);
throw error;
}
};
export const initializeFalukantWeathers = async () => {
try {
// Hole alle Städte (Regions vom Typ "city")
const cityRegionType = await RegionType.findOne({ where: { labelTr: 'city' } });
if (!cityRegionType) {
console.warn('[Falukant] Kein RegionType "city" gefunden, überspringe Wetter-Initialisierung');
return;
}
const cities = await RegionData.findAll({
where: { regionTypeId: cityRegionType.id },
attributes: ['id', 'name']
});
// Hole alle Wettertypen
const allWeatherTypes = await WeatherType.findAll();
if (allWeatherTypes.length === 0) {
console.warn('[Falukant] Keine Wettertypen gefunden, überspringe Wetter-Initialisierung');
return;
}
// Weise jeder Stadt zufällig ein Wetter zu
for (const city of cities) {
const randomWeatherType = allWeatherTypes[Math.floor(Math.random() * allWeatherTypes.length)];
await Weather.findOrCreate({
where: { regionId: city.id },
defaults: {
weatherTypeId: randomWeatherType.id
}
});
}
console.log(`[Falukant] Wetter für ${cities.length} Städte initialisiert`);
} catch (error) {
console.error('❌ Fehler beim Initialisieren der Falukant-Wetter:', error);
throw error;
}
};
export const initializeFalukantProductWeatherEffects = async () => {
try {
// Hole alle Produkte und Wettertypen
const products = await ProductType.findAll();
const weatherTypes = await WeatherType.findAll();
if (products.length === 0 || weatherTypes.length === 0) {
console.warn('[Falukant] Keine Produkte oder Wettertypen gefunden, überspringe Produkt-Wetter-Effekte');
return;
}
// Erstelle Map für schnellen Zugriff
const productMap = new Map(products.map(p => [p.labelTr, p.id]));
const weatherMap = new Map(weatherTypes.map(w => [w.tr, w.id]));
// Definiere Effekte für jedes Produkt-Wetter-Paar
// Format: { productLabel: { weatherTr: effectValue } }
// effectValue: -2 (sehr negativ), -1 (negativ), 0 (neutral), 1 (positiv), 2 (sehr positiv)
const effects = {
// Landwirtschaftliche Produkte
wheat: {
sunny: 1, // Gutes Wachstum
cloudy: 0,
rainy: 2, // Wasser ist essentiell
stormy: -1, // Kann Ernte beschädigen
snowy: -2, // Kein Wachstum
foggy: 0,
windy: 0,
clear: 1
},
grain: {
sunny: 1,
cloudy: 0,
rainy: 2,
stormy: -1,
snowy: -2,
foggy: 0,
windy: 0,
clear: 1
},
carrot: {
sunny: 1,
cloudy: 0,
rainy: 2,
stormy: -1,
snowy: -2,
foggy: 0,
windy: 0,
clear: 1
},
fish: {
sunny: 0,
cloudy: 0,
rainy: 0,
stormy: -2, // Gefährlich zu fischen
snowy: -1, // Kaltes Wasser
foggy: -1, // Schlechte Sicht
windy: -1, // Schwierig zu fischen
clear: 1
},
meat: {
sunny: -1, // Kann verderben
cloudy: 0,
rainy: -1, // Feucht
stormy: -2,
snowy: 1, // Kühlt
foggy: 0,
windy: 0,
clear: 0
},
leather: {
sunny: -1, // Kann austrocknen
cloudy: 0,
rainy: -1, // Feucht
stormy: -2,
snowy: 1, // Kühlt
foggy: 0,
windy: 0,
clear: 0
},
wood: {
sunny: 1, // Trocknet gut
cloudy: 0,
rainy: -1, // Feucht
stormy: -2, // Kann beschädigt werden
snowy: 0,
foggy: -1, // Feucht
windy: 0,
clear: 1
},
stone: {
sunny: 0,
cloudy: 0,
rainy: 0,
stormy: 0,
snowy: 0,
foggy: 0,
windy: 0,
clear: 0
},
milk: {
sunny: -1, // Kann sauer werden
cloudy: 0,
rainy: 0,
stormy: -1,
snowy: 1, // Kühlt
foggy: 0,
windy: 0,
clear: 0
},
cheese: {
sunny: -1,
cloudy: 0,
rainy: -1, // Feucht
stormy: -1,
snowy: 1, // Kühlt
foggy: 0,
windy: 0,
clear: 0
},
bread: {
sunny: 0,
cloudy: 0,
rainy: -1, // Feucht
stormy: -1,
snowy: 0,
foggy: -1, // Feucht
windy: 0,
clear: 0
},
beer: {
sunny: 0,
cloudy: 0,
rainy: 0,
stormy: 0,
snowy: 1, // Kühlt
foggy: 0,
windy: 0,
clear: 0
},
iron: {
sunny: 0,
cloudy: 0,
rainy: -1, // Rost
stormy: -2, // Rost
snowy: 0,
foggy: -1, // Feucht
windy: 0,
clear: 0
},
copper: {
sunny: 0,
cloudy: 0,
rainy: -1, // Oxidation
stormy: -2,
snowy: 0,
foggy: -1,
windy: 0,
clear: 0
},
spices: {
sunny: 0,
cloudy: 0,
rainy: -1, // Feucht
stormy: -1,
snowy: 0,
foggy: -1, // Feucht
windy: 0,
clear: 0
},
salt: {
sunny: 1, // Trocknet gut
cloudy: 0,
rainy: -2, // Löst sich auf
stormy: -2,
snowy: 0,
foggy: -1, // Feucht
windy: 0,
clear: 1
},
sugar: {
sunny: 0,
cloudy: 0,
rainy: -2, // Löst sich auf
stormy: -2,
snowy: 0,
foggy: -1, // Feucht
windy: 0,
clear: 0
},
vinegar: {
sunny: 1, // Heißes Wetter fördert Gärung
cloudy: 0,
rainy: 0,
stormy: 0,
snowy: -1, // Kaltes Wetter hemmt Gärung
foggy: 0,
windy: 0,
clear: 1 // Heißes Wetter fördert Gärung
},
cotton: {
sunny: 1, // Trocknet gut
cloudy: 0,
rainy: -1, // Feucht
stormy: -2,
snowy: 0,
foggy: -1, // Feucht
windy: 0,
clear: 1
},
wine: {
sunny: 0,
cloudy: 0,
rainy: 0,
stormy: 0,
snowy: 1, // Kühlt
foggy: 0,
windy: 0,
clear: 0
},
gold: {
sunny: 0,
cloudy: 0,
rainy: 0,
stormy: 0,
snowy: 0,
foggy: 0,
windy: 0,
clear: 0
},
diamond: {
sunny: 0,
cloudy: 0,
rainy: 0,
stormy: 0,
snowy: 0,
foggy: 0,
windy: 0,
clear: 0
},
furniture: {
sunny: 0,
cloudy: 0,
rainy: -1, // Feucht
stormy: -2,
snowy: 0,
foggy: -1, // Feucht
windy: 0,
clear: 0
},
clothing: {
sunny: 0,
cloudy: 0,
rainy: -1, // Feucht
stormy: -2,
snowy: 0,
foggy: -1, // Feucht
windy: 0,
clear: 0
},
jewelry: {
sunny: 0,
cloudy: 0,
rainy: -2, // Kann beschädigt werden
stormy: -2,
snowy: 0,
foggy: -1, // Feucht
windy: 0,
clear: 0
},
painting: {
sunny: -1, // Kann verblassen
cloudy: 0,
rainy: -2, // Feucht
stormy: -2,
snowy: 0,
foggy: -1, // Feucht
windy: 0,
clear: -1
},
book: {
sunny: -1, // Kann verblassen
cloudy: 0,
rainy: -2, // Feucht
stormy: -2,
snowy: 0,
foggy: -1, // Feucht
windy: 0,
clear: -1
},
weapon: {
sunny: 0,
cloudy: 0,
rainy: -1, // Rost
stormy: -2, // Rost
snowy: 0,
foggy: -1, // Feucht
windy: 0,
clear: 0
},
armor: {
sunny: 0,
cloudy: 0,
rainy: -1, // Rost
stormy: -2, // Rost
snowy: 0,
foggy: -1, // Feucht
windy: 0,
clear: 0
},
shield: {
sunny: 0,
cloudy: 0,
rainy: -1, // Rost
stormy: -2, // Rost
snowy: 0,
foggy: -1, // Feucht
windy: 0,
clear: 0
},
horse: {
sunny: 1, // Gutes Wetter
cloudy: 0,
rainy: -1, // Nass
stormy: -2, // Angst
snowy: -1, // Kalt
foggy: 0,
windy: 0,
clear: 1
},
ox: {
sunny: 1, // Gutes Wetter
cloudy: 0,
rainy: -1, // Nass
stormy: -2, // Angst
snowy: -1, // Kalt
foggy: 0,
windy: 0,
clear: 1
}
};
// Erstelle alle Produkt-Wetter-Effekte
const effectEntries = [];
for (const [productLabel, weatherEffects] of Object.entries(effects)) {
const productId = productMap.get(productLabel);
if (!productId) {
console.warn(`[Falukant] Produkt "${productLabel}" nicht gefunden, überspringe`);
continue;
}
for (const [weatherTr, effectValue] of Object.entries(weatherEffects)) {
const weatherTypeId = weatherMap.get(weatherTr);
if (!weatherTypeId) {
console.warn(`[Falukant] Wettertyp "${weatherTr}" nicht gefunden, überspringe`);
continue;
}
effectEntries.push({
productId,
weatherTypeId,
qualityEffect: effectValue
});
}
}
// Bulk insert mit ignoreDuplicates
await ProductWeatherEffect.bulkCreate(effectEntries, {
ignoreDuplicates: true
});
console.log(`[Falukant] Produkt-Wetter-Effekte initialisiert: ${effectEntries.length} Einträge`);
} catch (error) {
console.error('❌ Fehler beim Initialisieren der Produkt-Wetter-Effekte:', error);
throw error;
}
};

View File

@@ -3,6 +3,25 @@ import dotenv from 'dotenv';
dotenv.config(); dotenv.config();
// Optionales Performance-Logging (aktivierbar per ENV)
// - SQL_BENCHMARK=1: Sequelize liefert Query-Timing (ms) an logger
// - SQL_SLOW_MS=200: Logge nur Queries ab dieser Dauer (wenn SQL_LOG_ALL nicht gesetzt)
// - SQL_LOG_ALL=1: Logge alle Queries (auch ohne benchmark)
const SQL_BENCHMARK = process.env.SQL_BENCHMARK === '1';
const SQL_LOG_ALL = process.env.SQL_LOG_ALL === '1';
const SQL_SLOW_MS = Number.parseInt(process.env.SQL_SLOW_MS || '200', 10);
const sqlLogger = (sql, timing) => {
// Sequelize ruft logging(sql) oder logging(sql, timing) abhängig von benchmark auf.
if (!SQL_BENCHMARK) {
if (SQL_LOG_ALL) console.log(sql);
return;
}
const ms = typeof timing === 'number' ? timing : 0;
if (SQL_LOG_ALL || ms >= SQL_SLOW_MS) {
console.log(`🛢️ SQL ${ms}ms: ${sql}`);
}
};
// Validiere Umgebungsvariablen // Validiere Umgebungsvariablen
const dbName = process.env.DB_NAME; const dbName = process.env.DB_NAME;
const dbUser = process.env.DB_USER; const dbUser = process.env.DB_USER;
@@ -26,6 +45,8 @@ const sequelize = new Sequelize(dbName, dbUser, dbPass, {
timestamps: false, timestamps: false,
underscored: true // WICHTIG: Alle Datenbankfelder im snake_case Format underscored: true // WICHTIG: Alle Datenbankfelder im snake_case Format
}, },
benchmark: SQL_BENCHMARK,
logging: sqlLogger,
}); });
const createSchemas = async () => { const createSchemas = async () => {
@@ -45,6 +66,16 @@ const createSchemas = async () => {
const initializeDatabase = async () => { const initializeDatabase = async () => {
await createSchemas(); await createSchemas();
// Aktiviere die pgcrypto Erweiterung für die digest() Funktion
try {
await sequelize.query('CREATE EXTENSION IF NOT EXISTS pgcrypto;');
console.log('✅ pgcrypto Erweiterung aktiviert');
} catch (error) {
console.warn('⚠️ Konnte pgcrypto Erweiterung nicht aktivieren:', error.message);
// Fortfahren, da die Erweiterung möglicherweise bereits aktiviert ist
}
// Modelle nur laden, aber an dieser Stelle NICHT syncen. // Modelle nur laden, aber an dieser Stelle NICHT syncen.
// Das Syncing (inkl. alter: true bei Bedarf) wird anschließend zentral // Das Syncing (inkl. alter: true bei Bedarf) wird anschließend zentral
// über syncModelsWithUpdates()/syncModelsAlways gesteuert. // über syncModelsWithUpdates()/syncModelsAlways gesteuert.
@@ -95,7 +126,8 @@ const syncModelsWithUpdates = async (models) => {
if (needsUpdate) { if (needsUpdate) {
console.log('🔄 Schema-Updates nötig - verwende alter: true'); console.log('🔄 Schema-Updates nötig - verwende alter: true');
for (const model of Object.values(models)) { for (const model of Object.values(models)) {
await model.sync({ alter: true, force: false }); // constraints: false verhindert, dass Sequelize Foreign Keys automatisch erstellt
await model.sync({ alter: true, force: false, constraints: false });
} }
console.log('✅ Schema-Updates abgeschlossen'); console.log('✅ Schema-Updates abgeschlossen');
} else { } else {
@@ -363,12 +395,13 @@ const getExpectedDefaultValue = (defaultValue) => {
const updateSchema = async (models) => { const updateSchema = async (models) => {
console.log('🔄 Aktualisiere Datenbankschema...'); console.log('🔄 Aktualisiere Datenbankschema...');
for (const model of Object.values(models)) { for (const model of Object.values(models)) {
await model.sync({ alter: true, force: false }); // constraints: false verhindert, dass Sequelize Foreign Keys automatisch erstellt
await model.sync({ alter: true, force: false, constraints: false });
} }
console.log('✅ Datenbankschema aktualisiert'); console.log('✅ Datenbankschema aktualisiert');
}; };
async function updateFalukantUserMoney(falukantUserId, moneyChange, activity, changedBy = null) { async function updateFalukantUserMoney(falukantUserId, moneyChange, activity, changedBy = null, transaction = null) {
try { try {
const result = await sequelize.query( const result = await sequelize.query(
`SELECT falukant_data.update_money( `SELECT falukant_data.update_money(
@@ -385,6 +418,7 @@ async function updateFalukantUserMoney(falukantUserId, moneyChange, activity, ch
changedBy, changedBy,
}, },
type: sequelize.QueryTypes.SELECT, type: sequelize.QueryTypes.SELECT,
transaction: transaction || undefined,
} }
); );
return { return {
@@ -406,8 +440,311 @@ const syncModelsAlways = async (models) => {
console.log('🔍 Deployment-Modus: Führe immer Schema-Updates durch...'); console.log('🔍 Deployment-Modus: Führe immer Schema-Updates durch...');
try { try {
for (const model of Object.values(models)) { for (const model of Object.values(models)) {
await model.sync({ alter: true, force: false }); // Temporarily remove VIRTUAL fields before sync to prevent sync errors
const originalAttributes = model.rawAttributes;
const virtualFields = {};
// Find and temporarily remove VIRTUAL fields
// Check multiple ways to identify VIRTUAL fields
for (const [key, attr] of Object.entries(originalAttributes)) {
// Check if it's a VIRTUAL field by checking the type
let isVirtual = false;
if (attr.type) {
// Method 1: Check if type key is VIRTUAL (most reliable)
if (attr.type.key === 'VIRTUAL') {
isVirtual = true;
}
// Method 2: Direct comparison with DataTypes.VIRTUAL
else if (attr.type === DataTypes.VIRTUAL) {
isVirtual = true;
}
// Method 3: Check toString representation
else if (typeof attr.type.toString === 'function') {
const typeStr = attr.type.toString();
if (typeStr === 'VIRTUAL' || typeStr.includes('VIRTUAL')) {
isVirtual = true;
}
}
// Method 4: Check constructor name
else if (attr.type.constructor && attr.type.constructor.name === 'VIRTUAL') {
isVirtual = true;
}
}
// Also check if field has a getter but no setter and no field mapping (common pattern for VIRTUAL fields)
// But only if it doesn't have a 'field' property, which means it's not mapped to a database column
if (!isVirtual && attr.get && !attr.set && !attr.field) {
// This might be a VIRTUAL field, but be careful not to remove real fields
// Only remove if we're certain it's VIRTUAL
}
if (isVirtual) {
virtualFields[key] = attr;
delete model.rawAttributes[key];
console.log(` ⚠️ Temporarily removed VIRTUAL field: ${key} from model ${model.name}`);
}
}
// Special handling for Notification model: ensure characterName VIRTUAL field is removed
// This is a workaround for Sequelize bug where it confuses characterName (VIRTUAL) with character_name (STRING)
if (model.name === 'Notification' && model.rawAttributes.characterName) {
if (!virtualFields.characterName) {
virtualFields.characterName = model.rawAttributes.characterName;
delete model.rawAttributes.characterName;
console.log(` ⚠️ Explicitly removed VIRTUAL field: characterName from Notification model`);
}
}
// constraints: false wird von Sequelize ignoriert wenn Associations vorhanden sind
// Wir müssen die Associations temporär entfernen, um Foreign Keys zu verhindern
const originalAssociations = model.associations ? { ...model.associations } : {};
const associationKeys = Object.keys(originalAssociations);
try {
// Entferne temporär alle Associations, damit Sequelize keine Foreign Keys erstellt
// Dies muss innerhalb des try Blocks sein, damit die Wiederherstellung im finally Block garantiert ist
if (associationKeys.length > 0) {
console.log(` ⚠️ Temporarily removing ${associationKeys.length} associations from ${model.name} to prevent FK creation`);
// Lösche alle Associations temporär
for (const key of associationKeys) {
delete model.associations[key];
}
}
// Entferne bestehende Foreign Keys vor dem Sync, damit Sequelize sie nicht aktualisiert
try {
const tableName = model.tableName;
// Schema kann eine Funktion sein, daher prüfen wir model.options.schema direkt
const schema = model.options?.schema || 'public';
console.log(` 🔍 Checking for foreign keys in ${schema}.${tableName}...`);
const foreignKeys = await sequelize.query(`
SELECT tc.constraint_name
FROM information_schema.table_constraints AS tc
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_name = :tableName
AND tc.table_schema = :schema
`, {
replacements: { tableName, schema },
type: sequelize.QueryTypes.SELECT
});
if (foreignKeys && foreignKeys.length > 0) {
console.log(` ⚠️ Found ${foreignKeys.length} existing foreign keys:`, foreignKeys.map(fk => fk.constraint_name).join(', '));
console.log(` ⚠️ Removing ${foreignKeys.length} existing foreign keys from ${model.name} (schema: ${schema}) before sync`);
for (const fk of foreignKeys) {
console.log(` 🗑️ Dropping constraint: ${fk.constraint_name}`);
await sequelize.query(`
ALTER TABLE "${schema}"."${tableName}"
DROP CONSTRAINT IF EXISTS "${fk.constraint_name}" CASCADE
`);
}
console.log(` ✅ All foreign keys removed for ${model.name}`);
} else {
console.log(` ✅ No foreign keys found for ${model.name}`);
}
} catch (fkError) {
console.warn(` ⚠️ Could not remove foreign keys for ${model.name}:`, fkError.message);
console.warn(` ⚠️ Error details:`, fkError);
}
console.log(` 🔄 Syncing model ${model.name} with constraints: false`);
try {
// Versuche doppelte pg_description Einträge vor dem Sync zu bereinigen
// Hinweis: Benötigt Superuser-Rechte oder spezielle Berechtigungen
try {
const tableName = model.tableName;
const schema = model.options?.schema || 'public';
// Verwende direkte Parameter-Einsetzung, da DO $$ keine Parameterbindung unterstützt
// Die Parameter sind sicher, da sie von Sequelize-Modell-Eigenschaften kommen
await sequelize.query(`
DELETE FROM pg_catalog.pg_description d1
WHERE d1.objoid IN (
SELECT c.oid
FROM pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = '${tableName.replace(/'/g, "''")}'
AND n.nspname = '${schema.replace(/'/g, "''")}'
)
AND EXISTS (
SELECT 1
FROM pg_catalog.pg_description d2
WHERE d2.objoid = d1.objoid
AND d2.objsubid = d1.objsubid
AND d2.ctid < d1.ctid
)
`);
} catch (descError) {
// Ignoriere Berechtigungsfehler - das ist normal, wenn der Benutzer keine Superuser-Rechte hat
if (descError.message && descError.message.includes('Berechtigung')) {
console.log(` Cannot clean up duplicate pg_description entries (requires superuser privileges): ${model.name}`);
} else {
console.warn(` ⚠️ Could not clean up duplicate pg_description entries for ${model.name}:`, descError.message);
}
}
await model.sync({ alter: true, force: false, constraints: false });
} catch (syncError) {
// Wenn Sequelize einen "mehr als eine Zeile" Fehler hat, überspringe das Model
// Dies kann durch doppelte pg_description Einträge oder mehrere Tabellen mit demselben Namen verursacht werden
if (syncError.message && (syncError.message.includes('mehr als eine Zeile') || syncError.message.includes('more than one row'))) {
const tableName = model.tableName;
const schema = model.options?.schema || 'public';
console.error(` ❌ Cannot sync ${model.name} (${schema}.${tableName}) due to Sequelize describeTable error`);
console.error(` ❌ This is likely caused by multiple tables with the same name in different schemas`);
console.error(` ❌ or duplicate pg_description entries (requires superuser to fix)`);
console.error(` ⚠️ Skipping sync for ${model.name} - Schema is likely already correct`);
// Überspringe dieses Model und fahre mit dem nächsten fort
continue;
}
// Wenn eine referenzierte Tabelle noch nicht existiert, erstelle die Tabelle ohne Foreign Key
else if (syncError.message && (syncError.message.includes('existiert nicht') || syncError.message.includes('does not exist') || syncError.message.includes('Relation'))) {
const tableName = model.tableName;
const schema = model.options?.schema || 'public';
console.warn(` ⚠️ Cannot create ${model.name} (${schema}.${tableName}) with Foreign Key - referenced table does not exist yet`);
console.warn(` ⚠️ Attempting to create table without Foreign Key constraint...`);
try {
// Prüfe, ob die Tabelle bereits existiert
const [tableExists] = await sequelize.query(`
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = :schema
AND table_name = :tableName
) as exists
`, {
replacements: { schema, tableName },
type: sequelize.QueryTypes.SELECT
});
if (tableExists && tableExists.exists) {
console.log(` Table ${schema}.${tableName} already exists, skipping creation`);
continue;
}
// Erstelle die Tabelle manuell ohne Foreign Key
// Verwende queryInterface.createTable mit den Attributen, aber ohne Foreign Keys
const queryInterface = sequelize.getQueryInterface();
const attributes = {};
// Kopiere alle Attribute aus dem Model, aber entferne references
for (const [key, attr] of Object.entries(model.rawAttributes)) {
attributes[key] = { ...attr };
// Entferne references, damit kein Foreign Key erstellt wird
if (attributes[key].references) {
delete attributes[key].references;
}
}
// Erstelle die Tabelle mit queryInterface.createTable ohne Foreign Keys
await queryInterface.createTable(tableName, attributes, {
schema,
// Stelle sicher, dass keine Foreign Keys erstellt werden
charset: model.options?.charset,
collate: model.options?.collate
});
console.log(` ✅ Table ${schema}.${tableName} created successfully without Foreign Key`);
} catch (createError) {
console.error(` ❌ Failed to create table ${schema}.${tableName} without Foreign Key:`, createError.message);
console.error(` ⚠️ Skipping ${model.name} - will retry after dependencies are created`);
// Überspringe dieses Model und fahre mit dem nächsten fort
continue;
}
}
// Wenn Sequelize einen Foreign Key Constraint Fehler hat, entferne verwaiste Einträge oder überspringe das Model
else if (syncError.name === 'SequelizeForeignKeyConstraintError' || (syncError.message && (syncError.message.includes('FOREIGN KEY') || syncError.message.includes('Fremdschlüssel')))) {
const tableName = model.tableName;
const schema = model.options?.schema || 'public';
console.error(` ❌ Cannot sync ${model.name} (${schema}.${tableName}) due to Foreign Key Constraint Error`);
console.error(` ❌ Detail: ${syncError.parent?.detail || syncError.message}`);
console.error(` ⚠️ This usually means there are orphaned records. Cleanup should have removed them.`);
console.error(` ⚠️ Skipping sync for ${model.name} - please check and fix orphaned records manually`);
// Überspringe dieses Model und fahre mit dem nächsten fort
continue;
}
// Wenn Sequelize versucht, Foreign Keys zu erstellen, entferne sie nach dem Fehler
else if (syncError.message && syncError.message.includes('REFERENCES')) {
console.log(` ⚠️ Sequelize tried to create FK despite constraints: false, removing any created FKs...`);
try {
const tableName = model.tableName;
const schema = model.options?.schema || 'public';
const foreignKeys = await sequelize.query(`
SELECT tc.constraint_name
FROM information_schema.table_constraints AS tc
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_name = :tableName
AND tc.table_schema = :schema
`, {
replacements: { tableName, schema },
type: sequelize.QueryTypes.SELECT
});
if (foreignKeys && foreignKeys.length > 0) {
for (const fk of foreignKeys) {
await sequelize.query(`
ALTER TABLE "${schema}"."${tableName}"
DROP CONSTRAINT IF EXISTS "${fk.constraint_name}" CASCADE
`);
}
}
// Versuche Sync erneut ohne Foreign Keys
console.log(` 🔄 Retrying sync without foreign keys...`);
await model.sync({ alter: true, force: false, constraints: false });
} catch (retryError) {
console.error(` ❌ Retry failed:`, retryError.message);
console.error(` ❌ Original sync error:`, syncError.message);
// Kombiniere beide Fehler für besseres Debugging
const combinedError = new Error(`Sync failed: ${syncError.message}. Retry also failed: ${retryError.message}`);
combinedError.originalError = syncError;
combinedError.retryError = retryError;
throw combinedError;
}
} else {
throw syncError;
}
}
// Entferne alle Foreign Keys, die Sequelize möglicherweise trotzdem erstellt hat
try {
const tableName = model.tableName;
const schema = model.options?.schema || 'public';
const foreignKeys = await sequelize.query(`
SELECT tc.constraint_name
FROM information_schema.table_constraints AS tc
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_name = :tableName
AND tc.table_schema = :schema
`, {
replacements: { tableName, schema },
type: sequelize.QueryTypes.SELECT
});
if (foreignKeys && foreignKeys.length > 0) {
console.log(` ⚠️ Sequelize created ${foreignKeys.length} foreign keys despite constraints: false, removing them...`);
for (const fk of foreignKeys) {
await sequelize.query(`
ALTER TABLE "${schema}"."${tableName}"
DROP CONSTRAINT IF EXISTS "${fk.constraint_name}" CASCADE
`);
}
}
} catch (fkError) {
console.warn(` ⚠️ Could not check/remove foreign keys after sync:`, fkError.message);
}
} finally {
// Stelle die Associations wieder her (IMMER, auch bei Fehlern)
if (associationKeys.length > 0) {
console.log(` ✅ Restoring ${associationKeys.length} associations for ${model.name}`);
model.associations = originalAssociations;
}
// Restore VIRTUAL fields after sync
for (const [key, attr] of Object.entries(virtualFields)) {
model.rawAttributes[key] = attr;
}
}
} }
console.log('✅ Schema-Updates für alle Models abgeschlossen'); console.log('✅ Schema-Updates für alle Models abgeschlossen');
} catch (error) { } catch (error) {

View File

@@ -33,6 +33,123 @@ const syncDatabase = async () => {
console.log("Initializing database schemas..."); console.log("Initializing database schemas...");
await initializeDatabase(); await initializeDatabase();
// Vokabeltrainer: Tabellen sicherstellen (auch ohne manuell ausgeführte Migrations)
// Hintergrund: In Produktion sind Schema-Updates deaktiviert, und Migrations werden nicht automatisch ausgeführt.
// Damit API/Menu nicht mit "relation does not exist" (42P01) scheitert, legen wir die Tabellen idempotent an.
console.log("Ensuring Vocab-Trainer tables exist...");
try {
await sequelize.query(`
CREATE TABLE IF NOT EXISTS community.vocab_language (
id SERIAL PRIMARY KEY,
owner_user_id INTEGER NOT NULL,
name TEXT NOT NULL,
share_code TEXT NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT vocab_language_owner_fk
FOREIGN KEY (owner_user_id)
REFERENCES community."user"(id)
ON DELETE CASCADE,
CONSTRAINT vocab_language_share_code_uniq UNIQUE (share_code)
);
CREATE TABLE IF NOT EXISTS community.vocab_language_subscription (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
language_id INTEGER NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT vocab_language_subscription_user_fk
FOREIGN KEY (user_id)
REFERENCES community."user"(id)
ON DELETE CASCADE,
CONSTRAINT vocab_language_subscription_language_fk
FOREIGN KEY (language_id)
REFERENCES community.vocab_language(id)
ON DELETE CASCADE,
CONSTRAINT vocab_language_subscription_uniq UNIQUE (user_id, language_id)
);
CREATE INDEX IF NOT EXISTS vocab_language_owner_idx
ON community.vocab_language(owner_user_id);
CREATE INDEX IF NOT EXISTS vocab_language_subscription_user_idx
ON community.vocab_language_subscription(user_id);
CREATE INDEX IF NOT EXISTS vocab_language_subscription_language_idx
ON community.vocab_language_subscription(language_id);
CREATE TABLE IF NOT EXISTS community.vocab_chapter (
id SERIAL PRIMARY KEY,
language_id INTEGER NOT NULL,
title TEXT NOT NULL,
created_by_user_id INTEGER NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT vocab_chapter_language_fk
FOREIGN KEY (language_id)
REFERENCES community.vocab_language(id)
ON DELETE CASCADE,
CONSTRAINT vocab_chapter_creator_fk
FOREIGN KEY (created_by_user_id)
REFERENCES community."user"(id)
ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS vocab_chapter_language_idx
ON community.vocab_chapter(language_id);
CREATE TABLE IF NOT EXISTS community.vocab_lexeme (
id SERIAL PRIMARY KEY,
language_id INTEGER NOT NULL,
text TEXT NOT NULL,
normalized TEXT NOT NULL,
created_by_user_id INTEGER NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT vocab_lexeme_language_fk
FOREIGN KEY (language_id)
REFERENCES community.vocab_language(id)
ON DELETE CASCADE,
CONSTRAINT vocab_lexeme_creator_fk
FOREIGN KEY (created_by_user_id)
REFERENCES community."user"(id)
ON DELETE CASCADE,
CONSTRAINT vocab_lexeme_unique_per_language UNIQUE (language_id, normalized)
);
CREATE INDEX IF NOT EXISTS vocab_lexeme_language_idx
ON community.vocab_lexeme(language_id);
CREATE TABLE IF NOT EXISTS community.vocab_chapter_lexeme (
id SERIAL PRIMARY KEY,
chapter_id INTEGER NOT NULL,
learning_lexeme_id INTEGER NOT NULL,
reference_lexeme_id INTEGER NOT NULL,
created_by_user_id INTEGER NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT vocab_chlex_chapter_fk
FOREIGN KEY (chapter_id)
REFERENCES community.vocab_chapter(id)
ON DELETE CASCADE,
CONSTRAINT vocab_chlex_learning_fk
FOREIGN KEY (learning_lexeme_id)
REFERENCES community.vocab_lexeme(id)
ON DELETE CASCADE,
CONSTRAINT vocab_chlex_reference_fk
FOREIGN KEY (reference_lexeme_id)
REFERENCES community.vocab_lexeme(id)
ON DELETE CASCADE,
CONSTRAINT vocab_chlex_creator_fk
FOREIGN KEY (created_by_user_id)
REFERENCES community."user"(id)
ON DELETE CASCADE,
CONSTRAINT vocab_chlex_unique UNIQUE (chapter_id, learning_lexeme_id, reference_lexeme_id)
);
CREATE INDEX IF NOT EXISTS vocab_chlex_chapter_idx
ON community.vocab_chapter_lexeme(chapter_id);
CREATE INDEX IF NOT EXISTS vocab_chlex_learning_idx
ON community.vocab_chapter_lexeme(learning_lexeme_id);
CREATE INDEX IF NOT EXISTS vocab_chlex_reference_idx
ON community.vocab_chapter_lexeme(reference_lexeme_id);
`);
console.log("✅ Vocab-Trainer Tabellen sind vorhanden.");
} catch (e) {
console.warn('⚠️ Konnte Vocab-Trainer Tabellen nicht sicherstellen:', e?.message || e);
}
// Vorab: Stelle kritische Spalten sicher, damit Index-Erstellung nicht fehlschlägt // Vorab: Stelle kritische Spalten sicher, damit Index-Erstellung nicht fehlschlägt
console.log("Pre-ensure Taxi columns (traffic_light) ..."); console.log("Pre-ensure Taxi columns (traffic_light) ...");
try { try {
@@ -54,6 +171,112 @@ const syncDatabase = async () => {
console.warn('⚠️ Konnte traffic_light-Spalte nicht vorab sicherstellen:', e?.message || e); console.warn('⚠️ Konnte traffic_light-Spalte nicht vorab sicherstellen:', e?.message || e);
} }
// Cleanup: Entferne verwaiste Einträge vor Schema-Updates (nur wenn Schema-Updates aktiviert)
if (currentStage === 'dev') {
console.log("Cleaning up orphaned entries...");
try {
// Cleanup user_param_visibility
const result1 = await sequelize.query(`
DELETE FROM community.user_param_visibility
WHERE param_id NOT IN (
SELECT id FROM community.user_param
);
`);
const deletedCount1 = result1[1] || 0;
if (deletedCount1 > 0) {
console.log(`${deletedCount1} verwaiste user_param_visibility Einträge entfernt`);
}
// Cleanup stock mit ungültigen branch_id (0 oder nicht existierend)
const result2 = await sequelize.query(`
DELETE FROM falukant_data.stock
WHERE branch_id = 0 OR branch_id NOT IN (
SELECT id FROM falukant_data.branch
);
`);
const deletedCount2 = result2[1] || 0;
if (deletedCount2 > 0) {
console.log(`${deletedCount2} verwaiste stock Einträge entfernt`);
}
// Cleanup knowledge mit ungültigen character_id oder product_id
const result3 = await sequelize.query(`
DELETE FROM falukant_data.knowledge
WHERE character_id NOT IN (
SELECT id FROM falukant_data.character
) OR product_id NOT IN (
SELECT id FROM falukant_type.product
);
`);
const deletedCount3 = result3[1] || 0;
if (deletedCount3 > 0) {
console.log(`${deletedCount3} verwaiste knowledge Einträge entfernt`);
}
// Cleanup notification mit ungültigen user_id
const result4 = await sequelize.query(`
DELETE FROM falukant_log.notification
WHERE user_id NOT IN (
SELECT id FROM falukant_data.falukant_user
);
`);
const deletedCount4 = result4[1] || 0;
if (deletedCount4 > 0) {
console.log(`${deletedCount4} verwaiste notification Einträge entfernt`);
}
// Cleanup promotional_gift mit ungültigen sender_character_id oder recipient_character_id
const result5 = await sequelize.query(`
DELETE FROM falukant_log.promotional_gift
WHERE sender_character_id NOT IN (
SELECT id FROM falukant_data.character
) OR recipient_character_id NOT IN (
SELECT id FROM falukant_data.character
);
`);
const deletedCount5 = result5[1] || 0;
if (deletedCount5 > 0) {
console.log(`${deletedCount5} verwaiste promotional_gift Einträge entfernt`);
}
// Cleanup user_house mit ungültigen house_type_id oder user_id
const result6 = await sequelize.query(`
DELETE FROM falukant_data.user_house
WHERE house_type_id NOT IN (
SELECT id FROM falukant_type.house
) OR user_id NOT IN (
SELECT id FROM falukant_data.falukant_user
);
`);
const deletedCount6 = result6[1] || 0;
if (deletedCount6 > 0) {
console.log(`${deletedCount6} verwaiste user_house Einträge entfernt`);
}
// Cleanup child_relation mit ungültigen father_character_id, mother_character_id oder child_character_id
const result7 = await sequelize.query(`
DELETE FROM falukant_data.child_relation
WHERE father_character_id NOT IN (
SELECT id FROM falukant_data.character
) OR mother_character_id NOT IN (
SELECT id FROM falukant_data.character
) OR child_character_id NOT IN (
SELECT id FROM falukant_data.character
);
`);
const deletedCount7 = result7[1] || 0;
if (deletedCount7 > 0) {
console.log(`${deletedCount7} verwaiste child_relation Einträge entfernt`);
}
if (deletedCount1 === 0 && deletedCount2 === 0 && deletedCount3 === 0 && deletedCount4 === 0 && deletedCount5 === 0 && deletedCount6 === 0 && deletedCount7 === 0) {
console.log("✅ Keine verwaisten Einträge gefunden");
}
} catch (e) {
console.warn('⚠️ Konnte verwaiste Einträge nicht bereinigen:', e?.message || e);
}
}
console.log("Setting up associations..."); console.log("Setting up associations...");
setupAssociations(); setupAssociations();
@@ -104,6 +327,10 @@ const syncDatabase = async () => {
// Deployment-Synchronisation (immer Schema-Updates) // Deployment-Synchronisation (immer Schema-Updates)
const syncDatabaseForDeployment = async () => { const syncDatabaseForDeployment = async () => {
try { try {
// WICHTIG: Bei Caching-Problemen das Script neu starten
// Node.js cached ES-Module, daher müssen Models neu geladen werden
console.log('📦 Lade Models neu (Node.js Module-Cache wird verwendet)...');
// Zeige den aktuellen Stage an // Zeige den aktuellen Stage an
const currentStage = process.env.STAGE || 'nicht gesetzt'; const currentStage = process.env.STAGE || 'nicht gesetzt';
console.log(`🚀 Starte Datenbank-Synchronisation für Deployment (Stage: ${currentStage})`); console.log(`🚀 Starte Datenbank-Synchronisation für Deployment (Stage: ${currentStage})`);
@@ -133,6 +360,185 @@ const syncDatabaseForDeployment = async () => {
console.warn('⚠️ Konnte traffic_light-Spalte nicht vorab sicherstellen:', e?.message || e); console.warn('⚠️ Konnte traffic_light-Spalte nicht vorab sicherstellen:', e?.message || e);
} }
// Migration: Transport product_id und size nullable machen
console.log("Making transport product_id and size nullable...");
try {
await sequelize.query(`
DO $$
BEGIN
-- Prüfe ob product_id NOT NULL Constraint existiert
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'falukant_data'
AND table_name = 'transport'
AND column_name = 'product_id'
AND is_nullable = 'NO'
) THEN
ALTER TABLE falukant_data.transport
ALTER COLUMN product_id DROP NOT NULL;
RAISE NOTICE 'product_id NOT NULL Constraint entfernt';
END IF;
-- Prüfe ob size NOT NULL Constraint existiert
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'falukant_data'
AND table_name = 'transport'
AND column_name = 'size'
AND is_nullable = 'NO'
) THEN
ALTER TABLE falukant_data.transport
ALTER COLUMN size DROP NOT NULL;
RAISE NOTICE 'size NOT NULL Constraint entfernt';
END IF;
END
$$;
`);
console.log("✅ Transport product_id und size sind jetzt nullable");
} catch (e) {
console.warn('⚠️ Konnte Transport-Spalten nicht nullable machen:', e?.message || e);
}
// Cleanup: Entferne verwaiste Einträge vor Schema-Updates
console.log("Cleaning up orphaned entries...");
try {
// Cleanup user_param_visibility
const result1 = await sequelize.query(`
DELETE FROM community.user_param_visibility
WHERE param_id NOT IN (
SELECT id FROM community.user_param
);
`);
const deletedCount1 = result1[1] || 0;
if (deletedCount1 > 0) {
console.log(`${deletedCount1} verwaiste user_param_visibility Einträge entfernt`);
}
// Cleanup stock mit ungültigen branch_id (0 oder nicht existierend)
const result2 = await sequelize.query(`
DELETE FROM falukant_data.stock
WHERE branch_id = 0 OR branch_id NOT IN (
SELECT id FROM falukant_data.branch
);
`);
const deletedCount2 = result2[1] || 0;
if (deletedCount2 > 0) {
console.log(`${deletedCount2} verwaiste stock Einträge entfernt`);
}
// Cleanup knowledge mit ungültigen character_id oder product_id
const result3 = await sequelize.query(`
DELETE FROM falukant_data.knowledge
WHERE character_id NOT IN (
SELECT id FROM falukant_data.character
) OR product_id NOT IN (
SELECT id FROM falukant_type.product
);
`);
const deletedCount3 = result3[1] || 0;
if (deletedCount3 > 0) {
console.log(`${deletedCount3} verwaiste knowledge Einträge entfernt`);
}
// Cleanup notification mit ungültigen user_id
const result4 = await sequelize.query(`
DELETE FROM falukant_log.notification
WHERE user_id NOT IN (
SELECT id FROM falukant_data.falukant_user
);
`);
const deletedCount4 = result4[1] || 0;
if (deletedCount4 > 0) {
console.log(`${deletedCount4} verwaiste notification Einträge entfernt`);
}
// Cleanup promotional_gift mit ungültigen sender_character_id oder recipient_character_id
const result5 = await sequelize.query(`
DELETE FROM falukant_log.promotional_gift
WHERE sender_character_id NOT IN (
SELECT id FROM falukant_data.character
) OR recipient_character_id NOT IN (
SELECT id FROM falukant_data.character
);
`);
const deletedCount5 = result5[1] || 0;
if (deletedCount5 > 0) {
console.log(`${deletedCount5} verwaiste promotional_gift Einträge entfernt`);
}
// Cleanup user_house mit ungültigen house_type_id oder user_id
const result6 = await sequelize.query(`
DELETE FROM falukant_data.user_house
WHERE house_type_id NOT IN (
SELECT id FROM falukant_type.house
) OR user_id NOT IN (
SELECT id FROM falukant_data.falukant_user
);
`);
const deletedCount6 = result6[1] || 0;
if (deletedCount6 > 0) {
console.log(`${deletedCount6} verwaiste user_house Einträge entfernt`);
}
// Cleanup child_relation mit ungültigen father_character_id, mother_character_id oder child_character_id
const result7 = await sequelize.query(`
DELETE FROM falukant_data.child_relation
WHERE father_character_id NOT IN (
SELECT id FROM falukant_data.character
) OR mother_character_id NOT IN (
SELECT id FROM falukant_data.character
) OR child_character_id NOT IN (
SELECT id FROM falukant_data.character
);
`);
const deletedCount7 = result7[1] || 0;
if (deletedCount7 > 0) {
console.log(`${deletedCount7} verwaiste child_relation Einträge entfernt`);
}
// Cleanup political_office mit ungültigen character_id, office_type_id oder region_id
const result8 = await sequelize.query(`
DELETE FROM falukant_data.political_office
WHERE character_id NOT IN (
SELECT id FROM falukant_data.character
) OR office_type_id NOT IN (
SELECT id FROM falukant_type.political_office_type
) OR region_id NOT IN (
SELECT id FROM falukant_data.region
);
`);
const deletedCount8 = result8[1] || 0;
if (deletedCount8 > 0) {
console.log(`${deletedCount8} verwaiste political_office Einträge entfernt`);
}
// Cleanup vehicle.condition: Legacy-Nulls + Range clamp (UI zeigt sonst "Unbekannt")
const result9 = await sequelize.query(`
UPDATE falukant_data.vehicle
SET condition = 100
WHERE condition IS NULL;
`);
const updatedNullConditions = result9[1] || 0;
if (updatedNullConditions > 0) {
console.log(`${updatedNullConditions} vehicle.condition NULL → 100 gesetzt`);
}
const result10 = await sequelize.query(`
UPDATE falukant_data.vehicle
SET condition = GREATEST(0, LEAST(100, condition))
WHERE condition < 0 OR condition > 100;
`);
const clampedConditions = result10[1] || 0;
if (clampedConditions > 0) {
console.log(`${clampedConditions} vehicle.condition Werte auf 0..100 geklemmt`);
}
if (deletedCount1 === 0 && deletedCount2 === 0 && deletedCount3 === 0 && deletedCount4 === 0 && deletedCount5 === 0 && deletedCount6 === 0 && deletedCount7 === 0 && deletedCount8 === 0 && updatedNullConditions === 0 && clampedConditions === 0) {
console.log("✅ Keine verwaisten Einträge gefunden");
}
} catch (e) {
console.warn('⚠️ Konnte verwaiste Einträge nicht bereinigen:', e?.message || e);
}
console.log("Setting up associations..."); console.log("Setting up associations...");
setupAssociations(); setupAssociations();

1
deploy Symbolic link
View File

@@ -0,0 +1 @@
/opt/yourpart

View File

@@ -26,6 +26,7 @@ fi
# 5. Frontend neu bauen VITE_* aus Environment übernehmen oder Defaults setzen # 5. Frontend neu bauen VITE_* aus Environment übernehmen oder Defaults setzen
echo "Baue Frontend neu..." echo "Baue Frontend neu..."
export VITE_API_BASE_URL=${VITE_API_BASE_URL:-https://www.your-part.de} export VITE_API_BASE_URL=${VITE_API_BASE_URL:-https://www.your-part.de}
# Standard: Daemon direkt auf Port 4551, nicht über Apache-Proxy
export VITE_DAEMON_SOCKET=${VITE_DAEMON_SOCKET:-wss://www.your-part.de:4551} export VITE_DAEMON_SOCKET=${VITE_DAEMON_SOCKET:-wss://www.your-part.de:4551}
export VITE_CHAT_WS_URL=${VITE_CHAT_WS_URL:-wss://www.your-part.de:1235} export VITE_CHAT_WS_URL=${VITE_CHAT_WS_URL:-wss://www.your-part.de:1235}

142
deploy-with-config.sh Executable file
View File

@@ -0,0 +1,142 @@
#!/bin/bash
echo "=== YourPart Deployment mit vorhandener Konfiguration ==="
# Prüfen ob wir im richtigen Verzeichnis sind
if [ ! -f "package.json" ]; then
echo "Error: Bitte führen Sie dieses Skript aus dem YourPart3-Root-Verzeichnis aus"
exit 1
fi
# Frontend bauen
echo "Building frontend..."
cd frontend
npm ci
npm run build
cd ..
# Verzeichnisse erstellen
echo "Creating directories..."
sudo mkdir -p /opt/yourpart/{frontend,backend,images/{tmp,userimages,screenshots}}
# Frontend kopieren
echo "Deploying frontend..."
sudo cp -r frontend/dist /opt/yourpart/frontend/
sudo chown -R www-data:www-data /opt/yourpart/frontend
sudo chmod -R 755 /opt/yourpart/frontend
# Backend kopieren
echo "Deploying backend..."
sudo cp -r backend/* /opt/yourpart/backend/
sudo chown -R www-data:www-data /opt/yourpart/backend
sudo chmod -R 755 /opt/yourpart/backend
# .env-Datei erstellen
echo "Creating .env file..."
cat > /opt/yourpart/backend/.env << 'ENVEOF'
# Datenbank-Konfiguration (aus alter App)
DB_HOST=localhost
DB_USER=yourpart
DB_PASS=hitomisan
DB_NAME=yp2
DB_PORT=60000
# Anwendungskonfiguration
NODE_ENV=production
PORT=2020
STAGE=production
# Redis-Konfiguration
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASS=your_redis_password
# Session-Konfiguration (aus alter App)
SECRET_KEY=k7e0CCw75PcmEGa
SESSION_SECRET=k7e0CCw75PcmEGa
# E-Mail-Konfiguration (aus alter App)
SMTP_HOST=smtp.1blu.de
SMTP_PORT=465
SMTP_USER=e226079_0-tsschulz
SMTP_PASS=hitomisan
SMTP_SECURE=true
# E-Mail-Einstellungen (aus alter App)
SENDER_NAME=YourPart
SENDER_EMAIL=kontakt@your-part.de
# AMQP-Konfiguration (aus alter App)
AMQP_HOST=tsschulz.de
AMQP_PORT=5672
AMQP_EXCHANGE=yourpart
AMQP_USERNAME=yourpart
AMQP_PASSWORD=yourpart
# API-Keys (aus alter App)
WEATHER_API_KEY=d0ddfcbc915f50263274211648a5dab0
NEWS_API_KEY=pub_212733602779de7708a7374d67e363bd06af4
# Pfad-Konfiguration (aus alter App)
ROOT_PATH=/opt/yourpart
IMAGES_PATH=/images
TMP_IMAGES_PATH=/images/tmp
USER_IMAGES_PATH=/images/userimages
SCREENSHOT_IMAGES_PATH=/images/screenshots
# URL-Konfiguration
BASE_URL=https://www.your-part.de
IMAGES_URL=https://www.your-part.de/images/
ACTIVATION_URL=https://www.your-part.de/activate?code=
PASSWORD_RESET_URL=https://www.your-part.de/setnewpassword?username=
# Spiel-Konfiguration (aus alter App)
START_BUDGET=10
TEST_MODE=false
PAY_PER_DAY=100
# Debug-Einstellungen
DEBUG_SQL=false
DEBUG_MESSAGES=false
DEBUG_RECREATE_DB=false
ENVEOF
sudo chown www-data:www-data /opt/yourpart/backend/.env
sudo chmod 600 /opt/yourpart/backend/.env
# Service installieren
echo "Installing service..."
sudo cp yourpart.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable yourpart.service
# Apache-Konfiguration
echo "Configuring Apache..."
sudo cp yourpart-http.conf /etc/apache2/sites-available/
sudo cp yourpart-https.conf /etc/apache2/sites-available/
# Alte Konfiguration deaktivieren
sudo a2dissite yourpart 2>/dev/null || true
# Neue Konfigurationen aktivieren
sudo a2ensite yourpart-http
sudo a2ensite yourpart-https
# Apache-Module aktivieren
sudo a2enmod proxy proxy_http proxy_wstunnel rewrite ssl
sudo systemctl reload apache2
# Service starten
echo "Starting service..."
sudo systemctl start yourpart.service
echo ""
echo "=== Deployment abgeschlossen! ==="
echo "Frontend: /opt/yourpart/frontend/dist/"
echo "Backend: /opt/yourpart/backend/"
echo "Service: yourpart.service"
echo ""
echo "Status prüfen:"
echo " sudo systemctl status yourpart.service"
echo " sudo systemctl status apache2"
echo " sudo journalctl -u yourpart.service -f"

View File

@@ -0,0 +1,143 @@
# 3D-Animationen im Falukant-Bereich
## Benötigte Dependencies
### Three.js (Empfohlen)
```bash
npm install three
npm install @types/three --save-dev # Für TypeScript-Support
```
**Alternative Optionen:**
- **Babylon.js**: Mächtiger, aber größer (~500KB vs ~600KB)
- **A-Frame**: WebVR-fokussiert, einfacher für VR/AR
- **React Three Fiber**: Falls React verwendet wird (hier Vue)
**Empfehlung: Three.js** - am weitesten verbreitet, beste Dokumentation, große Community
### Optional: Vue-Three.js Wrapper
```bash
npm install vue-threejs # Oder troika-three-text für Text-Rendering
```
## Sinnvolle Seiten für 3D-Animationen
### 1. **OverviewView** (Hauptübersicht)
**Sinnvoll:** ⭐⭐⭐⭐⭐
- **3D-Charakter-Modell**: Rotierendes 3D-Modell des eigenen Charakters
- **Statussymbole**: 3D-Icons für Geld, Gesundheit, Reputation (schwebend/rotierend)
- **Hintergrund**: Subtile 3D-Szene (z.B. mittelalterliche Stadt im Hintergrund)
### 2. **HouseView** (Haus)
**Sinnvoll:** ⭐⭐⭐⭐⭐
- **3D-Haus-Modell**: Interaktives 3D-Modell des eigenen Hauses
- **Upgrade-Visualisierung**: Animation beim Haus-Upgrade
- **Zustand-Anzeige**: 3D-Visualisierung von Dach, Wänden, Boden, Fenstern
### 3. **BranchView** (Niederlassungen)
**Sinnvoll:** ⭐⭐⭐⭐
- **3D-Fabrik/Gebäude**: 3D-Modell der Niederlassung
- **Produktions-Animation**: 3D-Animationen für laufende Produktionen
- **Transport-Visualisierung**: 3D-Wagen/Karren für Transporte
### 4. **FamilyView** (Familie)
**Sinnvoll:** ⭐⭐⭐⭐
- **3D-Charaktere**: 3D-Modelle von Partner und Kindern
- **Beziehungs-Visualisierung**: 3D-Animationen für Beziehungsstatus
- **Geschenk-Animation**: 3D-Animation beim Verschenken
### 5. **HealthView** (Gesundheit)
**Sinnvoll:** ⭐⭐⭐
- **3D-Körper-Modell**: 3D-Visualisierung des Gesundheitszustands
- **Aktivitäts-Animationen**: 3D-Animationen für Gesundheitsaktivitäten
### 6. **NobilityView** (Sozialstatus)
**Sinnvoll:** ⭐⭐⭐
- **3D-Wappen**: Rotierendes 3D-Wappen
- **Insignien**: 3D-Krone, Schwert, etc. je nach Titel
### 7. **ChurchView** (Kirche)
**Sinnvoll:** ⭐⭐⭐
- **3D-Kirche**: 3D-Modell der Kirche
- **Taufe-Animation**: 3D-Animation bei der Taufe
### 8. **BankView** (Bank)
**Sinnvoll:** ⭐⭐
- **3D-Bankgebäude**: 3D-Modell der Bank
- **Geld-Animation**: 3D-Münzen/Geldstapel
### 9. **UndergroundView** (Untergrund)
**Sinnvoll:** ⭐⭐⭐⭐
- **3D-Dungeon**: 3D-Untergrund-Visualisierung
- **Aktivitäts-Animationen**: 3D-Animationen für Untergrund-Aktivitäten
### 10. **ReputationView** (Reputation)
**Sinnvoll:** ⭐⭐⭐
- **3D-Party-Szene**: 3D-Visualisierung von Festen
- **Reputation-Visualisierung**: 3D-Effekte für Reputationsänderungen
## Implementierungs-Strategie
### Phase 1: Basis-Setup
1. Three.js installieren
2. Basis-Komponente `ThreeScene.vue` erstellen
3. Erste einfache Animation (z.B. rotierender Würfel) auf OverviewView
### Phase 2: Charakter-Modell
1. 3D-Charakter-Modell erstellen/laden (GLTF/GLB)
2. Auf OverviewView integrieren
3. Interaktionen (Klick, Hover)
### Phase 3: Gebäude-Modelle
1. Haus-Modell für HouseView
2. Fabrik-Modell für BranchView
3. Kirche-Modell für ChurchView
### Phase 4: Animationen
1. Upgrade-Animationen
2. Status-Änderungs-Animationen
3. Interaktive Elemente
## Technische Überlegungen
### Performance
- **Lazy Loading**: 3D-Szenen nur laden, wenn Seite aktiv ist
- **Level of Detail (LOD)**: Einfache Modelle für schwächere Geräte
- **WebGL-Detection**: Fallback auf 2D, wenn WebGL nicht unterstützt wird
### Asset-Management
- **GLTF/GLB**: Kompaktes Format für 3D-Modelle
- **Texturen**: Optimiert für Web (WebP, komprimiert)
- **CDN**: Assets über CDN laden für bessere Performance
### Browser-Kompatibilität
- **WebGL 1.0**: Mindestanforderung (95%+ Browser)
- **WebGL 2.0**: Optional für bessere Features
- **Fallback**: 2D-Versionen für ältere Browser
## Beispiel-Struktur
```
frontend/src/
components/
falukant/
ThreeScene.vue # Basis-3D-Szene-Komponente
CharacterModel.vue # 3D-Charakter-Komponente
BuildingModel.vue # 3D-Gebäude-Komponente
assets/
3d/
models/
character.glb
house.glb
factory.glb
textures/
...
```
## Nächste Schritte
1. **Three.js installieren**
2. **Basis-Komponente erstellen**
3. **Erste Animation auf OverviewView testen**
4. **3D-Modelle erstellen/beschaffen** (Blender, Sketchfab, etc.)
5. **Schrittweise auf weitere Seiten ausweiten**

171
docs/3D_ASSETS_STRUCTURE.md Normal file
View File

@@ -0,0 +1,171 @@
# 3D-Assets Struktur für Falukant
## Verzeichnisstruktur
```
frontend/public/
models/
3d/
falukant/
characters/
male.glb # Basis-Modell männlich
female.glb # Basis-Modell weiblich
male_child.glb # Männlich, Kind (0-9 Jahre)
male_teen.glb # Männlich, Teenager (10-17 Jahre)
male_adult.glb # Männlich, Erwachsen (18-39 Jahre)
male_middle.glb # Männlich, Mittelalter (40-59 Jahre)
male_elder.glb # Männlich, Älter (60+ Jahre)
female_child.glb # Weiblich, Kind
female_teen.glb # Weiblich, Teenager
female_adult.glb # Weiblich, Erwachsen
female_middle.glb # Weiblich, Mittelalter
female_elder.glb # Weiblich, Älter
buildings/
house/
house_small.glb # Kleines Haus
house_medium.glb # Mittleres Haus
house_large.glb # Großes Haus
factory/
factory_basic.glb # Basis-Fabrik
factory_advanced.glb # Erweiterte Fabrik
church/
church.glb # Kirche
bank/
bank.glb # Bank
objects/
weapons/
sword.glb
shield.glb
items/
coin.glb
gift.glb
effects/
particles/
money.glb # Geld-Effekt
health.glb # Gesundheits-Effekt
```
## Namenskonventionen
### Charaktere
- Format: `{gender}[_{ageRange}].glb`
- Beispiele:
- `male.glb` - Basis-Modell männlich (Fallback)
- `female.glb` - Basis-Modell weiblich (Fallback)
- `male_adult.glb` - Männlich, Erwachsen
- `female_teen.glb` - Weiblich, Teenager
### Gebäude
- Format: `{buildingType}_{variant}.glb`
- Beispiele:
- `house_small.glb`
- `factory_basic.glb`
- `church.glb`
### Objekte
- Format: `{category}/{item}.glb`
- Beispiele:
- `weapons/sword.glb`
- `items/coin.glb`
## Altersbereiche
Die Altersbereiche werden automatisch bestimmt:
```javascript
// In CharacterModel3D.vue
getAgeRange(age) {
if (age < 10) return 'child';
if (age < 18) return 'teen';
if (age < 40) return 'adult';
if (age < 60) return 'middle';
return 'elder';
}
```
**Fallback-Verhalten:**
- Wenn kein spezifisches Modell für den Altersbereich existiert, wird das Basis-Modell (`male.glb` / `female.glb`) verwendet
- Dies ermöglicht schrittweise Erweiterung ohne Breaking Changes
## Dateigrößen-Empfehlungen
- **Charaktere**: 100KB - 500KB (komprimiert)
- **Gebäude**: 200KB - 1MB (komprimiert)
- **Objekte**: 10KB - 100KB (komprimiert)
## Optimierung
### Vor dem Hochladen:
1. **Blender** öffnen
2. **Decimate Modifier** anwenden (falls nötig)
3. **Texturen komprimieren** (WebP, max 1024x1024)
4. **GLB Export** mit:
- Compression aktiviert
- Texturen eingebettet
- Unnötige Animationen entfernt
### Komprimierung:
- Verwende `gltf-pipeline` oder `gltf-transform` für weitere Komprimierung
- Ziel: < 500KB pro Modell
## Verwendung im Code
```vue
<!-- CharacterModel3D.vue -->
<CharacterModel3D
:gender="character.gender"
:age="character.age"
/>
<!-- Automatisch wird geladen: -->
<!-- /models/3d/falukant/characters/male_adult.glb -->
<!-- Falls nicht vorhanden: male.glb -->
```
## Erweiterte Struktur (Optional)
Für komplexere Szenarien:
```
frontend/public/
models/
3d/
falukant/
characters/
{gender}/
base/
{gender}.glb # Basis-Modell
ages/
{gender}_{ageRange}.glb
variants/
{gender}_{variant}.glb # Z.B. verschiedene Outfits
```
## Wartung
### Neue Modelle hinzufügen:
1. GLB-Datei in entsprechendes Verzeichnis kopieren
2. Namenskonvention beachten
3. Dateigröße prüfen (< 500KB empfohlen)
4. Im Browser testen
### Modelle aktualisieren:
1. Alte Datei ersetzen
2. Browser-Cache leeren (oder Versionierung verwenden)
3. Testen
### Versionierung (Optional):
```
characters/
v1/
male.glb
v2/
male.glb
```
## Performance-Tipps
1. **Lazy Loading**: Modelle nur laden, wenn benötigt
2. **Preloading**: Wichtige Modelle vorladen
3. **Caching**: Browser-Cache nutzen
4. **CDN**: Für Produktion CDN verwenden

View File

@@ -0,0 +1,159 @@
# 3D-Modell-Erstellung für Falukant
## KI-basierte Tools (Empfohlen)
### 1. **Rodin** ⭐⭐⭐⭐⭐
- **URL**: https://rodin.io/
- **Preis**: Kostenlos (mit Limits), Premium verfügbar
- **Features**:
- Text-zu-3D (z.B. "medieval character", "house")
- Sehr gute Qualität
- Export als GLB/GLTF
- **Gut für**: Charaktere, Gebäude, Objekte
### 2. **Meshy** ⭐⭐⭐⭐⭐
- **URL**: https://www.meshy.ai/
- **Preis**: Kostenlos (mit Limits), ab $9/monat
- **Features**:
- Text-zu-3D
- Bild-zu-3D
- Textur-Generierung
- Export als GLB/OBJ/FBX
- **Gut für**: Alle Arten von Modellen
### 3. **Luma AI Genie** ⭐⭐⭐⭐
- **URL**: https://lumalabs.ai/genie
- **Preis**: Kostenlos (Beta)
- **Features**:
- Text-zu-3D
- Sehr schnell
- Export als GLB
- **Gut für**: Schnelle Prototypen
### 4. **CSM (Common Sense Machines)** ⭐⭐⭐⭐
- **URL**: https://csm.ai/
- **Preis**: Kostenlos (mit Limits)
- **Features**:
- Text-zu-3D
- Bild-zu-3D
- Export als GLB/USD
- **Gut für**: Verschiedene Objekte
### 5. **Tripo AI** ⭐⭐⭐⭐
- **URL**: https://www.tripo3d.ai/
- **Preis**: Kostenlos (mit Limits), Premium verfügbar
- **Features**:
- Text-zu-3D
- Bild-zu-3D
- Export als GLB/FBX/OBJ
- **Gut für**: Charaktere und Objekte
### 6. **Masterpiece Studio** ⭐⭐⭐
- **URL**: https://masterpiecestudio.com/
- **Preis**: Ab $9/monat
- **Features**:
- Text-zu-3D
- VR-Unterstützung
- Export als GLB/FBX
- **Gut für**: Professionelle Modelle
## Traditionelle Tools (Für Nachbearbeitung)
### 1. **Blender** (Kostenlos) ⭐⭐⭐⭐⭐
- **URL**: https://www.blender.org/
- **Features**:
- Vollständige 3D-Suite
- GLB/GLTF Export
- Optimierung von KI-generierten Modellen
- **Gut für**: Nachbearbeitung, Optimierung, Animationen
### 2. **Sketchfab** (Modelle kaufen/laden)
- **URL**: https://sketchfab.com/
- **Preis**: Kostenlos (CC0 Modelle), Premium Modelle kostenpflichtig
- **Features**:
- Millionen von 3D-Modellen
- Viele kostenlose CC0 Modelle
- GLB/GLTF Download
- **Gut für**: Vorgefertigte Modelle, Inspiration
## Empfohlener Workflow
### Für Falukant-Charaktere:
1. **Rodin** oder **Meshy** verwenden
2. Prompt: "medieval character, male/female, simple style, low poly, game ready"
3. Export als GLB
4. In **Blender** optimieren (falls nötig)
5. Texturen anpassen
### Für Gebäude:
1. **Meshy** oder **Tripo AI** verwenden
2. Prompt: "medieval house, simple, low poly, game ready, front view"
3. Export als GLB
4. In **Blender** optimieren
5. Mehrere Varianten erstellen (Haus, Fabrik, Kirche)
### Für Objekte:
1. **Sketchfab** durchsuchen (kostenlose CC0 Modelle)
2. Oder **Meshy** für spezifische Objekte
3. Export als GLB
4. Optimieren falls nötig
## Prompt-Beispiele für Falukant
### Charakter:
```
"medieval character, [male/female], simple low poly style,
game ready, neutral pose, front view, no background,
GLB format, optimized for web"
```
### Haus:
```
"medieval house, simple low poly style, game ready,
front view, no background, GLB format, optimized for web"
```
### Fabrik:
```
"medieval factory building, simple low poly style,
game ready, front view, no background, GLB format"
```
### Wappen:
```
"medieval coat of arms shield, simple low poly style,
game ready, front view, no background, GLB format"
```
## Optimierung für Web
### Nach der Erstellung:
1. **Blender** öffnen
2. **Decimate Modifier** anwenden (weniger Polygone)
3. **Texture** komprimieren (WebP, 512x512 oder 1024x1024)
4. **GLB Export** mit:
- Compression aktiviert
- Texturen eingebettet
- Normals und Tangents berechnet
### Größen-Richtlinien:
- **Charaktere**: 2000-5000 Polygone
- **Gebäude**: 1000-3000 Polygone
- **Objekte**: 100-1000 Polygone
- **Texturen**: 512x512 oder 1024x1024 (nicht größer)
## Kostenlose Alternativen
### Wenn KI-Tools Limits haben:
1. **Sketchfab** durchsuchen (CC0 Modelle)
2. **Poly Haven** (https://polyhaven.com/) - kostenlose Assets
3. **Kenney.nl** - kostenlose Game Assets
4. **OpenGameArt.org** - kostenlose Game Assets
## Nächste Schritte
1. **Rodin** oder **Meshy** testen
2. Ersten Charakter erstellen
3. Als GLB exportieren
4. In Three.js testen
5. Bei Bedarf optimieren

View File

@@ -0,0 +1,334 @@
# Blender Rigging-Anleitung für Falukant-Charaktere
Diese Anleitung erklärt, wie du Bones/Gelenke zu deinen 3D-Modellen in Blender hinzufügst, damit sie animiert werden können.
## Voraussetzungen
- Blender (kostenlos, https://www.blender.org/)
- GLB-Modell von meshy.ai oder anderen Quellen
## Schritt-für-Schritt Anleitung
### 1. Modell in Blender importieren
1. Öffne Blender
2. Gehe zu `File``Import``glTF 2.0 (.glb/.gltf)`
3. Wähle dein Modell aus
4. Das Modell sollte jetzt in der Szene erscheinen
### 2. Modell vorbereiten
1. Stelle sicher, dass das Modell im **Object Mode** ist (Tab drücken, falls im Edit Mode)
2. Wähle das Modell aus (Linksklick)
3. Drücke `Alt + G` um die Position auf (0, 0, 0) zu setzen
4. Drücke `Alt + R` um die Rotation zurückzusetzen
5. Drücke `Alt + S` um die Skalierung auf 1 zu setzen
### 3. Rigging (Bones hinzufügen)
#### Option A: Automatisches Rigging mit Rigify (Empfohlen)
1. **Rigify aktivieren:**
- Gehe zu `Edit``Preferences` (oder `Blender``Preferences` auf Mac)
- Klicke auf den Tab **"Add-ons"** (links im Fenster)
- Im Suchfeld oben rechts tippe: **"rigify"** (ohne Anführungszeichen)
- Du solltest "Rigify: Auto-rigging system" sehen
- Aktiviere das **Häkchen** neben "Rigify"
- Das Add-on ist jetzt aktiviert
- Schließe das Preferences-Fenster
**Alternative Wege zu Preferences:**
- Windows/Linux: `Edit``Preferences`
- Mac: `Blender``Preferences`
- Oder: `Ctrl + ,` (Strg + Komma)
2. **Rigify-Rig hinzufügen:**
- Stelle sicher, dass du im **Object Mode** bist (Tab drücken, falls im Edit Mode)
- Wähle das Modell aus (oder nichts, das Rig wird separat erstellt)
- Drücke `Shift + A` (Add Menu)
- Wähle **`Armature`** aus
- In der Liste siehst du jetzt **`Human (Meta-Rig)`** - klicke darauf
- Ein Basis-Rig wird in der Szene erstellt
**Falls "Human (Meta-Rig)" nicht erscheint:**
- Stelle sicher, dass Rigify aktiviert ist (siehe Schritt 1)
- Starte Blender neu, falls nötig
- Prüfe, ob du die neueste Blender-Version hast (Rigify ist ab Version 2.8+ verfügbar)
3. **Rig positionieren und anpassen:**
**Schritt 1: Rig zum Modell bewegen**
- Stelle sicher, dass du im **Object Mode** bist (Tab drücken)
- Wähle das **Armature** aus (nicht das Modell)
- Drücke `G` (Grab/Move) und bewege das Rig zum Modell
- Oder: Drücke `Alt + G` um die Position zurückzusetzen, dann `G` + `X`, `Y` oder `Z` für eine Achse
**Schritt 2: Rig skalieren (falls zu groß/klein)**
- Wähle das Armature aus
- Drücke `S` (Scale) und skaliere das Rig
- Oder: `S` + `X`, `Y` oder `Z` für eine Achse
- Tipp: Drücke `Shift + X` (oder Y/Z) um diese Achse auszuschließen
**Schritt 3: Einzelne Bones anpassen**
- Wähle das Armature aus
- Wechsle in den **Edit Mode** (Tab)
- Wähle einen Bone aus (Linksklick)
- Drücke `G` um ihn zu bewegen
- Drücke `E` um einen neuen Bone zu extrudieren
- Drücke `R` um einen Bone zu rotieren
- Drücke `S` um einen Bone zu skalieren
**Wichtige Bones zum Anpassen:**
- **Root/Spine** - Sollte in der Mitte des Körpers sein (Hüfthöhe)
- **Spine1/Spine2** - Entlang der Wirbelsäule
- **Neck/Head** - Am Hals und Kopf
- **Shoulders** - An den Schultern
- **Arms** - Entlang der Arme
- **Legs** - Entlang der Beine
**Tipp:** Nutze die Zahlenansicht (Numpad) um die Positionen genau zu sehen
4. **Rig generieren:**
- Wechsle zurück in den **Object Mode** (Tab drücken)
- Wähle das **Meta-Rig (Armature)** aus (nicht das Modell!) - sollte im Outliner blau markiert sein
**Methode 1: Rigify-Button in der Toolbar (Einfachste Methode)**
- Oben in der Toolbar siehst du den Button **"Rigify"** (neben "Object")
- Klicke auf **"Rigify"** → **"Generate Rig"**
- Ein vollständiges Rig wird erstellt (dies kann einen Moment dauern)
**Methode 2: Properties-Panel (Alternative)**
- Im **Properties-Panel** (rechts):
- Klicke auf das **Wrench-Icon** (Modifier Properties) in der linken Toolbar
- Oder: Klicke auf das **Bone-Icon** (Armature Properties)
- Scrolle durch die Tabs, bis du **"Rigify"** oder **"Rigify Generation"** siehst
- In diesem Tab findest du den Button **"Generate Rig"**
- Klicke auf **"Generate Rig"**
**Wichtig:** Nach dem Generieren kannst du das Rig weiter anpassen, aber du musst es im **Pose Mode** tun (nicht Edit Mode)
**Die richtigen Tabs im Properties-Panel (von oben nach unten):**
- 📐 **Object Properties** (Würfel-Icon) - hier findest du Transform, etc.
- 🦴 **Armature Properties** (Bone-Icon) - hier findest du Armature-Einstellungen
- 🔧 **Modifier Properties** (Wrench-Icon) - hier sollte der **Rigify-Tab** sein!
- 🌍 **World Properties** (Globus-Icon) - NICHT hier suchen!
**Falls du den Rigify-Tab nicht siehst:**
- Stelle sicher, dass das **Meta-Rig** (nicht ein bereits generiertes Rig) ausgewählt ist
- Klicke auf das **Wrench-Icon** (Modifier Properties) in der linken Toolbar
- Der Rigify-Tab sollte dort erscheinen
#### Option B: Manuelles Rigging
1. **Armature erstellen:**
- Drücke `Shift + A``Armature`
- Ein Bone wird erstellt
2. **Bones hinzufügen:**
- Wechsle in den **Edit Mode** (Tab)
- Wähle den Root-Bone aus
- Drücke `E` um einen neuen Bone zu extrudieren
- Erstelle die wichtigsten Bones:
- **Spine/Spine1/Spine2** - Wirbelsäule
- **Neck/Head** - Hals und Kopf
- **LeftArm/LeftForeArm/LeftHand** - Linker Arm
- **RightArm/RightForeArm/RightHand** - Rechter Arm
- **LeftUpLeg/LeftLeg/LeftFoot** - Linkes Bein
- **RightUpLeg/RightLeg/RightFoot** - Rechtes Bein
3. **Bone-Namen vergeben:**
- Wähle jeden Bone aus
- Im Properties-Panel (rechts) unter "Bone" kannst du den Namen ändern
- **Wichtig:** Verwende diese Namen für die Animation:
- `LeftArm`, `RightArm`
- `LeftForeArm`, `RightForeArm`
- `LeftHand`, `RightHand`
- `LeftUpLeg`, `RightUpLeg`
- `LeftLeg`, `RightLeg`
- `LeftFoot`, `RightFoot`
- `Neck`, `Head`
- `Spine`, `Spine1`, `Spine2`
### 4. Modell an Bones binden (Skinning)
1. **Beide Objekte auswählen:**
- Wähle zuerst das **Mesh** aus
- Dann wähle das **Armature** aus (Shift + Linksklick)
- Drücke `Ctrl + P``With Automatic Weights`
- Blender berechnet automatisch, welche Vertices zu welchen Bones gehören
2. **Weights überprüfen:**
- Wähle das Mesh aus
- Wechsle in den **Weight Paint Mode** (Dropdown oben)
- Wähle einen Bone aus (rechts im Properties-Panel)
- Rot = vollständig gebunden, Blau = nicht gebunden
- Falls nötig, kannst du die Weights manuell anpassen
### 5. Test-Animation erstellen (Optional)
1. **Pose Mode aktivieren:**
- Wähle das **generierte Rig** aus (nicht das Meta-Rig!)
- Wechsle in den **Pose Mode** (Dropdown oben: "Object Mode" → "Pose Mode")
- Oder: `Ctrl + Tab` → "Pose Mode"
2. **Bone auswählen:**
- **Wichtig:** Arbeite im **3D-Viewport** (Hauptfenster), nicht nur im Outliner!
- **Rigify-Bone-Namen** (nach dem Generieren):
- Für **Knie beugen**: `Leg.L (IK)` oder `Leg.L (FK)` (nicht "Tweak"!)
- Für **Hand anheben**: `Arm.L (IK)` oder `Arm.L (FK)`
- Für **Fuß bewegen**: `Leg.L (IK)` (der Fuß-Controller)
- **IK** = Inverse Kinematics (einfacher, empfohlen für Anfänger)
- **FK** = Forward Kinematics (mehr Kontrolle)
- **Tweak** = Feinabstimmungen (für später, nicht für Hauptanimationen)
- Klicke auf einen **Bone** im **3D-Viewport** (nicht im Outliner!)
- Der Bone sollte orange/ausgewählt sein und im Viewport sichtbar sein
- **Tipp:** Nutze `X-Ray Mode` (Button oben im Viewport) um Bones besser zu sehen
- **Tipp:** Im Outliner kannst du Bones finden, aber die Animation machst du im Viewport
3. **Bone animieren:**
- Wähle z.B. `hand.L` (linke Hand) aus
- Drücke `R` (Rotate) und rotiere den Bone
- Oder: `R` + `Z` (um Z-Achse rotieren)
- Oder: `R` + `X` (um X-Achse rotieren)
- Bewege die Maus → Linksklick zum Bestätigen
- **Beispiel für Hand anheben:** `hand.L``R``Z` → nach oben bewegen
4. **Animation aufnehmen (Timeline):**
- Unten siehst du die **Timeline** (falls nicht sichtbar: `Shift + F12` oder `Window``Animation``Timeline`)
- Stelle den Frame auf **1** (Anfang)
- Wähle den Bone aus und positioniere ihn in der **Ausgangsposition**
- Drücke `I` (Insert Keyframe) → wähle **"Rotation"** (oder "Location" falls bewegt)
- Ein Keyframe wird erstellt (gelber Punkt in der Timeline)
- Stelle den Frame auf **30** (oder einen anderen Frame)
- Rotiere/Bewege den Bone in die **Zielposition** (z.B. Hand nach oben)
- Drücke wieder `I`**"Rotation"** (oder "Location")
- Stelle den Frame auf **60** (Rückkehr zur Ausgangsposition)
- Rotiere den Bone zurück zur Ausgangsposition
- Drücke `I`**"Rotation"**
- Drücke **Play** (Leertaste) um die Animation zu sehen
5. **Animation testen:**
- Die Animation sollte jetzt in einer Schleife abgespielt werden
- Du kannst weitere Keyframes hinzufügen (Frame 90, 120, etc.)
- **Tipp:** Nutze `Alt + A` um die Animation zu stoppen
### 6. Modell exportieren
1. **Beide Objekte auswählen:**
- Wähle das **Mesh** aus
- Shift + Linksklick auf das **generierte Rig** (nicht das Meta-Rig!)
2. **Exportieren:**
- Gehe zu `File``Export``glTF 2.0 (.glb/.gltf)`
- Wähle `.glb` Format
- Stelle sicher, dass folgende Optionen aktiviert sind:
-**Include****Selected Objects**
-**Transform****+Y Up**
-**Geometry****Apply Modifiers**
-**Animation****Bake Animation** (wichtig für Animationen!)
-**Animation****Always Sample Animations** (falls Animationen nicht korrekt exportiert werden)
- Klicke auf "Export glTF 2.0"
### 7. Modell testen
1. Kopiere die exportierte `.glb` Datei nach:
```
frontend/public/models/3d/falukant/characters/
```
2. Lade die Seite neu
3. Die Bones sollten jetzt automatisch erkannt und animiert werden
4. **Animationen testen:**
- Öffne die Browser-Konsole (F12)
- Du solltest sehen: `[ThreeScene] Found X animation(s)`
- Die Animationen sollten automatisch abgespielt werden
- Falls keine Animationen vorhanden sind, werden die Bones trotzdem mit Idle-Animationen bewegt
## Rig anpassen - Detaillierte Anleitung
### Rig nach dem Generieren anpassen
Wenn das Rigify-Rig generiert wurde, aber nicht perfekt passt:
1. **Pose Mode verwenden:**
- Wähle das generierte Armature aus
- Wechsle in den **Pose Mode** (Dropdown oben, oder Strg+Tab → Pose Mode)
- Hier kannst du die Bones bewegen, ohne die Struktur zu zerstören
2. **Rig neu generieren (falls nötig):**
- Falls das Rig komplett neu positioniert werden muss:
- Lösche das generierte Rig (X → Delete)
- Gehe zurück zum Meta-Rig
- Passe das Meta-Rig im Edit Mode an
- Generiere das Rig erneut
3. **Snap to Mesh (Hilfsmittel):**
- Im Edit Mode: `Shift + Tab` um Snap zu aktivieren
- Oder: Rechtsklick auf das Snap-Symbol (Magnet) oben
- Wähle "Face" oder "Vertex" als Snap-Target
- Jetzt werden Bones automatisch am Mesh ausgerichtet
### Häufige Probleme und Lösungen
**Problem: Rig ist zu groß/klein**
- Lösung: Im Object Mode das Armature auswählen und mit `S` skalieren
**Problem: Rig ist an falscher Position**
- Lösung: Im Object Mode mit `G` bewegen, oder `Alt + G` zurücksetzen
**Problem: Einzelne Bones passen nicht**
- Lösung: Im Edit Mode die Bones einzeln anpassen (`G` zum Bewegen)
**Problem: Nach dem Generieren passt es nicht mehr**
- Lösung: Passe das Meta-Rig an und generiere neu, oder verwende Pose Mode
## Tipps und Tricks
### Bone-Namen für automatische Erkennung
Die Komponente erkennt Bones anhand ihrer Namen. Verwende diese Keywords:
- `arm` - für Arme
- `hand` oder `wrist` - für Hände
- `leg` oder `knee` - für Beine
- `foot` oder `ankle` - für Füße
- `shoulder` - für Schultern
- `elbow` - für Ellbogen
### Einfacheres Rigging mit Mixamo
Alternativ kannst du:
1. Dein Modell auf [Mixamo](https://www.mixamo.com/) hochladen
2. Automatisches Rigging durchführen lassen
3. Das geriggte Modell herunterladen
4. In Blender importieren und anpassen
### Performance-Optimierung
- Verwende nicht zu viele Bones (max. 50-100 für Charaktere)
- Entferne unnötige Bones vor dem Export
- Teste die Animation im Browser, bevor du das finale Modell exportierst
## Troubleshooting
### Bones werden nicht erkannt
- Prüfe die Bone-Namen (müssen `arm`, `hand`, `leg`, etc. enthalten)
- Stelle sicher, dass das Modell korrekt an die Bones gebunden ist
- Öffne die Browser-Konsole und prüfe die Logs: `[ThreeScene] Found X bones for animation`
### Modell verformt sich falsch
- Überprüfe die Weights im Weight Paint Mode
- Passe die Bone-Positionen an
- Stelle sicher, dass alle Vertices korrekt zugewiesen sind
### Export schlägt fehl
- Stelle sicher, dass beide Objekte (Mesh + Armature) ausgewählt sind
- Prüfe, ob das Modell im Object Mode ist
- Versuche es mit einem anderen Export-Format (.gltf statt .glb)
## Weitere Ressourcen
- [Blender Rigging Tutorial](https://www.youtube.com/results?search_query=blender+rigging+tutorial)
- [Mixamo Auto-Rigging](https://www.mixamo.com/)
- [Three.js GLTF Animation Guide](https://threejs.org/docs/#manual/en/introduction/Animation-system)

26
fix-api-urls.sh Executable file
View File

@@ -0,0 +1,26 @@
#!/bin/bash
echo "=== YourPart API-URL Fix ==="
cd ~/yourpart3/frontend
# 1. Alle localhost:3001 Referenzen finden
echo "Suche nach localhost:3001 Referenzen..."
grep -r "localhost:3001" src/ || echo "Keine localhost:3001 Referenzen gefunden"
# 2. Alle localhost Referenzen finden
echo ""
echo "Suche nach localhost Referenzen..."
grep -r "localhost" src/ || echo "Keine localhost Referenzen gefunden"
# 3. API-Konfigurationsdateien finden
echo ""
echo "Suche nach API-Konfigurationsdateien..."
find src/ -name "*.js" -exec grep -l "axios\|baseURL\|localhost" {} \;
echo ""
echo "=== API-URL Fix abgeschlossen ==="
echo "Bitte überprüfen Sie die gefundenen Dateien und ersetzen Sie:"
echo " localhost:3001 → /api"
echo " http://localhost:3001 → /api"
echo " baseURL: 'http://localhost:3001' → baseURL: '/api'"

22
fix-cors.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
echo "=== YourPart CORS-Fix ==="
# Backup der ursprünglichen app.js erstellen
sudo cp /opt/yourpart/backend/app.js /opt/yourpart/backend/app.js.backup
# CORS-Konfiguration aktualisieren
sudo sed -i 's|origin: \[.*\]|origin: [\n "http://localhost:3000", \n "http://localhost:5173", \n "http://127.0.0.1:3000", \n "http://127.0.0.1:5173",\n "https://your-part.de",\n "https://www.your-part.de",\n "http://your-part.de",\n "http://www.your-part.de"\n ]|' /opt/yourpart/backend/app.js
echo "CORS-Konfiguration aktualisiert!"
# Service neu starten
echo "Starte Backend-Service neu..."
sudo systemctl restart yourpart.service
# Status prüfen
echo "Service-Status:"
sudo systemctl status yourpart.service
echo ""
echo "CORS-Fix abgeschlossen! Testen Sie jetzt die Anwendung."

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