68 Commits

Author SHA1 Message Date
Torsten Schulz (local)
71e120bf20 feat(socialnetwork): enhance folder and video management with user visibility options
- Added functionality to manage selected users for adult folders and erotic videos, allowing for more granular visibility control.
- Introduced new endpoints and methods in the SocialNetworkController and SocialNetworkService to handle selected users.
- Updated the frontend components to include input fields for selected users in CreateFolderDialog, EditImageDialog, and EroticPicturesView.
- Enhanced the routing to support fetching erotic folders and videos by username, improving user experience in profile views.
2026-03-27 16:56:45 +01:00
Torsten Schulz (local)
39032570e3 fix(i18n): correct German translations for video upload hints and descriptions
- Updated the German translations in socialnetwork.json for improved accuracy and clarity.
- Enhanced the text in EroticPicturesView.vue and EroticVideosView.vue to ensure proper spelling and grammar in user-facing messages.
2026-03-27 16:29:18 +01:00
Torsten Schulz (local)
9e8f8e8077 refactor(i18n): update video management texts for clarity and consistency
- Revised the introductory texts and hints for video uploads in German, English, and Spanish to enhance user understanding.
- Improved descriptions to better reflect the functionality of the video management features.
- Removed outdated statistics display from the EroticVideosView component to streamline the user interface.
2026-03-27 15:43:15 +01:00
Torsten Schulz (local)
e76be33743 refactor(EroticVideosView): restructure layout and enhance video statistics display
- Updated the layout of the EroticVideosView component to improve organization and user experience.
- Introduced a sidebar for video upload and management, separating it from the video list.
- Added statistics for total, visible, and hidden videos to provide users with better insights.
- Enhanced form elements and labels for clarity and usability during video uploads.
2026-03-27 15:36:00 +01:00
Torsten Schulz (local)
6cbcf9d95f style(DialogWidget): update dialog overlay class for improved modal handling
- Changed the class binding for the dialog overlay to differentiate between modal and non-modal states, enhancing visual clarity.
- Introduced a new class for non-modal dialogs to ensure proper positioning and z-index management.
2026-03-27 15:21:57 +01:00
Torsten Schulz (local)
31a96aaf60 style(DialogWidget): remove backdrop filter for non-modal overlay
- Updated the non-modal dialog overlay to have no backdrop filter, enhancing visual clarity.
- Set the modal property to false in MessagesDialog to ensure proper dialog behavior.
2026-03-27 15:17:18 +01:00
Torsten Schulz (local)
8a1ff52a61 feat(chat): enhance room entry announcement logic in MultiChatDialog.vue
- Introduced a new property `lastAnnouncedRoomName` to track the last announced room, preventing redundant announcements.
- Updated the room entry announcement logic to ensure messages are sent only when entering a new room, improving clarity for users.
2026-03-27 15:10:38 +01:00
Torsten Schulz (local)
84c598bf52 feat(chat): initialize current room name in MultiChatDialog.vue
- Added a new property `currentRoomName` to track the name of the currently selected room.
- Updated the initialization logic to reset `currentRoomName` when rooms are loaded and when the chat is disconnected, ensuring accurate room state management.
2026-03-27 15:03:32 +01:00
Torsten Schulz (local)
291e79c41f fix(chat): improve room announcement logic in MultiChatDialog.vue
- Enhanced the logic for announcing room entries, particularly in adult-only mode, to ensure accurate messaging based on room selection.
- Added checks to prevent redundant announcements and ensure proper synchronization with the selected room.
2026-03-27 14:46:08 +01:00
Torsten Schulz (local)
3b823420e6 feat(chat): implement room synchronization for adult-only mode in MultiChatDialog.vue
- Added logic to handle room synchronization when entering a chat in adult-only mode.
- Introduced a flag to manage ongoing room sync requests, improving user experience during room transitions.
2026-03-27 14:37:49 +01:00
Torsten Schulz (local)
674c4d0b69 refactor(chat): simplify adult-only mode checks in MultiChatDialog.vue
- Removed redundant checks for adult-only mode when opening the chat dialog and connecting the chat socket.
- Streamlined the logic to enhance readability and maintainability of the component.
2026-03-27 14:29:00 +01:00
Torsten Schulz (local)
9f3facbb3f chore(dependencies): update package-lock.json and package.json for dependency versions
- Upgraded @emnapi/runtime to version 1.9.1 and @img/colour to version 1.1.0 in package-lock.json.
- Added new dependencies for ansi-regex and strip-ansi with updated versions in multiple locations.
- Updated lodash types to version 4.17.24 and ansi-escapes to version 7.3.0.
- Introduced overrides for minimatch and tmp in package.json to ensure consistent behavior across environments.
- Refactored MultiChatDialog.vue to improve room selection logic and handle adult-only mode more effectively.
2026-03-27 14:05:29 +01:00
Torsten Schulz (local)
07604cc9fa feat(navigation): enhance adult verification handling and notifications
- Updated navigationController to simplify the eroticChat menu structure.
- Enhanced adminService to notify users of adult verification status changes, including previous status.
- Improved AppNavigation and related components to register and unregister socket listeners for adult verification updates.
- Added localized messages for adult verification notifications in English, German, and Spanish.
- Introduced a verification hint in the EroticAccessView to guide users on document submission.
2026-03-27 13:23:44 +01:00
Torsten Schulz (local)
82223676a6 style(admin): update button styles in AdultVerificationView and EroticModerationView
- Added new styles for buttons in the adult verification filters to enhance appearance and usability.
- Set minimum height, padding, and font size for consistency across both views.
2026-03-27 11:25:56 +01:00
Torsten Schulz (local)
207ef6266a feat(deploy): add adult verification directory creation and permissions setup
- Enhanced deploy-backend.sh and update-backend.sh to create the adult verification directory under /opt/yourpart-data.
- Updated permissions for the new directory to ensure proper access control.
- Refactored file path handling in AdminService and SettingsService to utilize the new directory structure for adult verification files.
2026-03-27 11:24:21 +01:00
Torsten Schulz (local)
02837c7b73 refactor(admin): restructure adult verification and erotic moderation views for improved layout
- Updated the AdultVerificationView and EroticModerationView components to utilize a new layout structure with content scrolling and hidden overflow for better user experience.
- Adjusted styles in styles.scss to support the new layout, ensuring proper height and overflow handling for content sections.
2026-03-27 11:18:42 +01:00
Torsten Schulz (local)
25b658acce feat(admin): enhance adult verification file handling and localization
- Added a new method in AdminService to resolve adult verification file paths, improving file retrieval logic.
- Updated the AdminVerificationView to display a message when the verification document is missing.
- Localized the missing document message in German, English, and Spanish for better user experience.
2026-03-27 11:08:52 +01:00
Torsten Schulz (local)
0f0c102ded fix(admin): add 'adult_verification_request' to user parameter type filtering
- Updated the filtering criteria in the AdminService to include 'adult_verification_request' in the user parameter type query, enhancing data retrieval for adult verification processes.
2026-03-27 10:57:16 +01:00
Torsten Schulz (local)
26eb7b8ce7 feat(admin): add document preview functionality to adult verification
- Implemented a preview section for adult verification documents, allowing users to view images and PDFs inline.
- Added localization support for preview titles and messages in German, English, and Spanish.
- Enhanced the component's state management to handle preview visibility and cleanup.
2026-03-27 10:50:28 +01:00
Torsten Schulz (local)
0dd2bce5d1 fix(settings): streamline settings type creation in settingsService and initialization
- Refactored settingsService to use findOrCreate for settings type, improving efficiency and error handling.
- Added initialization for 'account' settings type in initializeSettings, ensuring all necessary settings are created during setup.
2026-03-27 10:43:44 +01:00
Torsten Schulz (local)
cf6d72385e fix(settings): enhance user parameter handling and add special user parameter types
- Introduced a new method to ensure special user parameter types for adult verification settings, improving data integrity and handling.
- Updated the upsertUserParam method to utilize the new special parameter type handling, ensuring robust user parameter management.
- Updated package dependencies in package.json and package-lock.json for consistency and to address potential vulnerabilities.
2026-03-27 10:38:42 +01:00
Torsten Schulz (local)
1a86061680 fix(dependencies): update @gltf-transform packages and sequelize-cli version
- Upgraded @gltf-transform/cli, core, extensions, and functions to version 4.3.0 for improved functionality and compatibility.
- Updated sequelize-cli to version 6.6.5 to address potential vulnerabilities.
- Added an overrides section in package.json for minimatch to ensure consistent behavior across environments.
- Refactored the EroticAccessView.vue component for improved structure and readability.
2026-03-27 10:23:25 +01:00
Torsten Schulz (local)
e13deb0720 fix(api): improve 404 handling for unknown API routes
- Refactored the 404 response for API routes to ensure it only triggers for paths starting with '/api/', enhancing clarity in error handling.
- Updated package dependencies in package.json and package-lock.json to maintain version consistency and address potential vulnerabilities.
2026-03-27 09:52:24 +01:00
Torsten Schulz (local)
21072139f7 fix(cors): refine CORS handling for OPTIONS requests
- Updated CORS middleware to explicitly handle OPTIONS requests, ensuring proper preflight response and improving API request handling.
2026-03-27 09:49:54 +01:00
Torsten Schulz (local)
1878b2a8c7 fix(router): refine stock and inventory routes for improved clarity
- Updated stock and inventory routes to explicitly define branchId as a required parameter, enhancing API usability and consistency in request handling.
2026-03-27 09:39:51 +01:00
Torsten Schulz (local)
6563ca23c7 fix(router): update stock and inventory routes to support optional branchId parameter
- Modified the stock and inventory routes to allow an optional branchId parameter, improving flexibility in API requests.
2026-03-27 09:24:05 +01:00
Torsten Schulz (local)
085333db29 fix(deploy): update package-lock.json for consistent npm ci deployments
- Removed package-lock.json from .gitignore to ensure it is tracked.
- Added locks for backend, frontend, and repo root to maintain version consistency during deployments with npm ci.
- Updated backend with a new script for lockfile synchronization and added a description note.
2026-03-27 09:20:37 +01:00
Torsten Schulz (local)
17325a5263 fix(deploy): package-lock.json versionieren für npm ci
- Zeile **/package-lock.json aus .gitignore entfernt; Locks zu backend,
  frontend und Repo-Root hinzugefügt, damit Deploy-Skripte mit npm ci
  dieselben Versionen wie package.json installieren.
- backend: Script lockfile:sync und Hinweis in description.

Made-with: Cursor
2026-03-27 09:16:17 +01:00
Torsten Schulz (local)
3e6c09ab29 Add adult verification and erotic moderation features: Implement new routes and controller methods for managing adult verification requests, status updates, and document retrieval. Introduce erotic moderation actions and reports, enhancing administrative capabilities. Update chat and navigation controllers to support adult content filtering and access control. Enhance user parameter handling for adult verification status and requests, improving overall user experience and compliance. 2026-03-27 09:14:54 +01:00
Torsten Schulz (local)
f93687c753 Update product sell costs in SQL and initialization script: Adjust sell costs for various products to improve game balance, ensuring consistency across the database and initialization logic. 2026-03-26 20:21:48 +01:00
Torsten Schulz (local)
e0c3b472db Update product definitions and revenue calculations in Falukant: Adjust product sell costs and production times for better balance. Refactor revenue calculations to focus on profit per minute instead of revenue per minute. Enhance localization files to include new terms related to product unlocks and certificate levels in English, German, and Spanish, improving user experience across languages. 2026-03-26 20:19:49 +01:00
Torsten Schulz (local)
01849c8ffe Enhance speech input handling in VocabLessonView: Introduce fallback instructions and placeholders for unsupported speech recognition in English, German, and Spanish. Update exercise instructions to dynamically display fallback options, improving user experience for speech-related exercises. 2026-03-25 18:07:01 +01:00
Torsten Schulz (local)
2e6eb53918 Enhance API key handling in settings and vocab services: Update key retrieval logic to improve validation and status reporting. Introduce new localization strings for key status messages in English, German, and Spanish. Update LanguageAssistantView to display key status dynamically, enhancing user feedback on API key management. 2026-03-25 17:46:18 +01:00
Torsten Schulz (local)
09141d9e55 Refactor email encryption handling in user and contact message models: Introduce utility functions for encoding and decoding encrypted values, simplifying the encryption process. Update the registerUser and handleForgotPassword functions to support multiple encrypted email formats. Enhance the VocabCourseView and VocabLessonView components with new methods for managing language assistant interactions, improving user experience. 2026-03-25 17:41:10 +01:00
Torsten Schulz (local)
95c9e7c036 Add language assistant features and improve encryption handling: Implement a new route and controller method for sending messages to the language assistant, enhancing user interaction within lessons. Update the encryption utility to support both base64 and hex formats for better compatibility with existing data. Enhance localization files to include new terms related to the language assistant in English, German, and Spanish, improving user experience across languages. 2026-03-25 17:31:00 +01:00
Torsten Schulz (local)
850a59a0b5 Enhance Bisaya course content by refining existing exercises and adding a new exercise for recognizing farewells: Update questions for clarity and introduce a new exercise focused on the meaning of "Babay," improving the overall learning experience for users. 2026-03-25 16:33:05 +01:00
Torsten Schulz (local)
90385f2ee0 Enhance Bisaya course content with new greetings and farewells: Add exercises for recognizing and practicing morning greetings and farewells in Bisaya. Update lesson didactics to include new learning goals, core patterns, and speaking prompts related to greetings and farewells, improving the overall learning experience. 2026-03-25 16:28:29 +01:00
Torsten Schulz (local)
eb8f9c1d19 Update SQL scripts to include 'created_at' timestamp for exercise types: Modify insert statements in add_vocab_lesson_didactics.sql, create-vocab-courses.sql, and update-vocab-courses-existing.sql to add a 'created_at' column with the current timestamp, enhancing data tracking for vocabulary grammar exercise types. 2026-03-25 16:26:48 +01:00
Torsten Schulz (local)
3ac4ea04d5 Refactor bisaya course progress reset logic: Replace raw SQL queries with Sequelize ORM methods for fetching lesson and exercise IDs. This improves code readability and maintainability while ensuring consistent database interactions. 2026-03-25 16:13:05 +01:00
Torsten Schulz (local)
6be816fe48 Improve exercise type validation and encryption in settings service: Enhance error handling for exercise type name checks in both create-bisaya-course-content and update-week1-bisaya-exercises scripts. Implement encryption for API keys and user settings in settingsService, ensuring sensitive data is securely stored. Update localization files to include new terms related to model patterns in English, German, and Spanish. 2026-03-25 16:09:04 +01:00
Torsten Schulz (local)
d50d3c4016 Add language assistant settings and related features: Introduce new routes and controller methods for managing language assistant settings, including retrieval and saving of LLM configurations. Update navigation structure to include language assistant options. Enhance vocab course model to support additional learning attributes such as learning goals and core patterns. Update SQL scripts to reflect new database schema changes for vocab courses. Improve localization for language assistant settings in German and English. 2026-03-25 15:53:49 +01:00
Torsten Schulz (local)
8af726c65a Update environment configuration and enhance logging: Add support for loading a local .env file and improve logging behavior based on QUIET_ENV_LOGS settings. Introduce new diagnostic scripts in package.json for town worth and money flow analysis. Adjust production cost calculations in FalukantService to align with updated pricing logic and enhance product initialization parameters. 2026-03-25 13:23:51 +01:00
Torsten Schulz (local)
44991743d2 Implement certificate progress feature in FalukantService and frontend: Add methods to calculate and retrieve certificate progress based on user attributes. Update localization files for English, German, and Spanish to include new terms related to certificate progress. Enhance OverviewView to display certificate details and requirements, improving user experience and clarity. 2026-03-25 11:59:43 +01:00
Torsten Schulz (local)
b61a533eac Enhance FalukantService to include certificate attribute: Add 'certificate' to user attributes and ensure default value is set if absent. Update product type retrieval logic to utilize effective certificate value for filtering. 2026-03-25 11:51:25 +01:00
Torsten Schulz (local)
de52b6f26d Add raid transport feature and related updates: Introduce new raid transport functionality in FalukantService and FalukantController, including methods for retrieving raid transport regions and handling guard counts. Update frontend components to support guard count input and display related costs. Enhance localization files to include new terms for raid transport and associated metrics in English, German, and Spanish. 2026-03-23 18:47:01 +01:00
Torsten Schulz (local)
43dd1a3b7f Update package dependencies across frontend and backend: Upgrade various libraries including nodemon, sequelize-cli, and axios to their latest versions for improved performance and security. Adjust dependency versions in package.json files for both frontend and backend to ensure compatibility and stability. 2026-03-23 12:16:14 +01:00
Torsten Schulz (local)
22f1803e7d Add localization for marriage gift, lover maintenance, and household order: Update German, English, and Spanish translation files to include new terms related to marriage and household management. Enhance MoneyHistoryView to improve activity translation handling with additional candidate formats. 2026-03-23 12:09:31 +01:00
Torsten Schulz (local)
42e894d4e4 Enhance FalukantService error handling for debtors prison records: Implement try-catch logic to manage potential database errors when retrieving debtor records. Update nobility title requirements to include new house position values for various titles, ensuring consistency across the application. Adjust initialization script for title requirements to reflect these changes. 2026-03-23 12:05:26 +01:00
Torsten Schulz (local)
9b88a98a20 Implement debtors prison features across the application: Enhance FalukantController to include debtors prison logic in various service methods. Update FalukantService to manage debtors prison state and integrate it into user data retrieval. Modify frontend components, including DashboardWidget, StatusBar, and BankView, to display debtors prison status and warnings. Add localization for debtors prison messages in English, German, and Spanish, ensuring clarity in user notifications and actions. 2026-03-23 11:59:59 +01:00
Torsten Schulz (local)
f2343098d2 Refactor political office type references in FalukantService: Update the alias for PoliticalOfficeType from 'officeType' to 'type' in multiple locations to improve clarity and consistency in candidate ranking logic. 2026-03-23 11:11:40 +01:00
Torsten Schulz (local)
57ab85fe10 Implement church career information retrieval and update related components: Add a new method in FalukantService to fetch church career details for characters, including current and approved office levels. Enhance DashboardWidget, StatusBar, and ChurchView components to handle new church-related socket events and display relevant information. Update localization files for church-related terms and error messages in English, German, and Spanish. 2026-03-23 11:05:48 +01:00
Torsten Schulz (local)
ce36315b58 Enhance NobilityView with new house position and condition formatting: Introduce methods to format house position labels and house condition descriptions based on numeric values. Update requirement translations to utilize these new methods for improved clarity and localization. 2026-03-23 10:47:54 +01:00
Torsten Schulz (local)
80d8caee88 Add new requirements for nobility titles and enhance service logic: Introduce checks for reputation, house position, house condition, office rank, and lover count in the FalukantService. Update title requirements in the initialization script to include these new criteria. Enhance localization for requirements in English, German, and Spanish, ensuring accurate translations for new conditions. 2026-03-23 10:31:32 +01:00
Torsten Schulz (local)
b3607849d2 Update servant cost calculation and documentation: Adjust base servant cost formula to reflect a compressed time scale in Falukant, changing the calculation from a realistic monthly wage to an abstract maintenance value. Update implementation spec to clarify the new cost structure and time measurement for the external daemon. 2026-03-23 10:10:11 +01:00
Torsten Schulz (local)
d901257be1 Fix typos in BranchView and OverviewView: Correct spelling of 'Steuerfläche', 'prüfen', and 'alltäglicher' for improved localization accuracy. 2026-03-23 10:04:32 +01:00
Torsten Schulz (local)
d7c59df225 Enhance FamilyView styles for improved layout and responsiveness: Adjust flex properties, grid configurations, and dimensions for better visual consistency. Implement new styles for lover candidate cards and family view to prevent overlap and ensure proper spacing across components. 2026-03-23 09:53:31 +01:00
Torsten Schulz (local)
f7e0d97174 Add marriage management features: Implement endpoints for spending time with, gifting to, and reconciling with spouses in the FalukantController. Update UserHouse model to include household tension attributes. Enhance frontend components to manage marriage actions and display household tension details, including localization updates in multiple languages. 2026-03-23 09:34:56 +01:00
Torsten Schulz (local)
2055c11fd9 Add random chat button to NoLoginView: Introduce a new button for starting random chats, enhancing user engagement options. Update layout for improved accessibility and visual consistency. 2026-03-22 13:19:22 +01:00
Torsten Schulz (local)
f98352088e Refactor NoLoginView styles for improved layout and spacing: Adjust padding, margins, and grid properties to enhance visual consistency and responsiveness across components. 2026-03-22 11:44:44 +01:00
Torsten Schulz (local)
63d9aab66a Update NoLoginView styles: Change action panel alignment from space-between to flex-start for improved layout consistency and responsiveness. 2026-03-22 11:00:18 +01:00
Torsten Schulz (local)
5f9e0a5a49 Refactor AppContent and NoLoginView styles for improved layout: Adjust flex properties and dimensions to enhance responsiveness and ensure consistent spacing across components. 2026-03-22 10:28:24 +01:00
Torsten Schulz (local)
9af974d2f2 Update NoLoginView styling: Adjust action panel dimensions and layout for improved responsiveness and spacing. Enhance flex properties to ensure better alignment and visual consistency across components. 2026-03-22 10:10:36 +01:00
Torsten Schulz (local)
c0f9fc8970 Add lightweight mode to Character3D component: Introduce a new lightweight prop for optimized model loading based on age group. Update NoLoginView to utilize lightweight characters. Adjust styling for better layout and overflow handling in home view components. 2026-03-22 10:05:28 +01:00
Torsten Schulz (local)
876ee2ab49 Add servant management features: Implement endpoints for hiring, dismissing, and setting pay levels for servants in the FalukantController. Update UserHouse model to include servant-related attributes. Enhance frontend components to manage servant details, including staffing state and household order, with corresponding localization updates in multiple languages. 2026-03-22 09:57:44 +01:00
Torsten Schulz (local)
2977b152a2 Implement lover relationship management features: Add endpoints for creating, acknowledging, and managing lover relationships in the FalukantController. Enhance backend models with RelationshipState for tracking relationship statuses. Update frontend components to display and manage lover details, including marriage satisfaction and household tension. Improve localization for new features in multiple languages. 2026-03-20 11:37:46 +01:00
Torsten Schulz (local)
c7d33525ff Enhance usability and localization across components: Update USABILITY_CONCEPT.md with new focus areas, improve user feedback in AppFooter and FamilyView components, and refine text in various UI elements for better clarity and consistency. Replace console logs with user-friendly messages, correct German translations, and streamline interaction logic in multiple components. 2026-03-20 09:41:03 +01:00
Torsten Schulz (local)
1774d7df88 Refactor feedback handling across components: Replace alert and confirm calls with centralized feedback functions for improved user experience. Update various components to utilize showError, showSuccess, and confirmAction for consistent messaging and confirmation dialogs. Enhance UI responsiveness and maintainability by streamlining feedback logic. 2026-03-19 16:18:51 +01:00
Torsten Schulz (local)
2c58ef37c4 Enhance OverviewView component to conditionally display character avatar and house: Introduce a new image container for character representation, ensuring it only renders when a character is present. Refactor existing code to remove duplicate avatar rendering logic and maintain a clean overview layout. 2026-03-19 15:07:53 +01:00
249 changed files with 42691 additions and 2302 deletions

3
.gitignore vendored
View File

@@ -5,8 +5,9 @@
.depbe.sh
node_modules
node_modules/*
**/package-lock.json
# package-lock.json wird versioniert (npm ci im Deploy braucht konsistente Locks zu package.json)
backend/.env
backend/.env.local
backend/images
backend/images/*
backend/node_modules

View File

@@ -83,7 +83,12 @@ const corsOptions = {
};
app.use(cors(corsOptions));
app.options('*', cors(corsOptions));
app.use((req, res, next) => {
if (req.method === 'OPTIONS') {
return cors(corsOptions)(req, res, next);
}
return next();
});
app.use(express.json()); // To handle JSON request bodies
app.use('/api/chat', chatRouter);
@@ -124,6 +129,11 @@ app.get(/^\/(?!api\/).*/, (req, res) => {
});
// Fallback 404 for unknown API routes
app.use('/api/*', (req, res) => res.status(404).send('404 Not Found'));
app.use((req, res, next) => {
if (req.path.startsWith('/api/')) {
return res.status(404).send('404 Not Found');
}
return next();
});
export default app;

View File

@@ -7,6 +7,16 @@ import fs from 'fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const quietEnv = process.env.QUIET_ENV_LOGS === '1';
const dotenvQuiet = quietEnv || process.env.DOTENV_CONFIG_QUIET === '1';
function log(...args) {
if (!quietEnv) console.log(...args);
}
function warn(...args) {
console.warn(...args);
}
// Versuche zuerst Produktions-.env, dann lokale .env
const productionEnvPath = '/opt/yourpart/backend/.env';
const localEnvPath = path.resolve(__dirname, '../.env');
@@ -19,54 +29,68 @@ if (fs.existsSync(productionEnvPath)) {
fs.accessSync(productionEnvPath, fs.constants.R_OK);
envPath = productionEnvPath;
usingProduction = true;
console.log('[env] Produktions-.env gefunden und lesbar:', productionEnvPath);
log('[env] Produktions-.env gefunden und lesbar:', productionEnvPath);
} catch (err) {
console.warn('[env] Produktions-.env vorhanden, aber nicht lesbar - verwende lokale .env stattdessen:', productionEnvPath);
console.warn('[env] Fehler:', err && err.message);
if (!quietEnv) {
warn('[env] Produktions-.env vorhanden, aber nicht lesbar - verwende lokale .env stattdessen:', productionEnvPath);
warn('[env] Fehler:', err && err.message);
}
envPath = localEnvPath;
}
} else {
console.log('[env] Produktions-.env nicht gefunden, lade lokale .env:', localEnvPath);
log('[env] Produktions-.env nicht gefunden, lade lokale .env:', localEnvPath);
}
// Lade .env-Datei (robust gegen Fehler)
console.log('[env] Versuche .env zu laden von:', envPath);
console.log('[env] Datei existiert:', fs.existsSync(envPath));
log('[env] Versuche .env zu laden von:', envPath);
log('[env] Datei existiert:', fs.existsSync(envPath));
let result;
try {
result = dotenv.config({ path: envPath });
result = dotenv.config({ path: envPath, quiet: dotenvQuiet });
if (result.error) {
console.warn('[env] Konnte .env nicht laden:', result.error.message);
console.warn('[env] Fehler-Details:', result.error);
warn('[env] Konnte .env nicht laden:', result.error.message);
warn('[env] Fehler-Details:', result.error);
} else {
console.log('[env] .env erfolgreich geladen von:', envPath, usingProduction ? '(production)' : '(local)');
console.log('[env] Geladene Variablen:', Object.keys(result.parsed || {}));
log('[env] .env erfolgreich geladen von:', envPath, usingProduction ? '(production)' : '(local)');
log('[env] Geladene Variablen:', Object.keys(result.parsed || {}));
}
} catch (err) {
// Sollte nicht passieren, aber falls dotenv intern eine Exception wirft (z.B. EACCES), fange sie ab
console.warn('[env] Unerwarteter Fehler beim Laden der .env:', err && err.message);
console.warn('[env] Stack:', err && err.stack);
warn('[env] Unerwarteter Fehler beim Laden der .env:', err && err.message);
warn('[env] Stack:', err && err.stack);
if (envPath !== localEnvPath && fs.existsSync(localEnvPath)) {
console.log('[env] Versuche stattdessen lokale .env:', localEnvPath);
log('[env] Versuche stattdessen lokale .env:', localEnvPath);
try {
result = dotenv.config({ path: localEnvPath });
result = dotenv.config({ path: localEnvPath, quiet: dotenvQuiet });
if (!result.error) {
console.log('[env] Lokale .env erfolgreich geladen von:', localEnvPath);
log('[env] Lokale .env erfolgreich geladen von:', localEnvPath);
}
} catch (err2) {
console.warn('[env] Konnte lokale .env auch nicht laden:', err2 && err2.message);
warn('[env] Konnte lokale .env auch nicht laden:', err2 && err2.message);
}
}
}
// Debug: Zeige Redis-Konfiguration
console.log('[env] Redis-Konfiguration:');
console.log('[env] REDIS_HOST:', process.env.REDIS_HOST);
console.log('[env] REDIS_PORT:', process.env.REDIS_PORT);
console.log('[env] REDIS_PASSWORD:', process.env.REDIS_PASSWORD ? '***gesetzt***' : 'NICHT GESETZT');
console.log('[env] REDIS_URL:', process.env.REDIS_URL);
// Lokale Überschreibungen (nicht committen): z. B. SSH-Tunnel DB_HOST=127.0.0.1 DB_PORT=60000
const localOverridePath = path.resolve(__dirname, '../.env.local');
if (fs.existsSync(localOverridePath)) {
const overrideResult = dotenv.config({ path: localOverridePath, override: true, quiet: dotenvQuiet });
if (!overrideResult.error) {
log('[env] .env.local geladen (überschreibt Werte, z. B. SSH-Tunnel)');
} else {
warn('[env] .env.local vorhanden, aber Laden fehlgeschlagen:', overrideResult.error?.message);
}
}
if (!quietEnv) {
console.log('[env] Redis-Konfiguration:');
console.log('[env] REDIS_HOST:', process.env.REDIS_HOST);
console.log('[env] REDIS_PORT:', process.env.REDIS_PORT);
console.log('[env] REDIS_PASSWORD:', process.env.REDIS_PASSWORD ? '***gesetzt***' : 'NICHT GESETZT');
console.log('[env] REDIS_URL:', process.env.REDIS_URL);
}
if (!process.env.SECRET_KEY) {
console.warn('[env] SECRET_KEY nicht gesetzt in .env');
warn('[env] SECRET_KEY nicht gesetzt in .env');
}
export {};

View File

@@ -29,6 +29,12 @@ class AdminController {
this.getUser = this.getUser.bind(this);
this.getUsers = this.getUsers.bind(this);
this.updateUser = this.updateUser.bind(this);
this.getAdultVerificationRequests = this.getAdultVerificationRequests.bind(this);
this.setAdultVerificationStatus = this.setAdultVerificationStatus.bind(this);
this.getAdultVerificationDocument = this.getAdultVerificationDocument.bind(this);
this.getEroticModerationReports = this.getEroticModerationReports.bind(this);
this.applyEroticModerationAction = this.applyEroticModerationAction.bind(this);
this.getEroticModerationPreview = this.getEroticModerationPreview.bind(this);
// Rights
this.listRightTypes = this.listRightTypes.bind(this);
@@ -119,6 +125,97 @@ class AdminController {
}
}
async getAdultVerificationRequests(req, res) {
try {
const { userid: requester } = req.headers;
const { status = 'pending' } = req.query;
const result = await AdminService.getAdultVerificationRequests(requester, status);
res.status(200).json(result);
} catch (error) {
const status = error.message === 'noaccess' ? 403 : 500;
res.status(status).json({ error: error.message });
}
}
async setAdultVerificationStatus(req, res) {
const schema = Joi.object({
status: Joi.string().valid('approved', 'rejected', 'pending').required()
});
const { error, value } = schema.validate(req.body || {});
if (error) {
return res.status(400).json({ error: error.details[0].message });
}
try {
const { userid: requester } = req.headers;
const { id } = req.params;
const result = await AdminService.setAdultVerificationStatus(requester, id, value.status);
res.status(200).json(result);
} catch (err) {
const status = err.message === 'noaccess' ? 403 : (['notfound', 'notadult', 'wrongstatus', 'missingparamtype'].includes(err.message) ? 400 : 500);
res.status(status).json({ error: err.message });
}
}
async getAdultVerificationDocument(req, res) {
try {
const { userid: requester } = req.headers;
const { id } = req.params;
const result = await AdminService.getAdultVerificationDocument(requester, id);
res.setHeader('Content-Type', result.mimeType);
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(result.originalName)}"`);
res.sendFile(result.filePath);
} catch (err) {
const status = err.message === 'noaccess' ? 403 : (['notfound', 'norequest', 'nofile'].includes(err.message) ? 404 : 500);
res.status(status).json({ error: err.message });
}
}
async getEroticModerationReports(req, res) {
try {
const { userid: requester } = req.headers;
const { status = 'open' } = req.query;
const result = await AdminService.getEroticModerationReports(requester, status);
res.status(200).json(result);
} catch (error) {
const status = error.message === 'noaccess' ? 403 : 500;
res.status(status).json({ error: error.message });
}
}
async applyEroticModerationAction(req, res) {
const schema = Joi.object({
action: Joi.string().valid('dismiss', 'hide_content', 'restore_content', 'delete_content', 'block_uploads', 'revoke_access').required(),
note: Joi.string().allow('', null).max(2000).optional()
});
const { error, value } = schema.validate(req.body || {});
if (error) {
return res.status(400).json({ error: error.details[0].message });
}
try {
const { userid: requester } = req.headers;
const { id } = req.params;
const result = await AdminService.applyEroticModerationAction(requester, Number(id), value.action, value.note || null);
res.status(200).json(result);
} catch (err) {
const status = err.message === 'noaccess' ? 403 : (['notfound', 'targetnotfound', 'wrongaction'].includes(err.message) ? 400 : 500);
res.status(status).json({ error: err.message });
}
}
async getEroticModerationPreview(req, res) {
try {
const { userid: requester } = req.headers;
const { type, targetId } = req.params;
const result = await AdminService.getEroticModerationPreview(requester, type, Number(targetId));
res.setHeader('Content-Type', result.mimeType);
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(result.originalName)}"`);
res.sendFile(result.filePath);
} catch (err) {
const status = err.message === 'noaccess' ? 403 : (['notfound', 'nofile', 'wrongtype'].includes(err.message) ? 404 : 500);
res.status(status).json({ error: err.message });
}
}
// --- Rights ---
async listRightTypes(req, res) {
try {
@@ -523,6 +620,7 @@ class AdminController {
title: Joi.string().min(1).max(255).required(),
roomTypeId: Joi.number().integer().required(),
isPublic: Joi.boolean().required(),
isAdultOnly: Joi.boolean().allow(null),
genderRestrictionId: Joi.number().integer().allow(null),
minAge: Joi.number().integer().min(0).allow(null),
maxAge: Joi.number().integer().min(0).allow(null),
@@ -534,7 +632,7 @@ class AdminController {
if (error) {
return res.status(400).json({ error: error.details[0].message });
}
const room = await AdminService.updateRoom(req.params.id, value);
const room = await AdminService.updateRoom(userId, req.params.id, value);
res.status(200).json(room);
} catch (error) {
console.log(error);
@@ -553,6 +651,7 @@ class AdminController {
title: Joi.string().min(1).max(255).required(),
roomTypeId: Joi.number().integer().required(),
isPublic: Joi.boolean().required(),
isAdultOnly: Joi.boolean().allow(null),
genderRestrictionId: Joi.number().integer().allow(null),
minAge: Joi.number().integer().min(0).allow(null),
maxAge: Joi.number().integer().min(0).allow(null),
@@ -579,7 +678,7 @@ class AdminController {
if (!userId || !(await AdminService.hasUserAccess(userId, 'chatrooms'))) {
return res.status(403).json({ error: 'Keine Berechtigung.' });
}
await AdminService.deleteRoom(req.params.id);
await AdminService.deleteRoom(userId, req.params.id);
res.sendStatus(204);
} catch (error) {
console.log(error);

View File

@@ -172,7 +172,9 @@ class ChatController {
async getRoomList(req, res) {
// Öffentliche Räume für Chat-Frontend
try {
const rooms = await chatService.getRoomList();
const { userid: hashedUserId } = req.headers;
const adultOnly = String(req.query.adultOnly || '').toLowerCase() === 'true';
const rooms = await chatService.getRoomList(hashedUserId, { adultOnly });
res.status(200).json(rooms);
} catch (error) {
res.status(500).json({ error: error.message });

View File

@@ -29,30 +29,30 @@ class FalukantController {
// Dashboard widget: originaler Endpoint (siehe Commit 62d8cd7)
this.getDashboardWidget = this._wrapWithUser((userId) => this.service.getDashboardWidget(userId));
this.getBranches = this._wrapWithUser((userId) => this.service.getBranches(userId));
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), { blockInDebtorsPrison: true });
this.getBranchTypes = this._wrapWithUser((userId) => this.service.getBranchTypes(userId));
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.upgradeBranch = this._wrapWithUser((userId, req) => this.service.upgradeBranch(userId, req.body.branchId), { blockInDebtorsPrison: true });
this.createProduction = this._wrapWithUser((userId, req) => {
const { branchId, productId, quantity } = req.body;
return this.service.createProduction(userId, branchId, productId, quantity);
}, { successStatus: 201 });
}, { successStatus: 201, blockInDebtorsPrison: true });
this.getProduction = this._wrapWithUser((userId, req) => this.service.getProduction(userId, req.params.branchId));
this.getStock = this._wrapWithUser((userId, req) => this.service.getStock(userId, req.params.branchId || null));
this.createStock = this._wrapWithUser((userId, req) => {
const { branchId, stockTypeId, stockSize } = req.body;
return this.service.createStock(userId, branchId, stockTypeId, stockSize);
}, { successStatus: 201 });
}, { successStatus: 201, blockInDebtorsPrison: true });
this.getProducts = this._wrapWithUser((userId) => this.service.getProducts(userId));
this.getInventory = this._wrapWithUser((userId, req) => this.service.getInventory(userId, req.params.branchId));
this.sellProduct = this._wrapWithUser((userId, req) => {
const { branchId, productId, quality, quantity } = req.body;
return this.service.sellProduct(userId, branchId, productId, quality, quantity);
}, { successStatus: 201 });
}, { successStatus: 201, blockInDebtorsPrison: true });
this.sellAllProducts = this._wrapWithUser((userId, req) => {
const { branchId } = req.body;
return this.service.sellAllProducts(userId, branchId);
}, { successStatus: 201 });
}, { successStatus: 201, blockInDebtorsPrison: true });
this.moneyHistory = this._wrapWithUser((userId, req) => {
let { page, filter } = req.body;
if (!page) page = 1;
@@ -66,11 +66,11 @@ class FalukantController {
this.buyStorage = this._wrapWithUser((userId, req) => {
const { branchId, amount, stockTypeId } = req.body;
return this.service.buyStorage(userId, branchId, amount, stockTypeId);
}, { successStatus: 201 });
}, { successStatus: 201, blockInDebtorsPrison: true });
this.sellStorage = this._wrapWithUser((userId, req) => {
const { branchId, amount, stockTypeId } = req.body;
return this.service.sellStorage(userId, branchId, amount, stockTypeId);
}, { successStatus: 202 });
}, { successStatus: 202, blockInDebtorsPrison: true });
this.getStockTypes = this._wrapSimple(() => this.service.getStockTypes());
this.getStockOverview = this._wrapSimple(() => this.service.getStockOverview());
@@ -80,18 +80,18 @@ class FalukantController {
console.log('🔍 getDirectorProposals called with userId:', userId, 'branchId:', req.body.branchId);
return this.service.getDirectorProposals(userId, req.body.branchId);
});
this.convertProposalToDirector = this._wrapWithUser((userId, req) => this.service.convertProposalToDirector(userId, req.body.proposalId));
this.convertProposalToDirector = this._wrapWithUser((userId, req) => this.service.convertProposalToDirector(userId, req.body.proposalId), { blockInDebtorsPrison: true });
this.getDirectorForBranch = this._wrapWithUser((userId, req) => this.service.getDirectorForBranch(userId, req.params.branchId));
this.getAllDirectors = this._wrapWithUser((userId) => this.service.getAllDirectors(userId));
this.updateDirector = this._wrapWithUser((userId, req) => {
const { directorId, income } = req.body;
return this.service.updateDirector(userId, directorId, income);
});
}, { blockInDebtorsPrison: true });
this.setSetting = this._wrapWithUser((userId, req) => {
const { branchId, directorId, settingKey, value } = req.body;
return this.service.setSetting(userId, branchId, directorId, settingKey, value);
});
}, { blockInDebtorsPrison: true });
this.getFamily = this._wrapWithUser(async (userId) => {
const result = await this.service.getFamily(userId);
@@ -99,9 +99,9 @@ class FalukantController {
return result;
});
this.getPotentialHeirs = this._wrapWithUser((userId) => this.service.getPotentialHeirs(userId));
this.selectHeir = this._wrapWithUser((userId, req) => this.service.selectHeir(userId, req.body.heirId));
this.setHeir = this._wrapWithUser((userId, req) => this.service.setHeir(userId, req.body.childCharacterId));
this.acceptMarriageProposal = this._wrapWithUser((userId, req) => this.service.acceptMarriageProposal(userId, req.body.proposalId));
this.selectHeir = this._wrapWithUser((userId, req) => this.service.selectHeir(userId, req.body.heirId), { blockInDebtorsPrison: true });
this.setHeir = this._wrapWithUser((userId, req) => this.service.setHeir(userId, req.body.childCharacterId), { blockInDebtorsPrison: true });
this.acceptMarriageProposal = this._wrapWithUser((userId, req) => this.service.acceptMarriageProposal(userId, req.body.proposalId), { blockInDebtorsPrison: true });
this.cancelWooing = this._wrapWithUser(async (userId) => {
try {
return await this.service.cancelWooing(userId);
@@ -111,11 +111,25 @@ class FalukantController {
}
throw e;
}
}, { successStatus: 202 });
}, { successStatus: 202, blockInDebtorsPrison: true });
this.getGifts = this._wrapWithUser((userId) => {
console.log('🔍 getGifts called with userId:', userId);
return this.service.getGifts(userId);
});
this.setLoverMaintenance = this._wrapWithUser((userId, req) =>
this.service.setLoverMaintenance(userId, req.params.relationshipId, req.body?.maintenanceLevel), { blockInDebtorsPrison: true });
this.createLoverRelationship = this._wrapWithUser((userId, req) =>
this.service.createLoverRelationship(userId, req.body?.targetCharacterId, req.body?.loverRole), { successStatus: 201, blockInDebtorsPrison: true });
this.spendTimeWithSpouse = this._wrapWithUser((userId) =>
this.service.spendTimeWithSpouse(userId), { blockInDebtorsPrison: true });
this.giftToSpouse = this._wrapWithUser((userId, req) =>
this.service.giftToSpouse(userId, req.body?.giftLevel), { blockInDebtorsPrison: true });
this.reconcileMarriage = this._wrapWithUser((userId) =>
this.service.reconcileMarriage(userId), { blockInDebtorsPrison: true });
this.acknowledgeLover = this._wrapWithUser((userId, req) =>
this.service.acknowledgeLover(userId, req.params.relationshipId), { blockInDebtorsPrison: true });
this.endLoverRelationship = this._wrapWithUser((userId, req) =>
this.service.endLoverRelationship(userId, req.params.relationshipId), { blockInDebtorsPrison: true });
this.getChildren = this._wrapWithUser((userId) => this.service.getChildren(userId));
this.sendGift = this._wrapWithUser(async (userId, req) => {
try {
@@ -126,55 +140,59 @@ class FalukantController {
}
throw e;
}
});
}, { blockInDebtorsPrison: true });
this.getTitlesOfNobility = this._wrapWithUser((userId) => this.service.getTitlesOfNobility(userId));
this.getReputationActions = this._wrapWithUser((userId) => this.service.getReputationActions(userId));
this.executeReputationAction = this._wrapWithUser((userId, req) =>
this.service.executeReputationAction(userId, req.body?.actionTypeId), { successStatus: 201 });
this.service.executeReputationAction(userId, req.body?.actionTypeId), { successStatus: 201, blockInDebtorsPrison: true });
this.getHouseTypes = this._wrapWithUser((userId) => this.service.getHouseTypes(userId));
this.getMoodAffect = this._wrapWithUser((userId) => this.service.getMoodAffect(userId));
this.getCharacterAffect = this._wrapWithUser((userId) => this.service.getCharacterAffect(userId));
this.getUserHouse = this._wrapWithUser((userId) => this.service.getUserHouse(userId));
this.getBuyableHouses = this._wrapWithUser((userId) => this.service.getBuyableHouses(userId));
this.buyUserHouse = this._wrapWithUser((userId, req) => this.service.buyUserHouse(userId, req.body.houseId), { successStatus: 201 });
this.buyUserHouse = this._wrapWithUser((userId, req) => this.service.buyUserHouse(userId, req.body.houseId), { successStatus: 201, blockInDebtorsPrison: true });
this.hireServants = this._wrapWithUser((userId, req) => this.service.hireServants(userId, req.body?.amount), { successStatus: 201, blockInDebtorsPrison: true });
this.dismissServants = this._wrapWithUser((userId, req) => this.service.dismissServants(userId, req.body?.amount), { blockInDebtorsPrison: true });
this.setServantPayLevel = this._wrapWithUser((userId, req) => this.service.setServantPayLevel(userId, req.body?.payLevel), { blockInDebtorsPrison: true });
this.tidyHousehold = this._wrapWithUser((userId) => this.service.tidyHousehold(userId), { blockInDebtorsPrison: true });
this.getPartyTypes = this._wrapWithUser((userId) => this.service.getPartyTypes(userId));
this.createParty = this._wrapWithUser((userId, req) => {
const { partyTypeId, musicId, banquetteId, nobilityIds, servantRatio } = req.body;
return this.service.createParty(userId, partyTypeId, musicId, banquetteId, nobilityIds, servantRatio);
}, { successStatus: 201 });
}, { successStatus: 201, blockInDebtorsPrison: true });
this.getParties = this._wrapWithUser((userId) => this.service.getParties(userId));
this.getNotBaptisedChildren = this._wrapWithUser((userId) => this.service.getNotBaptisedChildren(userId));
this.baptise = this._wrapWithUser((userId, req) => {
const { characterId: childId, firstName } = req.body;
return this.service.baptise(userId, childId, firstName);
});
}, { blockInDebtorsPrison: true });
this.getChurchOverview = this._wrapWithUser((userId) => this.service.getChurchOverview(userId));
this.getAvailableChurchPositions = this._wrapWithUser((userId) => this.service.getAvailableChurchPositions(userId));
this.getSupervisedApplications = this._wrapWithUser((userId) => this.service.getSupervisedApplications(userId));
this.applyForChurchPosition = this._wrapWithUser((userId, req) => {
const { officeTypeId, regionId } = req.body;
return this.service.applyForChurchPosition(userId, officeTypeId, regionId);
});
}, { blockInDebtorsPrison: true });
this.decideOnChurchApplication = this._wrapWithUser((userId, req) => {
const { applicationId, decision } = req.body;
return this.service.decideOnChurchApplication(userId, applicationId, decision);
});
}, { blockInDebtorsPrison: true });
this.getEducation = this._wrapWithUser((userId) => this.service.getEducation(userId));
this.sendToSchool = this._wrapWithUser((userId, req) => {
const { item, student, studentId } = req.body;
return this.service.sendToSchool(userId, item, student, studentId);
});
}, { blockInDebtorsPrison: true });
this.getBankOverview = this._wrapWithUser((userId) => this.service.getBankOverview(userId));
this.getBankCredits = this._wrapWithUser((userId) => this.service.getBankCredits(userId));
this.takeBankCredits = this._wrapWithUser((userId, req) => this.service.takeBankCredits(userId, req.body.height));
this.takeBankCredits = this._wrapWithUser((userId, req) => this.service.takeBankCredits(userId, req.body.height), { blockInDebtorsPrison: true });
this.getNobility = this._wrapWithUser((userId) => this.service.getNobility(userId));
this.advanceNobility = this._wrapWithUser((userId) => this.service.advanceNobility(userId));
this.advanceNobility = this._wrapWithUser((userId) => this.service.advanceNobility(userId), { blockInDebtorsPrison: true });
this.getHealth = this._wrapWithUser((userId) => this.service.getHealth(userId));
this.healthActivity = this._wrapWithUser(async (userId, req) => {
@@ -186,13 +204,13 @@ class FalukantController {
}
throw e;
}
});
}, { blockInDebtorsPrison: true });
this.getPoliticsOverview = this._wrapWithUser((userId) => this.service.getPoliticsOverview(userId));
this.getOpenPolitics = this._wrapWithUser((userId) => this.service.getOpenPolitics(userId));
this.getElections = this._wrapWithUser((userId) => this.service.getElections(userId));
this.vote = this._wrapWithUser((userId, req) => this.service.vote(userId, req.body.votes));
this.applyForElections = this._wrapWithUser((userId, req) => this.service.applyForElections(userId, req.body.electionIds));
this.vote = this._wrapWithUser((userId, req) => this.service.vote(userId, req.body.votes), { blockInDebtorsPrison: true });
this.applyForElections = this._wrapWithUser((userId, req) => this.service.applyForElections(userId, req.body.electionIds), { blockInDebtorsPrison: true });
this.getRegions = this._wrapWithUser((userId) => this.service.getRegions(userId));
this.getBranchTaxes = this._wrapWithUser((userId, req) => this.service.getBranchTaxes(userId, req.params.branchId));
@@ -230,10 +248,12 @@ class FalukantController {
})).filter(i => !Number.isNaN(i.productId) && !Number.isNaN(i.currentPrice));
return this.service.getProductPricesInCitiesBatch(userId, valid, Number.isNaN(currentRegionId) ? null : currentRegionId);
});
this.renovate = this._wrapWithUser((userId, req) => this.service.renovate(userId, req.body.element));
this.renovateAll = this._wrapWithUser((userId) => this.service.renovateAll(userId));
this.renovate = this._wrapWithUser((userId, req) => this.service.renovate(userId, req.body.element), { blockInDebtorsPrison: true });
this.renovateAll = this._wrapWithUser((userId) => this.service.renovateAll(userId), { blockInDebtorsPrison: true });
this.getUndergroundTypes = this._wrapWithUser((userId) => this.service.getUndergroundTypes(userId));
this.getRaidTransportRegions = this._wrapWithUser((userId) => this.service.getRaidTransportRegions(userId));
this.getUndergroundActivities = this._wrapWithUser((userId) => this.service.getUndergroundActivities(userId));
this.getNotifications = this._wrapWithUser((userId) => this.service.getNotifications(userId));
this.getAllNotifications = this._wrapWithUser((userId, req) => this.service.getAllNotifications(userId, req.query.page, req.query.size));
this.markNotificationsShown = this._wrapWithUser((userId) => this.service.markNotificationsShown(userId), { successStatus: 202 });
@@ -258,7 +278,7 @@ class FalukantController {
throw { status: 400, message: 'goal is required for corrupt_politician' };
}
return this.service.createUndergroundActivity(userId, payload);
}, { successStatus: 201 });
}, { successStatus: 201, blockInDebtorsPrison: true });
this.getUndergroundAttacks = this._wrapWithUser((userId, req) => {
const direction = (req.query.direction || '').toLowerCase();
@@ -272,14 +292,14 @@ 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 }
{ successStatus: 201, blockInDebtorsPrison: true }
);
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 }
{ successStatus: 201, blockInDebtorsPrison: true }
);
this.getTransportRoute = this._wrapWithUser(
(userId, req) => this.service.getTransportRoute(userId, req.query)
@@ -289,23 +309,26 @@ class FalukantController {
);
this.repairVehicle = this._wrapWithUser(
(userId, req) => this.service.repairVehicle(userId, req.params.vehicleId),
{ successStatus: 200 }
{ successStatus: 200, blockInDebtorsPrison: true }
);
this.repairAllVehicles = this._wrapWithUser(
(userId, req) => this.service.repairAllVehicles(userId, req.body.vehicleIds),
{ successStatus: 200 }
{ successStatus: 200, blockInDebtorsPrison: true }
);
}
_wrapWithUser(fn, { successStatus = 200, postProcess } = {}) {
_wrapWithUser(fn, { successStatus = 200, postProcess, blockInDebtorsPrison = false } = {}) {
return async (req, res) => {
try {
const hashedUserId = extractHashedUserId(req);
if (!hashedUserId) {
return res.status(400).json({ error: 'Missing user identifier' });
}
if (blockInDebtorsPrison) {
await this.service.assertActionAllowedOutsideDebtorsPrison(hashedUserId);
}
const result = await fn(hashedUserId, req, res);
const toSend = postProcess ? postProcess(result) : result;
res.status(successStatus).json(toSend);

View File

@@ -96,9 +96,7 @@ const menuStructure = {
},
eroticChat: {
visible: ["over18"],
action: "openEroticChat",
view: "window",
class: "eroticChatWindow"
action: "openEroticChat"
}
}
},
@@ -214,6 +212,10 @@ const menuStructure = {
visible: ["all"],
path: "/settings/account"
},
languageAssistant: {
visible: ["all"],
path: "/settings/language-assistant"
},
personal: {
visible: ["all"],
path: "/settings/personal"
@@ -254,6 +256,14 @@ const menuStructure = {
visible: ["mainadmin", "useradministration"],
path: "/admin/users"
},
adultverification: {
visible: ["mainadmin", "useradministration"],
path: "/admin/users/adult-verification"
},
eroticmoderation: {
visible: ["mainadmin", "useradministration"],
path: "/admin/users/erotic-moderation"
},
userstatistics: {
visible: ["mainadmin"],
path: "/admin/users/statistics"
@@ -339,7 +349,14 @@ class NavigationController {
return age;
}
async filterMenu(menu, rights, age, userId) {
normalizeAdultVerificationStatus(value) {
if (['pending', 'approved', 'rejected'].includes(value)) {
return value;
}
return 'none';
}
async filterMenu(menu, rights, age, userId, adultVerificationStatus = 'none') {
const filteredMenu = {};
try {
const hasFalukantAccount = await this.hasFalukantAccount(userId);
@@ -353,8 +370,17 @@ class NavigationController {
|| (value.visible.includes('hasfalukantaccount') && hasFalukantAccount)) {
const { visible, ...itemWithoutVisible } = value;
filteredMenu[key] = { ...itemWithoutVisible };
if (
value.visible.includes("over18")
&& age >= 18
&& adultVerificationStatus !== 'approved'
&& (value.path || value.action || value.view)
) {
filteredMenu[key].disabled = true;
filteredMenu[key].disabledReasonKey = 'socialnetwork.erotic.lockedShort';
}
if (value.children) {
filteredMenu[key].children = await this.filterMenu(value.children, rights, age, userId);
filteredMenu[key].children = await this.filterMenu(value.children, rights, age, userId, adultVerificationStatus);
}
}
}
@@ -381,20 +407,29 @@ class NavigationController {
required: false
}]
});
const userBirthdateParams = await UserParam.findAll({
const userParams = await UserParam.findAll({
where: { userId: user.id },
include: [
{
model: UserParamType,
as: 'paramType',
where: { description: 'birthdate' }
where: { description: ['birthdate', 'adult_verification_status'] }
}
]
});
const birthDate = userBirthdateParams.length > 0 ? userBirthdateParams[0].value : (new Date()).toDateString();
let birthDate = (new Date()).toDateString();
let adultVerificationStatus = 'none';
for (const param of userParams) {
if (param.paramType?.description === 'birthdate' && param.value) {
birthDate = param.value;
}
if (param.paramType?.description === 'adult_verification_status') {
adultVerificationStatus = this.normalizeAdultVerificationStatus(param.value);
}
}
const age = this.calculateAge(birthDate);
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, adultVerificationStatus);
// Vokabeltrainer: Sprachen werden im Frontend dynamisch geladen (wie Forum)
// Keine children mehr, da das Menü nur 2 Ebenen unterstützt

View File

@@ -185,6 +185,57 @@ class SettingsController {
res.status(500).json({ error: 'Internal server error' });
}
}
async getLlmSettings(req, res) {
try {
const hashedUserId = req.headers.userid;
const data = await settingsService.getLlmSettings(hashedUserId);
res.status(200).json(data);
} catch (error) {
console.error('Error retrieving LLM settings:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
async saveLlmSettings(req, res) {
const schema = Joi.object({
baseUrl: Joi.string().allow('').optional(),
model: Joi.string().allow('').optional(),
enabled: Joi.boolean().optional(),
apiKey: Joi.string().allow('').optional(),
clearKey: Joi.boolean().optional()
});
const { error, value } = schema.validate(req.body || {});
if (error) {
return res.status(400).json({ error: error.details[0].message });
}
try {
await settingsService.saveLlmSettings(req.headers.userid, value);
res.status(200).json({ success: true });
} catch (err) {
console.error('Error saving LLM settings:', err);
res.status(500).json({ error: 'Internal server error' });
}
}
async submitAdultVerificationRequest(req, res) {
try {
const hashedUserId = req.headers.userid;
const note = req.body?.note || '';
const file = req.file || null;
const result = await settingsService.submitAdultVerificationRequest(hashedUserId, { note }, file);
res.status(200).json(result);
} catch (error) {
console.error('Error submitting adult verification request:', error);
const status = [
'User not found',
'Adult verification can only be requested by adult users',
'No verification document provided',
'Unsupported verification document type'
].includes(error.message) ? 400 : 500;
res.status(status).json({ error: error.message });
}
}
}
export default SettingsController;

View File

@@ -15,6 +15,19 @@ class SocialNetworkController {
this.changeImage = this.changeImage.bind(this);
this.getFoldersByUsername = this.getFoldersByUsername.bind(this);
this.deleteFolder = this.deleteFolder.bind(this);
this.getAdultFolders = this.getAdultFolders.bind(this);
this.getAdultFoldersByUsername = this.getAdultFoldersByUsername.bind(this);
this.createAdultFolder = this.createAdultFolder.bind(this);
this.getAdultFolderImageList = this.getAdultFolderImageList.bind(this);
this.uploadAdultImage = this.uploadAdultImage.bind(this);
this.getAdultImageByHash = this.getAdultImageByHash.bind(this);
this.changeAdultImage = this.changeAdultImage.bind(this);
this.listEroticVideos = this.listEroticVideos.bind(this);
this.getEroticVideosByUsername = this.getEroticVideosByUsername.bind(this);
this.uploadEroticVideo = this.uploadEroticVideo.bind(this);
this.changeEroticVideo = this.changeEroticVideo.bind(this);
this.getEroticVideoByHash = this.getEroticVideoByHash.bind(this);
this.reportEroticContent = this.reportEroticContent.bind(this);
this.createGuestbookEntry = this.createGuestbookEntry.bind(this);
this.getGuestbookEntries = this.getGuestbookEntries.bind(this);
this.deleteGuestbookEntry = this.deleteGuestbookEntry.bind(this);
@@ -147,8 +160,8 @@ class SocialNetworkController {
try {
const userId = req.headers.userid;
const { imageId } = req.params;
const { title, visibilities } = req.body;
const folderId = await this.socialNetworkService.changeImage(userId, imageId, title, visibilities);
const { title, visibilities, selectedUsers } = req.body;
const folderId = await this.socialNetworkService.changeImage(userId, imageId, title, visibilities, selectedUsers);
console.log('--->', folderId);
res.status(201).json(await this.socialNetworkService.getFolderImageList(userId, folderId));
} catch (error) {
@@ -187,6 +200,177 @@ class SocialNetworkController {
}
}
async getAdultFolders(req, res) {
try {
const userId = req.headers.userid;
const folders = await this.socialNetworkService.getAdultFolders(userId);
res.status(200).json(folders);
} catch (error) {
console.error('Error in getAdultFolders:', error);
res.status(error.status || 500).json({ error: error.message });
}
}
async getAdultFoldersByUsername(req, res) {
try {
const requestingUserId = req.headers.userid;
const { username } = req.params;
const folders = await this.socialNetworkService.getAdultFoldersByUsername(username, requestingUserId);
if (!folders) {
return res.status(404).json({ error: 'No folders found or access denied.' });
}
res.status(200).json(folders);
} catch (error) {
console.error('Error in getAdultFoldersByUsername:', error);
res.status(error.status || 500).json({ error: error.message });
}
}
async createAdultFolder(req, res) {
try {
const userId = req.headers.userid;
const folderData = req.body;
const { folderId } = req.params;
const folder = await this.socialNetworkService.createAdultFolder(userId, folderData, folderId);
res.status(201).json(folder);
} catch (error) {
console.error('Error in createAdultFolder:', error);
res.status(error.status || 500).json({ error: error.message });
}
}
async getAdultFolderImageList(req, res) {
try {
const userId = req.headers.userid;
const { folderId } = req.params;
const images = await this.socialNetworkService.getAdultFolderImageList(userId, folderId);
res.status(200).json(images);
} catch (error) {
console.error('Error in getAdultFolderImageList:', error);
res.status(error.status || 500).json({ error: error.message });
}
}
async uploadAdultImage(req, res) {
try {
const userId = req.headers.userid;
const file = req.file;
const formData = req.body;
const image = await this.socialNetworkService.uploadAdultImage(userId, file, formData);
res.status(201).json(image);
} catch (error) {
console.error('Error in uploadAdultImage:', error);
res.status(error.status || 500).json({ error: error.message });
}
}
async getAdultImageByHash(req, res) {
try {
const userId = req.headers.userid;
const { hash } = req.params;
const filePath = await this.socialNetworkService.getAdultImageFilePath(userId, hash);
res.sendFile(filePath, err => {
if (err) {
console.error('Error sending adult file:', err);
res.status(500).json({ error: 'Error sending file' });
}
});
} catch (error) {
console.error('Error in getAdultImageByHash:', error);
res.status(error.status || 403).json({ error: error.message || 'Access denied or image not found' });
}
}
async changeAdultImage(req, res) {
try {
const userId = req.headers.userid;
const { imageId } = req.params;
const { title, visibilities, selectedUsers } = req.body;
const folderId = await this.socialNetworkService.changeAdultImage(userId, imageId, title, visibilities, selectedUsers);
res.status(201).json(await this.socialNetworkService.getAdultFolderImageList(userId, folderId));
} catch (error) {
console.error('Error in changeAdultImage:', error);
res.status(error.status || 403).json({ error: error.message || 'Access denied or image not found' });
}
}
async listEroticVideos(req, res) {
try {
const userId = req.headers.userid;
const videos = await this.socialNetworkService.listEroticVideos(userId);
res.status(200).json(videos);
} catch (error) {
console.error('Error in listEroticVideos:', error);
res.status(error.status || 500).json({ error: error.message });
}
}
async getEroticVideosByUsername(req, res) {
try {
const userId = req.headers.userid;
const { username } = req.params;
const videos = await this.socialNetworkService.getEroticVideosByUsername(username, userId);
res.status(200).json(videos);
} catch (error) {
console.error('Error in getEroticVideosByUsername:', error);
res.status(error.status || 500).json({ error: error.message });
}
}
async uploadEroticVideo(req, res) {
try {
const userId = req.headers.userid;
const file = req.file;
const formData = req.body;
const video = await this.socialNetworkService.uploadEroticVideo(userId, file, formData);
res.status(201).json(video);
} catch (error) {
console.error('Error in uploadEroticVideo:', error);
res.status(error.status || 500).json({ error: error.message });
}
}
async changeEroticVideo(req, res) {
try {
const userId = req.headers.userid;
const { videoId } = req.params;
const updatedVideo = await this.socialNetworkService.changeEroticVideo(userId, videoId, req.body);
res.status(200).json(updatedVideo);
} catch (error) {
console.error('Error in changeEroticVideo:', error);
res.status(error.status || 500).json({ error: error.message });
}
}
async getEroticVideoByHash(req, res) {
try {
const userId = req.headers.userid;
const { hash } = req.params;
const { filePath, mimeType } = await this.socialNetworkService.getEroticVideoFilePath(userId, hash);
res.type(mimeType);
res.sendFile(filePath, err => {
if (err) {
console.error('Error sending adult video:', err);
res.status(500).json({ error: 'Error sending file' });
}
});
} catch (error) {
console.error('Error in getEroticVideoByHash:', error);
res.status(error.status || 403).json({ error: error.message || 'Access denied or video not found' });
}
}
async reportEroticContent(req, res) {
try {
const userId = req.headers.userid;
const result = await this.socialNetworkService.createEroticContentReport(userId, req.body || {});
res.status(201).json(result);
} catch (error) {
console.error('Error in reportEroticContent:', error);
res.status(error.status || 400).json({ error: error.message });
}
}
async createGuestbookEntry(req, res) {
try {
const { htmlContent, recipientName } = req.body;

View File

@@ -55,6 +55,7 @@ class VocabController {
this.getGrammarExerciseProgress = this._wrapWithUser((userId, req) => this.service.getGrammarExerciseProgress(userId, req.params.lessonId));
this.updateGrammarExercise = this._wrapWithUser((userId, req) => this.service.updateGrammarExercise(userId, req.params.exerciseId, req.body));
this.deleteGrammarExercise = this._wrapWithUser((userId, req) => this.service.deleteGrammarExercise(userId, req.params.exerciseId));
this.sendLessonAssistantMessage = this._wrapWithUser((userId, req) => this.service.sendLessonAssistantMessage(userId, req.params.lessonId, req.body), { successStatus: 201 });
}
_wrapWithUser(fn, { successStatus = 200 } = {}) {
@@ -77,4 +78,3 @@ class VocabController {
export default VocabController;

21
backend/env.example Normal file
View File

@@ -0,0 +1,21 @@
# Kopie nach `backend/.env` — nicht committen.
#
# Produktion / direkter DB-Host steht typischerweise in `.env`.
# Für Entwicklung mit SSH-Tunnel: Datei `backend/.env.local` anlegen (wird nach `.env`
# geladen und überschreibt). So bleibt `.env` mit echtem Host, Tunnel nur lokal.
#
# Beispiel backend/.env.local:
# DB_HOST=127.0.0.1
# DB_PORT=60000
# # DB_SSL=0
# (Tunnel z. B.: ssh -L 60000:127.0.0.1:5432 user@server)
#
DB_NAME=
DB_USER=
DB_PASS=
# DB_HOST=
# DB_PORT=5432
# DB_SSL=0
#
# Optional (Defaults siehe utils/sequelize.js)
# DB_CONNECT_TIMEOUT_MS=30000

View File

@@ -0,0 +1,5 @@
# Kopie nach backend/.env.local (liegt neben .env, wird nicht committet).
# Überschreibt nur bei dir lokal z. B. SSH-Tunnel — .env kann weiter DB_HOST=tsschulz.de haben.
DB_HOST=127.0.0.1
DB_PORT=60000

View File

@@ -0,0 +1,122 @@
/* eslint-disable */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
CREATE TABLE IF NOT EXISTS falukant_data.relationship_state (
id serial PRIMARY KEY,
relationship_id integer NOT NULL UNIQUE,
marriage_satisfaction integer NOT NULL DEFAULT 55 CHECK (marriage_satisfaction >= 0 AND marriage_satisfaction <= 100),
marriage_public_stability integer NOT NULL DEFAULT 55 CHECK (marriage_public_stability >= 0 AND marriage_public_stability <= 100),
lover_role text NULL CHECK (lover_role IN ('secret_affair', 'lover', 'mistress_or_favorite')),
affection integer NOT NULL DEFAULT 50 CHECK (affection >= 0 AND affection <= 100),
visibility integer NOT NULL DEFAULT 15 CHECK (visibility >= 0 AND visibility <= 100),
discretion integer NOT NULL DEFAULT 50 CHECK (discretion >= 0 AND discretion <= 100),
maintenance_level integer NOT NULL DEFAULT 50 CHECK (maintenance_level >= 0 AND maintenance_level <= 100),
status_fit integer NOT NULL DEFAULT 0 CHECK (status_fit >= -2 AND status_fit <= 2),
monthly_base_cost integer NOT NULL DEFAULT 0 CHECK (monthly_base_cost >= 0),
months_underfunded integer NOT NULL DEFAULT 0 CHECK (months_underfunded >= 0),
active boolean NOT NULL DEFAULT true,
acknowledged boolean NOT NULL DEFAULT false,
exclusive_flag boolean NOT NULL DEFAULT false,
last_monthly_processed_at timestamp with time zone NULL,
last_daily_processed_at timestamp with time zone NULL,
notes_json jsonb NULL,
flags_json jsonb NULL,
created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT relationship_state_relationship_fk
FOREIGN KEY (relationship_id)
REFERENCES falukant_data.relationship(id)
ON DELETE CASCADE
);
`);
await queryInterface.sequelize.query(`
CREATE INDEX IF NOT EXISTS relationship_state_active_idx
ON falukant_data.relationship_state (active);
`);
await queryInterface.sequelize.query(`
CREATE INDEX IF NOT EXISTS relationship_state_lover_role_idx
ON falukant_data.relationship_state (lover_role);
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.child_relation
ADD COLUMN IF NOT EXISTS legitimacy text NOT NULL DEFAULT 'legitimate';
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.child_relation
ADD COLUMN IF NOT EXISTS birth_context text NOT NULL DEFAULT 'marriage';
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.child_relation
ADD COLUMN IF NOT EXISTS public_known boolean NOT NULL DEFAULT false;
`);
await queryInterface.sequelize.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'child_relation_legitimacy_chk'
) THEN
ALTER TABLE falukant_data.child_relation
ADD CONSTRAINT child_relation_legitimacy_chk
CHECK (legitimacy IN ('legitimate', 'acknowledged_bastard', 'hidden_bastard'));
END IF;
END
$$;
`);
await queryInterface.sequelize.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'child_relation_birth_context_chk'
) THEN
ALTER TABLE falukant_data.child_relation
ADD CONSTRAINT child_relation_birth_context_chk
CHECK (birth_context IN ('marriage', 'lover'));
END IF;
END
$$;
`);
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.child_relation
DROP CONSTRAINT IF EXISTS child_relation_birth_context_chk;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.child_relation
DROP CONSTRAINT IF EXISTS child_relation_legitimacy_chk;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.child_relation
DROP COLUMN IF EXISTS public_known;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.child_relation
DROP COLUMN IF EXISTS birth_context;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.child_relation
DROP COLUMN IF EXISTS legitimacy;
`);
await queryInterface.sequelize.query(`
DROP TABLE IF EXISTS falukant_data.relationship_state;
`);
},
};

View File

@@ -0,0 +1,60 @@
/* eslint-disable */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
INSERT INTO falukant_data.relationship_state (
relationship_id,
marriage_satisfaction,
marriage_public_stability,
lover_role,
affection,
visibility,
discretion,
maintenance_level,
status_fit,
monthly_base_cost,
months_underfunded,
active,
acknowledged,
exclusive_flag,
created_at,
updated_at
)
SELECT
r.id,
55,
55,
CASE WHEN rt.tr = 'lover' THEN 'lover' ELSE NULL END,
50,
CASE WHEN rt.tr = 'lover' THEN 20 ELSE 15 END,
CASE WHEN rt.tr = 'lover' THEN 45 ELSE 50 END,
50,
0,
CASE WHEN rt.tr = 'lover' THEN 30 ELSE 0 END,
0,
true,
false,
false,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM falukant_data.relationship r
INNER JOIN falukant_type.relationship rt
ON rt.id = r.relationship_type_id
LEFT JOIN falukant_data.relationship_state rs
ON rs.relationship_id = r.id
WHERE rs.id IS NULL
AND rt.tr IN ('lover', 'wooing', 'engaged', 'married');
`);
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
DELETE FROM falukant_data.relationship_state rs
USING falukant_data.relationship r
INNER JOIN falukant_type.relationship rt
ON rt.id = r.relationship_type_id
WHERE rs.relationship_id = r.id
AND rt.tr IN ('lover', 'wooing', 'engaged', 'married');
`);
},
};

View File

@@ -0,0 +1,53 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn(
{ schema: 'falukant_data', tableName: 'user_house' },
'servant_count',
{
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0
}
);
await queryInterface.addColumn(
{ schema: 'falukant_data', tableName: 'user_house' },
'servant_quality',
{
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 50
}
);
await queryInterface.addColumn(
{ schema: 'falukant_data', tableName: 'user_house' },
'servant_pay_level',
{
type: Sequelize.STRING(20),
allowNull: false,
defaultValue: 'normal'
}
);
await queryInterface.addColumn(
{ schema: 'falukant_data', tableName: 'user_house' },
'household_order',
{
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 55
}
);
},
async down(queryInterface) {
await queryInterface.removeColumn({ schema: 'falukant_data', tableName: 'user_house' }, 'household_order');
await queryInterface.removeColumn({ schema: 'falukant_data', tableName: 'user_house' }, 'servant_pay_level');
await queryInterface.removeColumn({ schema: 'falukant_data', tableName: 'user_house' }, 'servant_quality');
await queryInterface.removeColumn({ schema: 'falukant_data', tableName: 'user_house' }, 'servant_count');
}
};

View File

@@ -0,0 +1,36 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn(
{ schema: 'falukant_data', tableName: 'user_house' },
'household_tension_score',
{
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 10
}
);
await queryInterface.addColumn(
{ schema: 'falukant_data', tableName: 'user_house' },
'household_tension_reasons_json',
{
type: Sequelize.JSONB,
allowNull: true
}
);
},
async down(queryInterface) {
await queryInterface.removeColumn(
{ schema: 'falukant_data', tableName: 'user_house' },
'household_tension_reasons_json'
);
await queryInterface.removeColumn(
{ schema: 'falukant_data', tableName: 'user_house' },
'household_tension_score'
);
}
};

View File

@@ -0,0 +1,83 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
const table = { schema: 'falukant_data', tableName: 'debtors_prism' };
await queryInterface.addColumn(table, 'status', {
type: Sequelize.STRING,
allowNull: false,
defaultValue: 'delinquent'
});
await queryInterface.addColumn(table, 'entered_at', {
type: Sequelize.DATE,
allowNull: true
});
await queryInterface.addColumn(table, 'released_at', {
type: Sequelize.DATE,
allowNull: true
});
await queryInterface.addColumn(table, 'debt_at_entry', {
type: Sequelize.DECIMAL(14, 2),
allowNull: true
});
await queryInterface.addColumn(table, 'remaining_debt', {
type: Sequelize.DECIMAL(14, 2),
allowNull: true
});
await queryInterface.addColumn(table, 'days_overdue', {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0
});
await queryInterface.addColumn(table, 'reason', {
type: Sequelize.STRING,
allowNull: true
});
await queryInterface.addColumn(table, 'creditworthiness_penalty', {
type: Sequelize.INTEGER,
allowNull: false,
defaultValue: 0
});
await queryInterface.addColumn(table, 'next_forced_action', {
type: Sequelize.STRING,
allowNull: true
});
await queryInterface.addColumn(table, 'assets_seized_json', {
type: Sequelize.JSONB,
allowNull: true
});
await queryInterface.addColumn(table, 'public_known', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false
});
},
async down(queryInterface) {
const table = { schema: 'falukant_data', tableName: 'debtors_prism' };
await queryInterface.removeColumn(table, 'public_known');
await queryInterface.removeColumn(table, 'assets_seized_json');
await queryInterface.removeColumn(table, 'next_forced_action');
await queryInterface.removeColumn(table, 'creditworthiness_penalty');
await queryInterface.removeColumn(table, 'reason');
await queryInterface.removeColumn(table, 'days_overdue');
await queryInterface.removeColumn(table, 'remaining_debt');
await queryInterface.removeColumn(table, 'debt_at_entry');
await queryInterface.removeColumn(table, 'released_at');
await queryInterface.removeColumn(table, 'entered_at');
await queryInterface.removeColumn(table, 'status');
}
};

View File

@@ -0,0 +1,22 @@
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.transport
ADD COLUMN IF NOT EXISTS guard_count integer NOT NULL DEFAULT 0;
`);
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.underground
ALTER COLUMN victim_id DROP NOT NULL;
`);
},
async down(queryInterface, Sequelize) {
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.transport
DROP COLUMN IF EXISTS guard_count;
`);
}
};

View File

@@ -0,0 +1,36 @@
/* eslint-disable */
'use strict';
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
ALTER TABLE community.vocab_course_lesson
ADD COLUMN IF NOT EXISTS learning_goals JSONB,
ADD COLUMN IF NOT EXISTS core_patterns JSONB,
ADD COLUMN IF NOT EXISTS grammar_focus JSONB,
ADD COLUMN IF NOT EXISTS speaking_prompts JSONB,
ADD COLUMN IF NOT EXISTS practical_tasks JSONB;
`);
await queryInterface.sequelize.query(`
INSERT INTO community.vocab_grammar_exercise_type (name, description) VALUES
('dialog_completion', 'Dialogergänzung'),
('situational_response', 'Situative Antwort'),
('pattern_drill', 'Muster-Drill'),
('reading_aloud', 'Lautlese-Übung'),
('speaking_from_memory', 'Freies Sprechen')
ON CONFLICT (name) DO NOTHING;
`);
},
async down(queryInterface) {
await queryInterface.sequelize.query(`
ALTER TABLE community.vocab_course_lesson
DROP COLUMN IF EXISTS practical_tasks,
DROP COLUMN IF EXISTS speaking_prompts,
DROP COLUMN IF EXISTS grammar_focus,
DROP COLUMN IF EXISTS core_patterns,
DROP COLUMN IF EXISTS learning_goals;
`);
}
};

View File

@@ -0,0 +1,18 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
ALTER TABLE community.user_param
ALTER COLUMN value TYPE TEXT;
`);
},
async down(queryInterface) {
await queryInterface.sequelize.query(`
ALTER TABLE community.user_param
ALTER COLUMN value TYPE VARCHAR(255);
`);
}
};

View File

@@ -0,0 +1,31 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn(
{ schema: 'community', tableName: 'folder' },
'is_adult_area',
{
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
},
);
await queryInterface.addColumn(
{ schema: 'community', tableName: 'image' },
'is_adult_content',
{
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
},
);
},
async down(queryInterface) {
await queryInterface.removeColumn({ schema: 'community', tableName: 'image' }, 'is_adult_content');
await queryInterface.removeColumn({ schema: 'community', tableName: 'folder' }, 'is_adult_area');
},
};

View File

@@ -0,0 +1,63 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable(
{ schema: 'community', tableName: 'erotic_video' },
{
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
title: {
type: Sequelize.STRING,
allowNull: false,
},
description: {
type: Sequelize.TEXT,
allowNull: true,
},
original_file_name: {
type: Sequelize.STRING,
allowNull: false,
},
hash: {
type: Sequelize.STRING,
allowNull: false,
unique: true,
},
mime_type: {
type: Sequelize.STRING,
allowNull: false,
},
user_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: { schema: 'community', tableName: 'user' },
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
}
);
},
async down(queryInterface) {
await queryInterface.dropTable({ schema: 'community', tableName: 'erotic_video' });
},
};

View File

@@ -0,0 +1,20 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn(
{ schema: 'chat', tableName: 'room' },
'is_adult_only',
{
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
},
);
},
async down(queryInterface) {
await queryInterface.removeColumn({ schema: 'chat', tableName: 'room' }, 'is_adult_only');
},
};

View File

@@ -0,0 +1,95 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn(
{ schema: 'community', tableName: 'image' },
'is_moderated_hidden',
{
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false
}
).catch(() => {});
await queryInterface.addColumn(
{ schema: 'community', tableName: 'erotic_video' },
'is_moderated_hidden',
{
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false
}
).catch(() => {});
await queryInterface.createTable(
{ schema: 'community', tableName: 'erotic_content_report' },
{
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false
},
reporter_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: { model: { schema: 'community', tableName: 'user' }, key: 'id' },
onDelete: 'CASCADE'
},
target_type: {
type: Sequelize.STRING(20),
allowNull: false
},
target_id: {
type: Sequelize.INTEGER,
allowNull: false
},
reason: {
type: Sequelize.STRING(80),
allowNull: false
},
note: {
type: Sequelize.TEXT,
allowNull: true
},
status: {
type: Sequelize.STRING(20),
allowNull: false,
defaultValue: 'open'
},
action_taken: {
type: Sequelize.STRING(40),
allowNull: true
},
handled_by: {
type: Sequelize.INTEGER,
allowNull: true,
references: { model: { schema: 'community', tableName: 'user' }, key: 'id' },
onDelete: 'SET NULL'
},
handled_at: {
type: Sequelize.DATE,
allowNull: true
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('NOW()')
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('NOW()')
}
}
).catch(() => {});
},
async down(queryInterface) {
await queryInterface.dropTable({ schema: 'community', tableName: 'erotic_content_report' }).catch(() => {});
await queryInterface.removeColumn({ schema: 'community', tableName: 'erotic_video' }, 'is_moderated_hidden').catch(() => {});
await queryInterface.removeColumn({ schema: 'community', tableName: 'image' }, 'is_moderated_hidden').catch(() => {});
}
};

View File

@@ -0,0 +1,89 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable(
{ schema: 'community', tableName: 'erotic_video_image_visibility' },
{
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
erotic_video_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: { schema: 'community', tableName: 'erotic_video' },
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
visibility_type_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: { schema: 'type', tableName: 'image_visibility' },
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
}
);
await queryInterface.createTable(
{ schema: 'community', tableName: 'erotic_video_visibility_user' },
{
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
erotic_video_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: { schema: 'community', tableName: 'erotic_video' },
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
user_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: { schema: 'community', tableName: 'user' },
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
}
);
await queryInterface.sequelize.query(`
INSERT INTO community.erotic_video_image_visibility (erotic_video_id, visibility_type_id)
SELECT ev.id, iv.id
FROM community.erotic_video ev
CROSS JOIN type.image_visibility iv
WHERE iv.description = 'adults'
AND NOT EXISTS (
SELECT 1
FROM community.erotic_video_image_visibility eviv
WHERE eviv.erotic_video_id = ev.id
AND eviv.visibility_type_id = iv.id
)
`);
},
async down(queryInterface) {
await queryInterface.dropTable({ schema: 'community', tableName: 'erotic_video_visibility_user' });
await queryInterface.dropTable({ schema: 'community', tableName: 'erotic_video_image_visibility' });
},
};

View File

@@ -18,11 +18,15 @@ import UserParamVisibilityType from './type/user_param_visibility.js';
import UserParamVisibility from './community/user_param_visibility.js';
import Folder from './community/folder.js';
import Image from './community/image.js';
import EroticVideo from './community/erotic_video.js';
import EroticContentReport from './community/erotic_content_report.js';
import ImageVisibilityType from './type/image_visibility.js';
import ImageVisibilityUser from './community/image_visibility_user.js';
import FolderImageVisibility from './community/folder_image_visibility.js';
import ImageImageVisibility from './community/image_image_visibility.js';
import FolderVisibilityUser from './community/folder_visibility_user.js';
import EroticVideoImageVisibility from './community/erotic_video_image_visibility.js';
import EroticVideoVisibilityUser from './community/erotic_video_visibility_user.js';
import GuestbookEntry from './community/guestbook.js';
import Forum from './forum/forum.js';
import Title from './forum/title.js';
@@ -68,6 +72,7 @@ import PromotionalGiftCharacterTrait from './falukant/predefine/promotional_gift
import PromotionalGiftMood from './falukant/predefine/promotional_gift_mood.js';
import RelationshipType from './falukant/type/relationship.js';
import Relationship from './falukant/data/relationship.js';
import RelationshipState from './falukant/data/relationship_state.js';
import PromotionalGiftLog from './falukant/log/promotional_gift.js';
import HouseType from './falukant/type/house.js';
import BuyableHouse from './falukant/data/buyable_house.js';
@@ -208,6 +213,14 @@ export default function setupAssociations() {
Image.belongsTo(User, { foreignKey: 'userId' });
User.hasMany(Image, { foreignKey: 'userId' });
EroticVideo.belongsTo(User, { foreignKey: 'userId', as: 'owner' });
User.hasMany(EroticVideo, { foreignKey: 'userId', as: 'eroticVideos' });
EroticContentReport.belongsTo(User, { foreignKey: 'reporterId', as: 'reporter' });
User.hasMany(EroticContentReport, { foreignKey: 'reporterId', as: 'eroticContentReports' });
EroticContentReport.belongsTo(User, { foreignKey: 'handledBy', as: 'moderator' });
User.hasMany(EroticContentReport, { foreignKey: 'handledBy', as: 'handledEroticContentReports' });
// Image visibility associations
Folder.belongsToMany(ImageVisibilityType, {
through: FolderImageVisibility,
@@ -231,6 +244,17 @@ export default function setupAssociations() {
otherKey: 'imageId'
});
EroticVideo.belongsToMany(ImageVisibilityType, {
through: EroticVideoImageVisibility,
foreignKey: 'eroticVideoId',
otherKey: 'visibilityTypeId'
});
ImageVisibilityType.belongsToMany(EroticVideo, {
through: EroticVideoImageVisibility,
foreignKey: 'visibilityTypeId',
otherKey: 'eroticVideoId'
});
Folder.belongsToMany(ImageVisibilityUser, {
through: FolderVisibilityUser,
foreignKey: 'folderId',
@@ -242,6 +266,19 @@ export default function setupAssociations() {
otherKey: 'folderId'
});
EroticVideo.belongsToMany(User, {
through: EroticVideoVisibilityUser,
foreignKey: 'eroticVideoId',
otherKey: 'userId',
as: 'selectedVisibilityUsers'
});
User.belongsToMany(EroticVideo, {
through: EroticVideoVisibilityUser,
foreignKey: 'userId',
otherKey: 'eroticVideoId',
as: 'visibleEroticVideos'
});
// Guestbook related associations
User.hasMany(GuestbookEntry, { foreignKey: 'recipientId', as: 'receivedEntries' });
User.hasMany(GuestbookEntry, { foreignKey: 'senderId', as: 'sentEntries' });
@@ -460,6 +497,8 @@ export default function setupAssociations() {
Relationship.belongsTo(FalukantCharacter, { foreignKey: 'character2Id', as: 'character2', });
FalukantCharacter.hasMany(Relationship, { foreignKey: 'character1Id', as: 'relationshipsAsCharacter1', });
FalukantCharacter.hasMany(Relationship, { foreignKey: 'character2Id', as: 'relationshipsAsCharacter2', });
Relationship.hasOne(RelationshipState, { foreignKey: 'relationshipId', as: 'state' });
RelationshipState.belongsTo(Relationship, { foreignKey: 'relationshipId', as: 'relationship' });
PromotionalGiftLog.belongsTo(PromotionalGift, { foreignKey: 'giftId', as: 'gift' });
PromotionalGift.hasMany(PromotionalGiftLog, { foreignKey: 'giftId', as: 'logs' });
@@ -1095,4 +1134,3 @@ export default function setupAssociations() {
CalendarEvent.belongsTo(User, { foreignKey: 'userId', as: 'user' });
User.hasMany(CalendarEvent, { foreignKey: 'userId', as: 'calendarEvents' });
}

View File

@@ -20,6 +20,10 @@ const Room = sequelize.define('Room', {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true},
isAdultOnly: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false},
genderRestrictionId: {
type: DataTypes.INTEGER,
allowNull: true},

View File

@@ -0,0 +1,64 @@
import { Model, DataTypes } from 'sequelize';
import { sequelize } from '../../utils/sequelize.js';
class EroticContentReport extends Model {}
EroticContentReport.init({
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
reporterId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'reporter_id'
},
targetType: {
type: DataTypes.STRING,
allowNull: false,
field: 'target_type'
},
targetId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'target_id'
},
reason: {
type: DataTypes.STRING,
allowNull: false
},
note: {
type: DataTypes.TEXT,
allowNull: true
},
status: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'open'
},
actionTaken: {
type: DataTypes.STRING,
allowNull: true,
field: 'action_taken'
},
handledBy: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'handled_by'
},
handledAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'handled_at'
}
}, {
sequelize,
modelName: 'EroticContentReport',
tableName: 'erotic_content_report',
schema: 'community',
timestamps: true,
underscored: true
});
export default EroticContentReport;

View File

@@ -0,0 +1,55 @@
import { Model, DataTypes } from 'sequelize';
import { sequelize } from '../../utils/sequelize.js';
class EroticVideo extends Model {}
EroticVideo.init({
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
title: {
type: DataTypes.STRING,
allowNull: false
},
description: {
type: DataTypes.TEXT,
allowNull: true
},
originalFileName: {
type: DataTypes.STRING,
allowNull: false,
field: 'original_file_name'
},
hash: {
type: DataTypes.STRING,
allowNull: false,
unique: true
},
mimeType: {
type: DataTypes.STRING,
allowNull: false,
field: 'mime_type'
},
isModeratedHidden: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'is_moderated_hidden'
},
userId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'user_id'
}
}, {
sequelize,
modelName: 'EroticVideo',
tableName: 'erotic_video',
schema: 'community',
timestamps: true,
underscored: true
});
export default EroticVideo;

View File

@@ -0,0 +1,26 @@
import { sequelize } from '../../utils/sequelize.js';
import { DataTypes } from 'sequelize';
const EroticVideoImageVisibility = sequelize.define('erotic_video_image_visibility', {
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
allowNull: false
},
eroticVideoId: {
type: DataTypes.INTEGER,
allowNull: false
},
visibilityTypeId: {
type: DataTypes.INTEGER,
allowNull: false
}
}, {
tableName: 'erotic_video_image_visibility',
timestamps: false,
underscored: true,
schema: 'community'
});
export default EroticVideoImageVisibility;

View File

@@ -0,0 +1,26 @@
import { sequelize } from '../../utils/sequelize.js';
import { DataTypes } from 'sequelize';
const EroticVideoVisibilityUser = sequelize.define('erotic_video_visibility_user', {
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
allowNull: false
},
eroticVideoId: {
type: DataTypes.INTEGER,
allowNull: false
},
userId: {
type: DataTypes.INTEGER,
allowNull: false
}
}, {
tableName: 'erotic_video_visibility_user',
timestamps: false,
underscored: true,
schema: 'community'
});
export default EroticVideoVisibilityUser;

View File

@@ -6,6 +6,11 @@ const Folder = sequelize.define('folder', {
name: {
type: DataTypes.STRING,
allowNull: false},
isAdultArea: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
parentId: {
type: DataTypes.INTEGER,
allowNull: true

View File

@@ -6,6 +6,16 @@ const Image = sequelize.define('image', {
title: {
type: DataTypes.STRING,
allowNull: false},
isAdultContent: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
isModeratedHidden: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
description: {
type: DataTypes.TEXT,
allowNull: true},

View File

@@ -3,6 +3,44 @@ import { DataTypes } from 'sequelize';
import { encrypt, decrypt } from '../../utils/encryption.js';
import crypto from 'crypto';
function encodeEncryptedValueToBlob(value) {
const encrypted = encrypt(value);
return Buffer.from(encrypted, 'utf8');
}
function decodeEncryptedBlob(value) {
if (!value) {
return null;
}
try {
const encryptedUtf8 = value.toString('utf8');
const decryptedUtf8 = decrypt(encryptedUtf8);
if (decryptedUtf8) {
return decryptedUtf8;
}
} catch (error) {
console.warn('Email utf8 decryption failed, trying legacy hex format:', error.message);
}
try {
const encryptedHex = value.toString('hex');
const decryptedHex = decrypt(encryptedHex);
if (decryptedHex) {
return decryptedHex;
}
} catch (error) {
console.warn('Email legacy hex decryption failed:', error.message);
}
try {
return value.toString('utf8');
} catch (error) {
console.warn('Email could not be read as plain text:', error.message);
return null;
}
}
const User = sequelize.define('user', {
email: {
type: DataTypes.BLOB,
@@ -10,35 +48,12 @@ const User = sequelize.define('user', {
unique: true,
set(value) {
if (value) {
const encrypted = encrypt(value);
// Konvertiere Hex-String zu Buffer für die Speicherung
const buffer = Buffer.from(encrypted, 'hex');
this.setDataValue('email', buffer);
this.setDataValue('email', encodeEncryptedValueToBlob(value));
}
},
get() {
const encrypted = this.getDataValue('email');
if (encrypted) {
try {
// Konvertiere Buffer zu String für die Entschlüsselung
const encryptedString = encrypted.toString('hex');
const decrypted = decrypt(encryptedString);
if (decrypted) {
return decrypted;
}
} catch (error) {
console.warn('Email decryption failed, treating as plain text:', error.message);
}
// Fallback: Versuche es als Klartext zu lesen
try {
return encrypted.toString('utf8');
} catch (error) {
console.warn('Email could not be read as plain text:', error.message);
return null;
}
}
return null;
return decodeEncryptedBlob(encrypted);
}
},
salt: {

View File

@@ -14,7 +14,7 @@ const UserParam = sequelize.define('user_param', {
allowNull: false
},
value: {
type: DataTypes.STRING,
type: DataTypes.TEXT,
allowNull: false,
set(value) {
if (value) {

View File

@@ -58,6 +58,31 @@ VocabCourseLesson.init({
allowNull: true,
field: 'cultural_notes'
},
learningGoals: {
type: DataTypes.JSONB,
allowNull: true,
field: 'learning_goals'
},
corePatterns: {
type: DataTypes.JSONB,
allowNull: true,
field: 'core_patterns'
},
grammarFocus: {
type: DataTypes.JSONB,
allowNull: true,
field: 'grammar_focus'
},
speakingPrompts: {
type: DataTypes.JSONB,
allowNull: true,
field: 'speaking_prompts'
},
practicalTasks: {
type: DataTypes.JSONB,
allowNull: true,
field: 'practical_tasks'
},
targetMinutes: {
type: DataTypes.INTEGER,
allowNull: true,

View File

@@ -27,7 +27,25 @@ ChildRelation.init(
isHeir: {
type: DataTypes.BOOLEAN,
allowNull: true,
default: false}
default: false},
legitimacy: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'legitimate',
validate: {
isIn: [['legitimate', 'acknowledged_bastard', 'hidden_bastard']]
}},
birthContext: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'marriage',
validate: {
isIn: [['marriage', 'lover']]
}},
publicKnown: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false}
},
{
sequelize,

View File

@@ -7,7 +7,57 @@ DebtorsPrism.init({
// Verknüpfung auf FalukantCharacter
characterId: {
type: DataTypes.INTEGER,
allowNull: false}}, {
allowNull: false
},
status: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'delinquent'
},
enteredAt: {
type: DataTypes.DATE,
allowNull: true
},
releasedAt: {
type: DataTypes.DATE,
allowNull: true
},
debtAtEntry: {
type: DataTypes.DECIMAL(14, 2),
allowNull: true
},
remainingDebt: {
type: DataTypes.DECIMAL(14, 2),
allowNull: true
},
daysOverdue: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
},
reason: {
type: DataTypes.STRING,
allowNull: true
},
creditworthinessPenalty: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
},
nextForcedAction: {
type: DataTypes.STRING,
allowNull: true
},
assetsSeizedJson: {
type: DataTypes.JSONB,
allowNull: true
},
publicKnown: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
}
}, {
sequelize,
modelName: 'DebtorsPrism',
tableName: 'debtors_prism',

View File

@@ -0,0 +1,141 @@
import { Model, DataTypes } from 'sequelize';
import { sequelize } from '../../../utils/sequelize.js';
class RelationshipState extends Model {}
RelationshipState.init(
{
relationshipId: {
type: DataTypes.INTEGER,
allowNull: false,
unique: true,
},
marriageSatisfaction: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 55,
validate: {
min: 0,
max: 100,
},
},
marriagePublicStability: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 55,
validate: {
min: 0,
max: 100,
},
},
loverRole: {
type: DataTypes.STRING,
allowNull: true,
validate: {
isIn: [[null, 'secret_affair', 'lover', 'mistress_or_favorite'].filter(Boolean)],
},
},
affection: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 50,
validate: {
min: 0,
max: 100,
},
},
visibility: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 15,
validate: {
min: 0,
max: 100,
},
},
discretion: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 50,
validate: {
min: 0,
max: 100,
},
},
maintenanceLevel: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 50,
validate: {
min: 0,
max: 100,
},
},
statusFit: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
validate: {
min: -2,
max: 2,
},
},
monthlyBaseCost: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
validate: {
min: 0,
},
},
monthsUnderfunded: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
validate: {
min: 0,
},
},
active: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
acknowledged: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
exclusiveFlag: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
lastMonthlyProcessedAt: {
type: DataTypes.DATE,
allowNull: true,
},
lastDailyProcessedAt: {
type: DataTypes.DATE,
allowNull: true,
},
notesJson: {
type: DataTypes.JSONB,
allowNull: true,
},
flagsJson: {
type: DataTypes.JSONB,
allowNull: true,
},
},
{
sequelize,
modelName: 'RelationshipState',
tableName: 'relationship_state',
schema: 'falukant_data',
timestamps: true,
underscored: true,
}
);
export default RelationshipState;

View File

@@ -22,8 +22,9 @@ TownProductWorth.init({
timestamps: false,
underscored: true,
hooks: {
// Neu: 5585 %; ältere Einträge können 4060 % sein (Preislogik im Service deckelt nach unten ab).
beforeCreate: (worthPercent) => {
worthPercent.worthPercent = Math.floor(Math.random() * 20) + 40;
worthPercent.worthPercent = Math.floor(Math.random() * 31) + 55;
}
}
});

View File

@@ -25,6 +25,11 @@ Transport.init(
type: DataTypes.INTEGER,
allowNull: false,
},
guardCount: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
},
},
{
sequelize,
@@ -38,4 +43,3 @@ Transport.init(
export default Transport;

View File

@@ -12,7 +12,7 @@ Underground.init({
allowNull: false},
victimId: {
type: DataTypes.INTEGER,
allowNull: false},
allowNull: true},
parameters: {
type: DataTypes.JSON,
allowNull: true},

View File

@@ -24,6 +24,35 @@ UserHouse.init({
allowNull: false,
defaultValue: 100
},
servantCount: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
},
servantQuality: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 50
},
servantPayLevel: {
type: DataTypes.STRING(20),
allowNull: false,
defaultValue: 'normal'
},
householdOrder: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 55
},
householdTensionScore: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 10
},
householdTensionReasonsJson: {
type: DataTypes.JSONB,
allowNull: true
},
houseTypeId: {
type: DataTypes.INTEGER,
allowNull: false

View File

@@ -18,11 +18,15 @@ import UserParamVisibilityType from './type/user_param_visibility.js';
import UserParamVisibility from './community/user_param_visibility.js';
import Folder from './community/folder.js';
import Image from './community/image.js';
import EroticVideo from './community/erotic_video.js';
import EroticContentReport from './community/erotic_content_report.js';
import ImageVisibilityType from './type/image_visibility.js';
import ImageVisibilityUser from './community/image_visibility_user.js';
import FolderImageVisibility from './community/folder_image_visibility.js';
import ImageImageVisibility from './community/image_image_visibility.js';
import FolderVisibilityUser from './community/folder_visibility_user.js';
import EroticVideoImageVisibility from './community/erotic_video_image_visibility.js';
import EroticVideoVisibilityUser from './community/erotic_video_visibility_user.js';
import GuestbookEntry from './community/guestbook.js';
import DiaryHistory from './community/diary_history.js';
import Diary from './community/diary.js';
@@ -67,6 +71,7 @@ import Notification from './falukant/log/notification.js';
import MarriageProposal from './falukant/data/marriage_proposal.js';
import RelationshipType from './falukant/type/relationship.js';
import Relationship from './falukant/data/relationship.js';
import RelationshipState from './falukant/data/relationship_state.js';
import CharacterTrait from './falukant/type/character_trait.js';
import FalukantCharacterTrait from './falukant/data/falukant_character_trait.js';
import Mood from './falukant/type/mood.js';
@@ -169,11 +174,15 @@ const models = {
UserParamVisibility,
Folder,
Image,
EroticVideo,
EroticContentReport,
ImageVisibilityType,
ImageVisibilityUser,
FolderImageVisibility,
ImageImageVisibility,
FolderVisibilityUser,
EroticVideoImageVisibility,
EroticVideoVisibilityUser,
GuestbookEntry,
DiaryHistory,
Diary,
@@ -219,6 +228,7 @@ const models = {
MarriageProposal,
RelationshipType,
Relationship,
RelationshipState,
CharacterTrait,
FalukantCharacterTrait,
Mood,

View File

@@ -9,13 +9,13 @@ const ContactMessage = sequelize.define('contact_message', {
set(value) {
if (value) {
const encryptedValue = encrypt(value);
this.setDataValue('email', encryptedValue.toString('hex'));
this.setDataValue('email', encryptedValue);
}
},
get() {
const value = this.getDataValue('email');
if (value) {
return decrypt(Buffer.from(value, 'hex'));
return decrypt(value);
}
}
},
@@ -25,13 +25,13 @@ const ContactMessage = sequelize.define('contact_message', {
set(value) {
if (value) {
const encryptedValue = encrypt(value);
this.setDataValue('message', encryptedValue.toString('hex'));
this.setDataValue('message', encryptedValue);
}
},
get() {
const value = this.getDataValue('message');
if (value) {
return decrypt(Buffer.from(value, 'hex'));
return decrypt(value);
}
}
},
@@ -41,13 +41,13 @@ const ContactMessage = sequelize.define('contact_message', {
set(value) {
if (value) {
const encryptedValue = encrypt(value);
this.setDataValue('name', encryptedValue.toString('hex'));
this.setDataValue('name', encryptedValue);
}
},
get() {
const value = this.getDataValue('name');
if (value) {
return decrypt(Buffer.from(value, 'hex'));
return decrypt(value);
}
}
},
@@ -67,13 +67,13 @@ const ContactMessage = sequelize.define('contact_message', {
set(value) {
if (value) {
const encryptedValue = encrypt(value);
this.setDataValue('answer', encryptedValue.toString('hex'));
this.setDataValue('answer', encryptedValue);
}
},
get() {
const value = this.getDataValue('answer');
if (value) {
return decrypt(Buffer.from(value, 'hex'));
return decrypt(value);
}
}
},

5965
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "backend",
"version": "1.0.0",
"description": "",
"description": "Nach Änderungen an dependencies: npm install ausführen und package-lock.json committen (npm ci im Deploy).",
"type": "module",
"main": "index.js",
"scripts": {
@@ -12,38 +12,44 @@
"sync-tables": "node sync-tables-only.js",
"check-connections": "node check-connections.js",
"cleanup-connections": "node cleanup-connections.js",
"diag:town-worth": "QUIET_ENV_LOGS=1 DOTENV_CONFIG_QUIET=1 node scripts/falukant-town-product-worth-stats.mjs",
"diag:moneyflow": "QUIET_ENV_LOGS=1 DOTENV_CONFIG_QUIET=1 node scripts/falukant-moneyflow-report.mjs",
"lockfile:sync": "npm install --package-lock-only",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"amqplib": "^0.10.4",
"bcryptjs": "^2.4.3",
"connect-redis": "^7.1.1",
"cors": "^2.8.5",
"@gltf-transform/cli": "^4.3.0",
"amqplib": "^0.10.9",
"bcryptjs": "^3.0.3",
"connect-redis": "^9.0.0",
"cors": "^2.8.6",
"date-fns": "^4.1.0",
"dompurify": "^3.1.7",
"dotenv": "^17.2.1",
"express": "^4.19.2",
"express-session": "^1.18.1",
"i18n": "^0.15.1",
"joi": "^17.13.3",
"jsdom": "^26.1.0",
"multer": "^2.0.0",
"mysql2": "^3.10.3",
"nodemailer": "^7.0.11",
"pg": "^8.12.0",
"dompurify": "^3.3.3",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"express-session": "^1.19.0",
"i18n": "^0.15.3",
"joi": "^18.0.2",
"jsdom": "^29.0.1",
"multer": "^2.1.1",
"mysql2": "^3.20.0",
"nodemailer": "^8.0.3",
"pg": "^8.20.0",
"pg-hstore": "^2.3.4",
"redis": "^4.7.0",
"sequelize": "^6.37.3",
"sharp": "^0.34.3",
"socket.io": "^4.7.5",
"uuid": "^11.1.0",
"ws": "^8.18.0",
"@gltf-transform/cli": "^4.3.0"
"redis": "^5.11.0",
"sequelize": "^6.37.8",
"sharp": "^0.34.5",
"socket.io": "^4.8.3",
"uuid": "^13.0.0",
"ws": "^8.20.0"
},
"devDependencies": {
"sequelize-cli": "^6.6.2"
"sequelize-cli": "^6.6.5"
},
"overrides": {
"minimatch": "10.2.4"
}
}

View File

@@ -19,6 +19,12 @@ router.delete('/chat/rooms/:id', authenticate, adminController.deleteRoom);
router.get('/users/search', authenticate, adminController.searchUsers);
router.get('/users/statistics', authenticate, adminController.getUserStatistics);
router.get('/users/batch', authenticate, adminController.getUsers);
router.get('/users/adult-verification', authenticate, adminController.getAdultVerificationRequests);
router.get('/users/:id/adult-verification/document', authenticate, adminController.getAdultVerificationDocument);
router.put('/users/:id/adult-verification', authenticate, adminController.setAdultVerificationStatus);
router.get('/users/erotic-moderation', authenticate, adminController.getEroticModerationReports);
router.get('/users/erotic-moderation/preview/:type/:targetId', authenticate, adminController.getEroticModerationPreview);
router.put('/users/erotic-moderation/:id', authenticate, adminController.applyEroticModerationAction);
router.get('/users/:id', authenticate, adminController.getUser);
router.put('/users/:id', authenticate, adminController.updateUser);

View File

@@ -14,7 +14,7 @@ router.post('/exit', chatController.removeUser);
router.post('/initOneToOne', authenticate, chatController.initOneToOne);
router.post('/oneToOne/sendMessage', authenticate, chatController.sendOneToOneMessage); // Neue Route zum Senden einer Nachricht
router.get('/oneToOne/messageHistory', authenticate, chatController.getOneToOneMessageHistory); // Neue Route zum Abrufen der Nachrichtengeschichte
router.get('/rooms', chatController.getRoomList);
router.get('/rooms', authenticate, chatController.getRoomList);
router.get('/room-create-options', authenticate, chatController.getRoomCreateOptions);
router.get('/my-rooms', authenticate, chatController.getOwnRooms);
router.delete('/my-rooms/:id', authenticate, chatController.deleteOwnRoom);

View File

@@ -22,10 +22,12 @@ router.post('/production', falukantController.createProduction);
router.get('/production/:branchId', falukantController.getProduction);
router.get('/stocktypes', falukantController.getStockTypes);
router.get('/stockoverview', falukantController.getStockOverview);
router.get('/stock/?:branchId', falukantController.getStock);
router.get('/stock', falukantController.getStock);
router.get('/stock/:branchId', falukantController.getStock);
router.post('/stock', falukantController.createStock);
router.get('/products', falukantController.getProducts);
router.get('/inventory/?:branchId', falukantController.getInventory);
router.get('/inventory', falukantController.getInventory);
router.get('/inventory/:branchId', falukantController.getInventory);
router.post('/sell/all', falukantController.sellAllProducts);
router.post('/sell', falukantController.sellProduct);
router.post('/moneyhistory', falukantController.moneyHistory);
@@ -47,6 +49,13 @@ router.get('/dashboard-widget', falukantController.getDashboardWidget);
router.post('/family/acceptmarriageproposal', falukantController.acceptMarriageProposal);
router.post('/family/cancel-wooing', falukantController.cancelWooing);
router.post('/family/set-heir', falukantController.setHeir);
router.post('/family/lover', falukantController.createLoverRelationship);
router.post('/family/marriage/spend-time', falukantController.spendTimeWithSpouse);
router.post('/family/marriage/gift', falukantController.giftToSpouse);
router.post('/family/marriage/reconcile', falukantController.reconcileMarriage);
router.post('/family/lover/:relationshipId/maintenance', falukantController.setLoverMaintenance);
router.post('/family/lover/:relationshipId/acknowledge', falukantController.acknowledgeLover);
router.post('/family/lover/:relationshipId/end', falukantController.endLoverRelationship);
router.get('/heirs/potential', falukantController.getPotentialHeirs);
router.post('/heirs/select', falukantController.selectHeir);
router.get('/family/gifts', falukantController.getGifts);
@@ -61,6 +70,10 @@ router.get('/houses/buyable', falukantController.getBuyableHouses);
router.get('/houses', falukantController.getUserHouse);
router.post('/houses/renovate-all', falukantController.renovateAll);
router.post('/houses/renovate', falukantController.renovate);
router.post('/houses/servants/hire', falukantController.hireServants);
router.post('/houses/servants/dismiss', falukantController.dismissServants);
router.post('/houses/servants/pay-level', falukantController.setServantPayLevel);
router.post('/houses/order', falukantController.tidyHousehold);
router.post('/houses', falukantController.buyUserHouse);
router.get('/party/types', falukantController.getPartyTypes);
router.post('/party', falukantController.createParty);
@@ -101,6 +114,8 @@ 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/raid-regions', falukantController.getRaidTransportRegions);
router.get('/underground/activities', falukantController.getUndergroundActivities);
router.get('/notifications', falukantController.getNotifications);
router.get('/notifications/all', falukantController.getAllNotifications);
router.post('/notifications/mark-shown', falukantController.markNotificationsShown);

View File

@@ -1,9 +1,11 @@
import { Router } from 'express';
import SettingsController from '../controllers/settingsController.js';
import { authenticate } from '../middleware/authMiddleware.js';
import multer from 'multer';
const router = Router();
const settingsController = new SettingsController();
const upload = multer();
router.post('/filter', authenticate, settingsController.filterSettings.bind(settingsController));
router.post('/update', authenticate, settingsController.updateSetting.bind(settingsController));
@@ -19,5 +21,8 @@ router.post('/setinterest', authenticate, settingsController.addUserInterest.bin
router.get('/removeinterest/:id', authenticate, settingsController.removeInterest.bind(settingsController));
router.get('/visibilities', authenticate, settingsController.getVisibilities.bind(settingsController));
router.post('/update-visibility', authenticate, settingsController.updateVisibility.bind(settingsController));
router.get('/llm', authenticate, settingsController.getLlmSettings.bind(settingsController));
router.post('/llm', authenticate, settingsController.saveLlmSettings.bind(settingsController));
router.post('/adult-verification/request', authenticate, upload.single('document'), settingsController.submitAdultVerificationRequest.bind(settingsController));
export default router;

View File

@@ -15,6 +15,19 @@ router.post('/folders/:folderId', socialNetworkController.createFolder);
router.get('/folders', socialNetworkController.getFolders);
router.get('/folder/:folderId', socialNetworkController.getFolderImageList);
router.post('/images', upload.single('image'), socialNetworkController.uploadImage);
router.post('/erotic/folders/:folderId', socialNetworkController.createAdultFolder);
router.get('/erotic/folders', socialNetworkController.getAdultFolders);
router.get('/profile/erotic/folders/:username', socialNetworkController.getAdultFoldersByUsername);
router.get('/profile/erotic/videos/:username', socialNetworkController.getEroticVideosByUsername);
router.get('/erotic/folder/:folderId', socialNetworkController.getAdultFolderImageList);
router.post('/erotic/images', upload.single('image'), socialNetworkController.uploadAdultImage);
router.put('/erotic/images/:imageId', socialNetworkController.changeAdultImage);
router.get('/erotic/image/:hash', socialNetworkController.getAdultImageByHash);
router.get('/erotic/videos', socialNetworkController.listEroticVideos);
router.post('/erotic/videos', upload.single('video'), socialNetworkController.uploadEroticVideo);
router.put('/erotic/videos/:videoId', socialNetworkController.changeEroticVideo);
router.get('/erotic/video/:hash', socialNetworkController.getEroticVideoByHash);
router.post('/erotic/report', socialNetworkController.reportEroticContent);
router.get('/images/:imageId', socialNetworkController.getImage);
router.put('/images/:imageId', socialNetworkController.changeImage);
router.get('/imagevisibilities', socialNetworkController.getImageVisibilityTypes);

View File

@@ -48,6 +48,7 @@ router.put('/lessons/:lessonId/progress', vocabController.updateLessonProgress);
// Grammar Exercises
router.get('/grammar/exercise-types', vocabController.getExerciseTypes);
router.post('/lessons/:lessonId/assistant', vocabController.sendLessonAssistantMessage);
router.post('/lessons/:lessonId/grammar-exercises', vocabController.createGrammarExercise);
router.get('/lessons/:lessonId/grammar-exercises', vocabController.getGrammarExercisesForLesson);
router.get('/lessons/:lessonId/grammar-exercises/progress', vocabController.getGrammarExerciseProgress);
@@ -58,4 +59,3 @@ router.delete('/grammar-exercises/:exerciseId', vocabController.deleteGrammarExe
export default router;

View File

@@ -19,6 +19,19 @@ const LESSONS_TO_ADD = [
title: 'Woche 1 - Wiederholung',
description: 'Wiederhole alle Inhalte der ersten Woche',
culturalNotes: 'Wiederholung ist der Schlüssel zum Erfolg!',
learningGoals: [
'Die Kernmuster der ersten Woche ohne Hilfe wiederholen.',
'Zwischen Begrüßung, Familie und Fürsorge schneller wechseln.',
'Eine kurze Alltagssequenz frei sprechen.'
],
corePatterns: ['Kumusta ka?', 'Palangga taka.', 'Nikaon na ka?', 'Wala ko kasabot.'],
speakingPrompts: [
{
title: 'Freie Wiederholung',
prompt: 'Begrüße jemanden, drücke Zuneigung aus und frage fürsorglich nach dem Essen.',
cue: 'Kumusta ka? Palangga taka. Nikaon na ka?'
}
],
targetMinutes: 30,
targetScorePercent: 80,
requiresReview: false
@@ -31,6 +44,12 @@ const LESSONS_TO_ADD = [
title: 'Woche 1 - Vokabeltest',
description: 'Teste dein Wissen aus Woche 1',
culturalNotes: null,
learningGoals: [
'Die wichtigsten Wörter der ersten Woche schnell abrufen.',
'Bedeutung und Gebrauch zentraler Wörter unterscheiden.',
'Von einzelnen Wörtern zu kurzen Sätzen übergehen.'
],
corePatterns: ['Kumusta', 'Salamat', 'Lami', 'Mingaw ko nimo'],
targetMinutes: 15,
targetScorePercent: 80,
requiresReview: true
@@ -89,6 +108,9 @@ async function addBisayaWeek1Lessons() {
dayNumber: lessonData.dayNumber,
lessonType: lessonData.lessonType,
culturalNotes: lessonData.culturalNotes,
learningGoals: lessonData.learningGoals || [],
corePatterns: lessonData.corePatterns || [],
speakingPrompts: lessonData.speakingPrompts || [],
targetMinutes: lessonData.targetMinutes,
targetScorePercent: lessonData.targetScorePercent,
requiresReview: lessonData.requiresReview

View File

@@ -0,0 +1,207 @@
#!/usr/bin/env node
/**
* Spielt die überarbeiteten Bisaya-Kursinhalte ein und setzt den Lernfortschritt zurück.
*
* Verwendung:
* node backend/scripts/apply-bisaya-course-refresh.js
*/
import { sequelize } from '../utils/sequelize.js';
import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js';
import VocabCourseProgress from '../models/community/vocab_course_progress.js';
import VocabGrammarExerciseProgress from '../models/community/vocab_grammar_exercise_progress.js';
import { Op } from 'sequelize';
const LESSON_DIDACTICS = {
'Begrüßungen & Höflichkeit': {
learningGoals: [
'Einfache Begrüßungen verstehen und selbst verwenden.',
'Tageszeitbezogene Grüße und einfache Verabschiedungen unterscheiden.',
'Höfliche Reaktionen wie Danke und Bitte passend einsetzen.',
'Ein kurzes Begrüßungs-Mini-Gespräch laut üben.'
],
corePatterns: ['Kumusta ka?', 'Maayong buntag.', 'Maayong adlaw.', 'Amping.', 'Babay.', 'Maayo ko.', 'Salamat.', 'Palihug.'],
grammarFocus: [
{ title: 'Kurzantworten mit ko', text: 'Mit "ko" sprichst du über dich selbst: "Maayo ko."', example: 'Maayo ko. = Mir geht es gut.' },
{ title: 'Maayong + Tageszeit', text: 'Mit "Maayong" kannst du Grüße für verschiedene Tageszeiten bilden.', example: 'Maayong buntag. / Maayong gabii.' }
],
speakingPrompts: [
{ title: 'Mini-Gespräch', prompt: 'Begrüße eine Person, frage nach dem Befinden und reagiere höflich.', cue: 'Kumusta ka? Maayo ko. Salamat.' },
{ title: 'Verabschiedung', prompt: 'Verabschiede dich kurz und wünsche, dass die andere Person auf sich aufpasst.', cue: 'Babay. Amping.' }
],
practicalTasks: [{ title: 'Alltag', text: 'Sprich die Begrüßung dreimal laut und variiere die Antwort.' }]
},
'Familienwörter': {
learningGoals: [
'Die wichtigsten Familienbezeichnungen sicher erkennen.',
'Familienmitglieder mit respektvollen Wörtern ansprechen.',
'Kurze Sätze über die eigene Familie bilden.'
],
corePatterns: ['Si Nanay', 'Si Tatay', 'Kuya nako', 'Ate nako'],
grammarFocus: [
{ title: 'Respekt in Familienanreden', text: 'Kuya und Ate werden nicht nur in der Familie, sondern auch respektvoll für ältere Personen benutzt.', example: 'Kuya, palihug.' }
],
speakingPrompts: [
{ title: 'Meine Familie', prompt: 'Stelle zwei Familienmitglieder mit einem kurzen Satz vor.', cue: 'Si Nanay. Si Kuya.' }
],
practicalTasks: [{ title: 'Familienpraxis', text: 'Nenne laut fünf Familienwörter und bilde danach zwei Mini-Sätze.' }]
},
'Essen & Fürsorge': {
learningGoals: [
'Fürsorgliche Fragen rund ums Essen verstehen.',
'Einladungen zum Essen passend beantworten.',
'Kurze Essens-Dialoge laut üben.'
],
corePatterns: ['Nikaon na ka?', 'Kaon ta.', 'Gusto ka mokaon?', 'Lami kaayo.'],
grammarFocus: [
{ title: 'na als Zustandsmarker', text: '"na" markiert oft etwas, das bereits eingetreten ist oder jetzt gilt.', example: 'Nikaon na ka?' }
],
speakingPrompts: [
{ title: 'Fürsorge-Dialog', prompt: 'Frage, ob jemand schon gegessen hat, und biete Essen oder Wasser an.', cue: 'Nikaon na ka? Gusto ka mokaon?' }
],
practicalTasks: [{ title: 'Rollenspiel', text: 'Spiele ein kurzes Gespräch zwischen Gastgeber und Gast beim Essen.' }]
},
'Zeitformen - Grundlagen': {
learningGoals: [
'Ni- und Mo- als einfache Zeitmarker unterscheiden.',
'Kurze Sätze in Vergangenheit und Zukunft bilden.',
'Das Muster laut mit mehreren Verben wiederholen.'
],
corePatterns: ['Ni-kaon ko.', 'Mo-kaon ko.', 'Ni-adto ko.', 'Mo-adto ko.'],
grammarFocus: [
{ title: 'Zeitpräfixe', text: 'Ni- verweist auf Vergangenes, Mo- auf Zukünftiges oder Bevorstehendes.', example: 'Ni-kaon ko. / Mo-kaon ko.' }
],
speakingPrompts: [
{ title: 'Vorher und nachher', prompt: 'Sage einen Satz über etwas, das du getan hast, und einen Satz über etwas, das du tun wirst.', cue: 'Ni-kaon ko. Mo-adto ko.' }
],
practicalTasks: [{ title: 'Mustertraining', text: 'Nimm ein Verb und sprich es einmal mit Ni- und einmal mit Mo-.' }]
},
'Woche 1 - Wiederholung': {
learningGoals: [
'Die Kernmuster der ersten Woche ohne Hilfe wiederholen.',
'Zwischen Begrüßung, Familie und Fürsorge schneller wechseln.',
'Eine kurze Alltagssequenz frei sprechen.'
],
corePatterns: ['Kumusta ka?', 'Palangga taka.', 'Nikaon na ka?', 'Wala ko kasabot.'],
speakingPrompts: [
{ title: 'Freie Wiederholung', prompt: 'Begrüße jemanden, drücke Zuneigung aus und frage fürsorglich nach dem Essen.', cue: 'Kumusta ka? Palangga taka. Nikaon na ka?' }
]
},
'Woche 1 - Vokabeltest': {
learningGoals: [
'Die wichtigsten Wörter der ersten Woche schnell abrufen.',
'Bedeutung und Gebrauch zentraler Wörter unterscheiden.',
'Von einzelnen Wörtern zu kurzen Sätzen übergehen.'
],
corePatterns: ['Kumusta', 'Salamat', 'Lami', 'Mingaw ko nimo']
}
};
async function resetBisayaProgress(courseIds) {
if (courseIds.length === 0) return { lessonProgress: 0, exerciseProgress: 0 };
const lessonIds = await VocabCourseLesson.findAll({
where: {
courseId: {
[Op.in]: courseIds
}
},
attributes: ['id']
});
const numericLessonIds = lessonIds.map((row) => row.id);
const exerciseIds = numericLessonIds.length > 0
? await VocabGrammarExercise.findAll({
where: {
lessonId: {
[Op.in]: numericLessonIds
}
},
attributes: ['id']
})
: [];
const deletedLessonProgress = await VocabCourseProgress.destroy({
where: { courseId: { [Op.in]: courseIds } }
});
let deletedExerciseProgress = 0;
if (exerciseIds.length > 0) {
deletedExerciseProgress = await VocabGrammarExerciseProgress.destroy({
where: { exerciseId: { [Op.in]: exerciseIds.map((row) => row.id) } }
});
}
return {
lessonProgress: deletedLessonProgress,
exerciseProgress: deletedExerciseProgress
};
}
async function applyBisayaCourseRefresh() {
await sequelize.authenticate();
const [bisayaLanguage] = await sequelize.query(
`SELECT id FROM community.vocab_language WHERE name = 'Bisaya' LIMIT 1`,
{ type: sequelize.QueryTypes.SELECT }
);
if (!bisayaLanguage) {
console.error('❌ Bisaya-Sprache nicht gefunden.');
return;
}
const courses = await sequelize.query(
`SELECT id, title FROM community.vocab_course WHERE language_id = :languageId ORDER BY id ASC`,
{
replacements: { languageId: bisayaLanguage.id },
type: sequelize.QueryTypes.SELECT
}
);
const courseIds = courses.map((course) => course.id);
const resetStats = await resetBisayaProgress(courseIds);
let updatedLessons = 0;
for (const course of courses) {
const lessons = await VocabCourseLesson.findAll({
where: { courseId: course.id }
});
for (const lesson of lessons) {
const didactics = LESSON_DIDACTICS[lesson.title];
if (!didactics) continue;
await lesson.update({
learningGoals: didactics.learningGoals || [],
corePatterns: didactics.corePatterns || [],
grammarFocus: didactics.grammarFocus || [],
speakingPrompts: didactics.speakingPrompts || [],
practicalTasks: didactics.practicalTasks || []
});
updatedLessons++;
}
}
console.log('✅ Bisaya-Kursupdate vorbereitet.');
console.log(` Kurse: ${courses.length}`);
console.log(` Didaktisch aktualisierte Lektionen: ${updatedLessons}`);
console.log(` Gelöschte Lektionsfortschritte: ${resetStats.lessonProgress}`);
console.log(` Gelöschte Übungsfortschritte: ${resetStats.exerciseProgress}`);
console.log('');
console.log('Nächste Schritte:');
console.log('1. create-bisaya-course-content.js ausführen, um die neuen Übungen einzuspielen');
console.log('2. optional update-week1-bisaya-exercises.js ausführen, falls Woche 1 separat gepflegt wird');
}
applyBisayaCourseRefresh()
.then(() => {
sequelize.close();
process.exit(0);
})
.catch((error) => {
console.error('❌ Fehler:', error);
sequelize.close();
process.exit(1);
});

View File

@@ -14,6 +14,13 @@ import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js'
import VocabCourse from '../models/community/vocab_course.js';
import User from '../models/community/user.js';
function withTypeName(exerciseTypeName, exercise) {
return {
...exercise,
exerciseTypeName
};
}
// Bisaya-spezifische Übungen basierend auf Lektionsthemen
const BISAYA_EXERCISES = {
// Lektion 1: Begrüßungen & Höflichkeit
@@ -62,6 +69,109 @@ const BISAYA_EXERCISES = {
correctAnswer: 0
},
explanation: '"Salamat" bedeutet "Danke" auf Bisaya.'
},
{
exerciseTypeId: 2,
title: 'Tagesgruß erkennen',
instruction: 'Wähle den passenden Gruß für den Morgen.',
questionData: {
type: 'multiple_choice',
question: 'Wie sagt man "Guten Morgen" auf Bisaya?',
options: ['Maayong buntag', 'Maayong gabii', 'Amping', 'Babay']
},
answerData: {
type: 'multiple_choice',
correctAnswer: 0
},
explanation: '"Maayong buntag" ist die übliche Form für "Guten Morgen".'
},
{
exerciseTypeId: 2,
title: 'Verabschiedung erkennen',
instruction: 'Wähle die passende Verabschiedung aus.',
questionData: {
type: 'multiple_choice',
question: 'Was bedeutet "Amping"?',
options: ['Pass auf dich auf', 'Guten Morgen', 'Danke', 'Bitte']
},
answerData: {
type: 'multiple_choice',
correctAnswer: 0
},
explanation: '"Amping" benutzt man beim Abschied im Sinn von "Pass auf dich auf".'
},
{
exerciseTypeId: 2,
title: 'Abschiedsform erkennen',
instruction: 'Wähle die richtige Bedeutung.',
questionData: {
type: 'multiple_choice',
question: 'Was bedeutet "Babay"?',
options: ['Tschüss / Auf Wiedersehen', 'Wie geht es dir?', 'Guten Tag', 'Ich bin müde']
},
answerData: {
type: 'multiple_choice',
correctAnswer: 0
},
explanation: '"Babay" ist eine einfache alltägliche Verabschiedung.'
},
withTypeName('dialog_completion', {
title: 'Begrüßungsdialog ergänzen',
instruction: 'Ergänze die passende Antwort im Mini-Dialog.',
questionData: {
type: 'dialog_completion',
question: 'Welche Antwort passt auf die Begrüßung?',
dialog: ['A: Kumusta ka?', 'B: ...']
},
answerData: {
modelAnswer: 'Maayo ko, salamat.',
correct: ['Maayo ko, salamat.', 'Maayo ko. Salamat.']
},
explanation: 'Eine typische kurze Antwort ist "Maayo ko, salamat."'
}),
withTypeName('dialog_completion', {
title: 'Verabschiedungsdialog ergänzen',
instruction: 'Ergänze die passende Verabschiedung.',
questionData: {
type: 'dialog_completion',
question: 'Wie endet der kurze Dialog natürlich?',
dialog: ['A: Sige, mauna ko.', 'B: ...']
},
answerData: {
modelAnswer: 'Babay, amping.',
correct: ['Babay, amping.', 'Amping.', 'Babay. Amping.']
},
explanation: '"Babay" und "Amping" sind typische kurze Abschiedsformen.'
}),
{
exerciseTypeId: 8,
title: 'Begrüßung frei sprechen',
instruction: 'Sprich eine kurze Begrüßung mit Frage und Antwort frei nach.',
questionData: {
type: 'speaking_from_memory',
question: 'Begrüße eine Person und antworte kurz auf "Kumusta ka?".',
expectedText: 'Kumusta ka? Maayo ko, salamat.',
keywords: ['kumusta', 'maayo', 'salamat']
},
answerData: {
type: 'speaking_from_memory'
},
explanation: 'Wichtig sind hier die Schlüsselwörter für Begrüßung, Antwort und Höflichkeit.'
},
{
exerciseTypeId: 8,
title: 'Gruß und Abschied laut sprechen',
instruction: 'Sprich einen Tagesgruß und eine kurze Verabschiedung laut.',
questionData: {
type: 'speaking_from_memory',
question: 'Sprich: "Guten Morgen" und verabschiede dich danach kurz.',
expectedText: 'Maayong buntag. Babay, amping.',
keywords: ['maayong', 'buntag', 'babay', 'amping']
},
answerData: {
type: 'speaking_from_memory'
},
explanation: 'Die Übung verbindet Begrüßung und Verabschiedung in einem kurzen Alltagspfad.'
}
],
@@ -188,7 +298,92 @@ const BISAYA_EXERCISES = {
alternatives: ['Mama', 'Nanay', 'Inahan']
},
explanation: '"Nanay" oder "Mama" bedeutet "Mutter" auf Bisaya.'
}
},
{
exerciseTypeId: 3,
title: 'Familiensatz bauen',
instruction: 'Bilde aus den Wörtern einen kurzen Satz.',
questionData: {
type: 'sentence_building',
question: 'Baue einen Satz: "Das ist meine Mutter."',
tokens: ['Si', 'Nanay', 'nako', 'ni']
},
answerData: {
correct: ['Si Nanay nako ni.', 'Si Nanay ni nako.']
},
explanation: 'Mit "Si Nanay nako ni." stellst du deine Mutter kurz vor.'
},
withTypeName('situational_response', {
title: 'Familie vorstellen',
instruction: 'Antworte kurz auf die Situation.',
questionData: {
type: 'situational_response',
question: 'Jemand fragt dich nach deiner Familie. Stelle kurz Mutter und älteren Bruder vor.',
keywords: ['nanay', 'kuya']
},
answerData: {
modelAnswer: 'Si Nanay ug si Kuya.',
keywords: ['nanay', 'kuya']
},
explanation: 'Für diese Aufgabe reichen kurze, klare Familiennennungen.'
})
],
'Essen & Fürsorge': [
{
exerciseTypeId: 2,
title: 'Fürsorgefrage verstehen',
instruction: 'Wähle die richtige Bedeutung.',
questionData: {
type: 'multiple_choice',
question: 'Was bedeutet "Nikaon na ka?"?',
options: ['Hast du schon gegessen?', 'Bist du müde?', 'Kommst du nach Hause?', 'Möchtest du Wasser?']
},
answerData: { type: 'multiple_choice', correctAnswer: 0 },
explanation: '"Nikaon na ka?" ist eine sehr fürsorgliche Alltagsfrage.'
},
{
exerciseTypeId: 1,
title: 'Essensdialog ergänzen',
instruction: 'Fülle die Lücken mit den passenden Wörtern.',
questionData: {
type: 'gap_fill',
text: 'Nikaon {gap} ka? {gap} ta!',
gaps: 2
},
answerData: {
answers: ['na', 'Kaon']
},
explanation: '"na" markiert hier den bereits eingetretenen Zustand; "Kaon ta!" heißt "Lass uns essen!".'
},
withTypeName('dialog_completion', {
title: 'Einladung zum Essen ergänzen',
instruction: 'Ergänze die passende Antwort.',
questionData: {
type: 'dialog_completion',
question: 'Welche Antwort passt auf die Einladung?',
dialog: ['A: Kaon ta!', 'B: ...']
},
answerData: {
modelAnswer: 'Oo, gusto ko.',
correct: ['Oo, gusto ko.', 'Oo, mokaon ko.']
},
explanation: 'Eine natürliche kurze Reaktion ist "Oo, gusto ko."'
}),
withTypeName('situational_response', {
title: 'Fürsorglich reagieren',
instruction: 'Reagiere passend auf die Situation.',
questionData: {
type: 'situational_response',
question: 'Jemand sieht hungrig aus. Frage fürsorglich nach und biete Essen an.',
keywords: ['nikaon', 'kaon']
},
answerData: {
modelAnswer: 'Nikaon na ka? Kaon ta.',
keywords: ['nikaon', 'kaon']
},
explanation: 'Die Übung trainiert einen sehr typischen fürsorglichen Mini-Dialog.'
})
],
// Lektion: Haus & Familie (Balay, Kwarto, Kusina, Pamilya)
@@ -424,6 +619,34 @@ const BISAYA_EXERCISES = {
answers: ['Ni', 'Mo']
},
explanation: 'Ni- für Vergangenheit, Mo- für Zukunft.'
},
withTypeName('pattern_drill', {
title: 'Zeitmuster anwenden',
instruction: 'Bilde mit demselben Muster einen Zukunftssatz.',
questionData: {
type: 'pattern_drill',
question: 'Verwende das Muster für "gehen".',
pattern: 'Mo- + Verb + ko'
},
answerData: {
modelAnswer: 'Mo-adto ko.',
correct: ['Mo-adto ko.', 'Moadto ko.']
},
explanation: 'Mit "Mo-" kannst du ein einfaches Zukunftsmuster bilden.'
}),
{
exerciseTypeId: 3,
title: 'Vergangenheit und Zukunft bauen',
instruction: 'Schreibe beide Formen nacheinander auf.',
questionData: {
type: 'sentence_building',
question: 'Formuliere: "Ich habe gegessen. Ich werde essen."',
tokens: ['Ni-kaon', 'ko', 'Mo-kaon', 'ko']
},
answerData: {
correct: ['Ni-kaon ko. Mo-kaon ko.', 'Nikaon ko. Mokaon ko.']
},
explanation: 'Die Übung trainiert den direkten Wechsel zwischen den beiden Zeitmarkern.'
}
],
@@ -1103,7 +1326,35 @@ const BISAYA_EXERCISES = {
},
answerData: { type: 'multiple_choice', correctAnswer: 0 },
explanation: '"Wala ko kasabot" bedeutet "Ich verstehe nicht".'
}
},
{
exerciseTypeId: 3,
title: 'Woche 1: Minisatz bauen',
instruction: 'Schreibe eine kurze Sequenz aus Begrüßung und Fürsorge.',
questionData: {
type: 'sentence_building',
question: 'Baue: "Wie geht es dir? Hast du schon gegessen?"',
tokens: ['Kumusta', 'ka', 'Nikaon', 'na', 'ka']
},
answerData: {
correct: ['Kumusta ka? Nikaon na ka?', 'Kumusta ka. Nikaon na ka?']
},
explanation: 'Hier kombinierst du zwei wichtige Muster aus Woche 1.'
},
withTypeName('dialog_completion', {
title: 'Woche 1: Dialog ergänzen',
instruction: 'Ergänze die passende liebevolle Reaktion.',
questionData: {
type: 'dialog_completion',
question: 'Welche Antwort passt?',
dialog: ['A: Mingaw ko nimo.', 'B: ...']
},
answerData: {
modelAnswer: 'Palangga taka.',
correct: ['Palangga taka.']
},
explanation: 'Die Kombination klingt im Familienkontext warm und natürlich.'
})
],
// Woche 1 - Vokabeltest (Lektion 10)
@@ -1167,10 +1418,53 @@ const BISAYA_EXERCISES = {
},
answerData: { type: 'multiple_choice', correctAnswer: 0 },
explanation: '"Mingaw ko nimo" bedeutet "Ich vermisse dich".'
}
},
withTypeName('situational_response', {
title: 'Woche 1: Situative Kurzantwort',
instruction: 'Reagiere passend auf die Situation.',
questionData: {
type: 'situational_response',
question: 'Jemand fragt: "Kumusta ka?" Antworte kurz und höflich.',
keywords: ['maayo', 'salamat']
},
answerData: {
modelAnswer: 'Maayo ko, salamat.',
keywords: ['maayo', 'salamat']
},
explanation: 'Eine kurze höfliche Antwort reicht hier völlig aus.'
})
]
};
async function resolveExerciseTypeId(exercise) {
if (exercise.exerciseTypeId) {
return exercise.exerciseTypeId;
}
const trimmedName =
exercise.exerciseTypeName != null && exercise.exerciseTypeName !== ''
? String(exercise.exerciseTypeName).trim()
: '';
if (!trimmedName) {
throw new Error(`Kein exerciseTypeId oder exerciseTypeName für Übung "${exercise.title || 'unbenannt'}" definiert`);
}
const [type] = await sequelize.query(
`SELECT id FROM community.vocab_grammar_exercise_type WHERE name = :name LIMIT 1`,
{
replacements: { name: trimmedName },
type: sequelize.QueryTypes.SELECT
}
);
if (!type) {
throw new Error(`Übungstyp "${trimmedName}" nicht gefunden`);
}
return Number(type.id);
}
async function findOrCreateSystemUser() {
let systemUser = await User.findOne({
where: {
@@ -1270,10 +1564,14 @@ async function createBisayaCourseContent() {
const replacePlaceholders = [
'Woche 1 - Wiederholung',
'Woche 1 - Vokabeltest',
'Begrüßungen & Höflichkeit',
'Familienwörter',
'Essen & Fürsorge',
'Alltagsgespräche - Teil 1',
'Alltagsgespräche - Teil 2',
'Haus & Familie',
'Ort & Richtung'
'Ort & Richtung',
'Zeitformen - Grundlagen'
].includes(lesson.title);
const existingCount = await VocabGrammarExercise.count({
where: { lessonId: lesson.id }
@@ -1292,9 +1590,10 @@ async function createBisayaCourseContent() {
// Erstelle Übungen
let exerciseNumber = 1;
for (const exerciseData of exercises) {
const exerciseTypeId = await resolveExerciseTypeId(exerciseData);
await VocabGrammarExercise.create({
lessonId: lesson.id,
exerciseTypeId: exerciseData.exerciseTypeId,
exerciseTypeId,
exerciseNumber: exerciseNumber++,
title: exerciseData.title,
instruction: exerciseData.instruction,

View File

@@ -12,6 +12,189 @@ import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
import User from '../models/community/user.js';
import crypto from 'crypto';
const LESSON_DIDACTICS = {
'Begrüßungen & Höflichkeit': {
learningGoals: [
'Einfache Begrüßungen verstehen und selbst verwenden.',
'Tageszeitbezogene Grüße und einfache Verabschiedungen unterscheiden.',
'Höfliche Reaktionen wie Danke und Bitte passend einsetzen.',
'Ein kurzes Begrüßungs-Mini-Gespräch laut üben.'
],
corePatterns: [
'Kumusta ka?',
'Maayong buntag.',
'Maayong adlaw.',
'Amping.',
'Babay.',
'Maayo ko.',
'Salamat.',
'Palihug.'
],
grammarFocus: [
{
title: 'Kurzantworten mit ko',
text: 'Mit "ko" sprichst du über dich selbst: "Maayo ko."',
example: 'Maayo ko. = Mir geht es gut.'
},
{
title: 'Maayong + Tageszeit',
text: 'Mit "Maayong" kannst du Grüße für verschiedene Tageszeiten bilden.',
example: 'Maayong buntag. / Maayong gabii.'
}
],
speakingPrompts: [
{
title: 'Mini-Gespräch',
prompt: 'Begrüße eine Person, frage nach dem Befinden und reagiere höflich.',
cue: 'Kumusta ka? Maayo ko. Salamat.'
},
{
title: 'Verabschiedung',
prompt: 'Verabschiede dich kurz und wünsche, dass die andere Person auf sich aufpasst.',
cue: 'Babay. Amping.'
}
],
practicalTasks: [
{
title: 'Alltag',
text: 'Sprich die Begrüßung dreimal laut und variiere die Antwort.'
}
]
},
'Familienwörter': {
learningGoals: [
'Die wichtigsten Familienbezeichnungen sicher erkennen.',
'Familienmitglieder mit respektvollen Wörtern ansprechen.',
'Kurze Sätze über die eigene Familie bilden.'
],
corePatterns: [
'Si Nanay',
'Si Tatay',
'Kuya nako',
'Ate nako'
],
grammarFocus: [
{
title: 'Respekt in Familienanreden',
text: 'Kuya und Ate werden nicht nur in der Familie, sondern auch respektvoll für ältere Personen benutzt.',
example: 'Kuya, palihug.'
}
],
speakingPrompts: [
{
title: 'Meine Familie',
prompt: 'Stelle zwei Familienmitglieder mit einem kurzen Satz vor.',
cue: 'Si Nanay. Si Kuya.'
}
],
practicalTasks: [
{
title: 'Familienpraxis',
text: 'Nenne laut fünf Familienwörter und bilde danach zwei Mini-Sätze.'
}
]
},
'Essen & Fürsorge': {
learningGoals: [
'Fürsorgliche Fragen rund ums Essen verstehen.',
'Einladungen zum Essen passend beantworten.',
'Kurze Essens-Dialoge laut üben.'
],
corePatterns: [
'Nikaon na ka?',
'Kaon ta.',
'Gusto ka mokaon?',
'Lami kaayo.'
],
grammarFocus: [
{
title: 'na als Zustandsmarker',
text: '"na" markiert oft etwas, das bereits eingetreten ist oder jetzt gilt.',
example: 'Nikaon na ka?'
}
],
speakingPrompts: [
{
title: 'Fürsorge-Dialog',
prompt: 'Frage, ob jemand schon gegessen hat, und biete Essen oder Wasser an.',
cue: 'Nikaon na ka? Gusto ka mokaon?'
}
],
practicalTasks: [
{
title: 'Rollenspiel',
text: 'Spiele ein kurzes Gespräch zwischen Gastgeber und Gast beim Essen.'
}
]
},
'Zeitformen - Grundlagen': {
learningGoals: [
'Ni- und Mo- als einfache Zeitmarker unterscheiden.',
'Kurze Sätze in Vergangenheit und Zukunft bilden.',
'Das Muster laut mit mehreren Verben wiederholen.'
],
corePatterns: [
'Ni-kaon ko.',
'Mo-kaon ko.',
'Ni-adto ko.',
'Mo-adto ko.'
],
grammarFocus: [
{
title: 'Zeitpräfixe',
text: 'Ni- verweist auf Vergangenes, Mo- auf Zukünftiges oder Bevorstehendes.',
example: 'Ni-kaon ko. / Mo-kaon ko.'
}
],
speakingPrompts: [
{
title: 'Vorher und nachher',
prompt: 'Sage einen Satz über etwas, das du getan hast, und einen Satz über etwas, das du tun wirst.',
cue: 'Ni-kaon ko. Mo-adto ko.'
}
],
practicalTasks: [
{
title: 'Mustertraining',
text: 'Nimm ein Verb und sprich es einmal mit Ni- und einmal mit Mo-.'
}
]
},
'Woche 1 - Wiederholung': {
learningGoals: [
'Die Kernmuster der ersten Woche ohne Hilfe wiederholen.',
'Zwischen Begrüßung, Familie und Fürsorge schneller wechseln.',
'Eine kurze Alltagssequenz frei sprechen.'
],
corePatterns: [
'Kumusta ka?',
'Palangga taka.',
'Nikaon na ka?',
'Wala ko kasabot.'
],
speakingPrompts: [
{
title: 'Freie Wiederholung',
prompt: 'Begrüße jemanden, drücke Zuneigung aus und frage fürsorglich nach dem Essen.',
cue: 'Kumusta ka? Palangga taka. Nikaon na ka?'
}
]
},
'Woche 1 - Vokabeltest': {
learningGoals: [
'Die wichtigsten Wörter der ersten Woche schnell abrufen.',
'Bedeutung und Gebrauch zentraler Wörter unterscheiden.',
'Von einzelnen Wörtern zu kurzen Sätzen übergehen.'
],
corePatterns: [
'Kumusta',
'Salamat',
'Lami',
'Mingaw ko nimo'
]
}
};
const LESSONS = [
// WOCHE 1: Grundlagen & Aussprache
{ week: 1, day: 1, num: 1, type: 'conversation', title: 'Begrüßungen & Höflichkeit',
@@ -262,6 +445,11 @@ async function createBisayaCourse(languageId, ownerHashedId) {
dayNumber: lessonData.day,
lessonType: lessonData.type,
culturalNotes: lessonData.cultural,
learningGoals: LESSON_DIDACTICS[lessonData.title]?.learningGoals || [],
corePatterns: LESSON_DIDACTICS[lessonData.title]?.corePatterns || [],
grammarFocus: LESSON_DIDACTICS[lessonData.title]?.grammarFocus || [],
speakingPrompts: LESSON_DIDACTICS[lessonData.title]?.speakingPrompts || [],
practicalTasks: LESSON_DIDACTICS[lessonData.title]?.practicalTasks || [],
targetMinutes: lessonData.targetMin,
targetScorePercent: lessonData.targetScore,
requiresReview: lessonData.review

View File

@@ -0,0 +1,142 @@
#!/usr/bin/env node
/**
* Aggregiert falukant_log.moneyflow nach activity (reale Buchungen aus dem Spiel).
*
* cd backend && npm run diag:moneyflow
*
* Umgebung:
* DIAG_DAYS=30 — Fenster in Tagen (13650, Standard 30)
* DIAG_USER_ID=123 — optional: nur dieser falukant_user
* DIAG_QUERY_TIMEOUT_MS, DIAG_AUTH_TIMEOUT_MS — wie diag:town-worth
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const sqlByActivity = join(__dirname, '../sql/diagnostics/falukant_moneyflow_by_activity.sql');
const sqlTotals = join(__dirname, '../sql/diagnostics/falukant_moneyflow_window_totals.sql');
const QUERY_TIMEOUT_MS = Number.parseInt(process.env.DIAG_QUERY_TIMEOUT_MS || '60000', 10);
const AUTH_TIMEOUT_MS = Number.parseInt(process.env.DIAG_AUTH_TIMEOUT_MS || '25000', 10);
process.env.QUIET_ENV_LOGS = process.env.QUIET_ENV_LOGS || '1';
process.env.DOTENV_CONFIG_QUIET = process.env.DOTENV_CONFIG_QUIET || '1';
if (!process.env.DB_CONNECT_TIMEOUT_MS) {
process.env.DB_CONNECT_TIMEOUT_MS = '15000';
}
await import('../config/loadEnv.js');
const { sequelize } = await import('../utils/sequelize.js');
function withTimeout(promise, ms, onTimeoutError) {
let timerId;
const timeoutPromise = new Promise((_, reject) => {
timerId = setTimeout(() => reject(new Error(onTimeoutError)), ms);
});
return Promise.race([promise, timeoutPromise]).finally(() => {
clearTimeout(timerId);
});
}
function raceQuery(sql) {
return withTimeout(
sequelize.query(sql),
QUERY_TIMEOUT_MS,
`Abfrage-Timeout nach ${QUERY_TIMEOUT_MS} ms (DIAG_QUERY_TIMEOUT_MS)`
);
}
function raceAuth() {
return withTimeout(
sequelize.authenticate(),
AUTH_TIMEOUT_MS,
`authenticate() Timeout nach ${AUTH_TIMEOUT_MS} ms (DIAG_AUTH_TIMEOUT_MS).`
);
}
function printConnectionHints() {
const port = process.env.DB_PORT || '5432';
const host = process.env.DB_HOST || '?';
const local = host === '127.0.0.1' || host === 'localhost' || host === '::1';
console.error('');
console.error('[diag] Mögliche Ursachen:');
if (local) {
console.error(' • SSH-Tunnel: Läuft z. B. ssh -L ' + port + ':127.0.0.1:5432 …? Dann DB_HOST=127.0.0.1 DB_PORT=' + port + ' (DB_SSL meist aus).');
console.error(' • Falscher lokaler Forward-Port in .env (DB_PORT).');
} else {
console.error(' • PostgreSQL erwartet TLS: in .env DB_SSL=1 setzen (ggf. DB_SSL_REJECT_UNAUTHORIZED=0 bei selbstsigniert).');
console.error(' • Falscher Port: DB_PORT=' + port + ' prüfen.');
console.error(' • Server-Firewall: deine IP muss für Port', port, 'auf', host, 'freigeschaltet sein.');
}
console.error(' • Test: nc -zv', host, port);
console.error('');
}
function parseDays() {
const raw = process.env.DIAG_DAYS;
const n = raw === undefined || raw === '' ? 30 : Number.parseInt(raw, 10);
if (!Number.isFinite(n) || n < 1 || n > 3650) {
throw new Error('DIAG_DAYS muss eine Ganzzahl zwischen 1 und 3650 sein');
}
return n;
}
function parseOptionalUserFilter() {
const raw = process.env.DIAG_USER_ID;
if (raw === undefined || raw === '') {
return '';
}
const uid = Number.parseInt(raw, 10);
if (!Number.isInteger(uid) || uid < 1) {
throw new Error('DIAG_USER_ID muss eine positive Ganzzahl sein (falukant_user.id)');
}
return ` AND m.falukant_user_id = ${uid}`;
}
function applyPlaceholders(sql, days, userFilter) {
return sql
.replace(/__DIAG_DAYS__/g, String(days))
.replace(/__DIAG_USER_FILTER__/g, userFilter);
}
try {
const days = parseDays();
const userFilter = parseOptionalUserFilter();
const host = process.env.DB_HOST || '(unbekannt)';
const t0 = Date.now();
console.log('');
console.log('[diag] moneyflow — Fenster:', days, 'Tage', userFilter ? `(User ${process.env.DIAG_USER_ID})` : '(alle Nutzer)');
console.log('[diag] PostgreSQL: authenticate() … (Host:', host + ', Port:', process.env.DB_PORT || '5432', ', DB_SSL:', process.env.DB_SSL === '1' ? '1' : '0', ')');
console.log('');
await raceAuth();
console.log('[diag] authenticate() ok nach', Date.now() - t0, 'ms');
await sequelize.query("SET statement_timeout = '60s'");
const sql1 = applyPlaceholders(readFileSync(sqlByActivity, 'utf8'), days, userFilter);
const sql2 = applyPlaceholders(readFileSync(sqlTotals, 'utf8'), days, userFilter);
const t1 = Date.now();
const [totalsRows] = await raceQuery(sql2);
console.log('[diag] Fenster-Totals nach', Date.now() - t1, 'ms');
console.table(totalsRows);
const t2 = Date.now();
const [byActivity] = await raceQuery(sql1);
console.log('[diag] Nach activity nach', Date.now() - t2, 'ms (gesamt', Date.now() - t0, 'ms)');
console.log('');
console.table(byActivity);
await sequelize.close();
process.exit(0);
} catch (err) {
console.error(err.message || err);
printConnectionHints();
try {
await sequelize.close();
} catch (_) {}
process.exit(1);
}

View File

@@ -0,0 +1,103 @@
#!/usr/bin/env node
/**
* Liest backend/sql/diagnostics/falukant_town_product_worth_stats.sql und gibt eine Tabelle aus.
*
* cd backend && npm run diag:town-worth
*
* SSH-Tunnel: DB_HOST=127.0.0.1, DB_PORT=<lokaler Forward, z. B. 60000> — siehe backend/env.example
* Hängt die Verbindung: Tunnel läuft? Sonst TLS (DB_SSL=1), falscher Port, Firewall.
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const sqlPath = join(__dirname, '../sql/diagnostics/falukant_town_product_worth_stats.sql');
const QUERY_TIMEOUT_MS = Number.parseInt(process.env.DIAG_QUERY_TIMEOUT_MS || '60000', 10);
const AUTH_TIMEOUT_MS = Number.parseInt(process.env.DIAG_AUTH_TIMEOUT_MS || '25000', 10);
process.env.QUIET_ENV_LOGS = process.env.QUIET_ENV_LOGS || '1';
process.env.DOTENV_CONFIG_QUIET = process.env.DOTENV_CONFIG_QUIET || '1';
if (!process.env.DB_CONNECT_TIMEOUT_MS) {
process.env.DB_CONNECT_TIMEOUT_MS = '15000';
}
await import('../config/loadEnv.js');
const { sequelize } = await import('../utils/sequelize.js');
/** Promise.race + Timeout, aber Timer wird bei Erfolg cleared — sonst blockiert setTimeout(60s) den Prozess. */
function withTimeout(promise, ms, onTimeoutError) {
let timerId;
const timeoutPromise = new Promise((_, reject) => {
timerId = setTimeout(() => reject(new Error(onTimeoutError)), ms);
});
return Promise.race([promise, timeoutPromise]).finally(() => {
clearTimeout(timerId);
});
}
function raceQuery(sql) {
return withTimeout(
sequelize.query(sql),
QUERY_TIMEOUT_MS,
`Abfrage-Timeout nach ${QUERY_TIMEOUT_MS} ms (DIAG_QUERY_TIMEOUT_MS)`
);
}
function raceAuth() {
return withTimeout(
sequelize.authenticate(),
AUTH_TIMEOUT_MS,
`authenticate() Timeout nach ${AUTH_TIMEOUT_MS} ms — TCP/TLS zu PostgreSQL kommt nicht zustande (DIAG_AUTH_TIMEOUT_MS).`
);
}
function printConnectionHints() {
const port = process.env.DB_PORT || '5432';
const host = process.env.DB_HOST || '?';
const local = host === '127.0.0.1' || host === 'localhost' || host === '::1';
console.error('');
console.error('[diag] Mögliche Ursachen:');
if (local) {
console.error(' • SSH-Tunnel: Läuft z. B. ssh -L ' + port + ':127.0.0.1:5432 …? Dann DB_HOST=127.0.0.1 DB_PORT=' + port + ' (DB_SSL meist aus).');
console.error(' • Falscher lokaler Forward-Port in .env (DB_PORT).');
} else {
console.error(' • PostgreSQL erwartet TLS: in .env DB_SSL=1 setzen (ggf. DB_SSL_REJECT_UNAUTHORIZED=0 bei selbstsigniert).');
console.error(' • Falscher Port: DB_PORT=' + port + ' prüfen.');
console.error(' • Server-Firewall: deine IP muss für Port', port, 'auf', host, 'freigeschaltet sein.');
}
console.error(' • Test: nc -zv', host, port);
console.error('');
}
try {
const sql = readFileSync(sqlPath, 'utf8');
const host = process.env.DB_HOST || '(unbekannt)';
const t0 = Date.now();
console.log('');
console.log('[diag] PostgreSQL: authenticate() … (Host:', host + ', Port:', process.env.DB_PORT || '5432', ', DB_SSL:', process.env.DB_SSL === '1' ? '1' : '0', ')');
console.log('');
await raceAuth();
console.log('[diag] authenticate() ok nach', Date.now() - t0, 'ms');
await sequelize.query("SET statement_timeout = '30s'");
const t1 = Date.now();
const [rows] = await raceQuery(sql);
console.log('[diag] SELECT ok nach', Date.now() - t1, 'ms (gesamt', Date.now() - t0, 'ms)');
console.log('');
console.table(rows);
await sequelize.close();
process.exit(0);
} catch (err) {
console.error(err.message || err);
printConnectionHints();
try {
await sequelize.close();
} catch (_) {}
process.exit(1);
}

View File

@@ -0,0 +1,157 @@
#!/usr/bin/env node
/**
* Pflegt didaktische Felder in bestehenden Bisaya-Kursen nach.
*
* Verwendung:
* node backend/scripts/update-bisaya-didactics.js
*/
import { sequelize } from '../utils/sequelize.js';
import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
const LESSON_DIDACTICS = {
'Begrüßungen & Höflichkeit': {
learningGoals: [
'Einfache Begrüßungen verstehen und selbst verwenden.',
'Tageszeitbezogene Grüße und einfache Verabschiedungen unterscheiden.',
'Höfliche Reaktionen wie Danke und Bitte passend einsetzen.',
'Ein kurzes Begrüßungs-Mini-Gespräch laut üben.'
],
corePatterns: ['Kumusta ka?', 'Maayong buntag.', 'Maayong adlaw.', 'Amping.', 'Babay.', 'Maayo ko.', 'Salamat.', 'Palihug.'],
grammarFocus: [
{ title: 'Kurzantworten mit ko', text: 'Mit "ko" sprichst du über dich selbst: "Maayo ko."', example: 'Maayo ko. = Mir geht es gut.' },
{ title: 'Maayong + Tageszeit', text: 'Mit "Maayong" kannst du Grüße für verschiedene Tageszeiten bilden.', example: 'Maayong buntag. / Maayong gabii.' }
],
speakingPrompts: [
{ title: 'Mini-Gespräch', prompt: 'Begrüße eine Person, frage nach dem Befinden und reagiere höflich.', cue: 'Kumusta ka? Maayo ko. Salamat.' },
{ title: 'Verabschiedung', prompt: 'Verabschiede dich kurz und wünsche, dass die andere Person auf sich aufpasst.', cue: 'Babay. Amping.' }
],
practicalTasks: [
{ title: 'Alltag', text: 'Sprich die Begrüßung dreimal laut und variiere die Antwort.' }
]
},
'Familienwörter': {
learningGoals: [
'Die wichtigsten Familienbezeichnungen sicher erkennen.',
'Familienmitglieder mit respektvollen Wörtern ansprechen.',
'Kurze Sätze über die eigene Familie bilden.'
],
corePatterns: ['Si Nanay', 'Si Tatay', 'Kuya nako', 'Ate nako'],
grammarFocus: [
{ title: 'Respekt in Familienanreden', text: 'Kuya und Ate werden nicht nur in der Familie, sondern auch respektvoll für ältere Personen benutzt.', example: 'Kuya, palihug.' }
],
speakingPrompts: [
{ title: 'Meine Familie', prompt: 'Stelle zwei Familienmitglieder mit einem kurzen Satz vor.', cue: 'Si Nanay. Si Kuya.' }
],
practicalTasks: [
{ title: 'Familienpraxis', text: 'Nenne laut fünf Familienwörter und bilde danach zwei Mini-Sätze.' }
]
},
'Essen & Fürsorge': {
learningGoals: [
'Fürsorgliche Fragen rund ums Essen verstehen.',
'Einladungen zum Essen passend beantworten.',
'Kurze Essens-Dialoge laut üben.'
],
corePatterns: ['Nikaon na ka?', 'Kaon ta.', 'Gusto ka mokaon?', 'Lami kaayo.'],
grammarFocus: [
{ title: 'na als Zustandsmarker', text: '"na" markiert oft etwas, das bereits eingetreten ist oder jetzt gilt.', example: 'Nikaon na ka?' }
],
speakingPrompts: [
{ title: 'Fürsorge-Dialog', prompt: 'Frage, ob jemand schon gegessen hat, und biete Essen oder Wasser an.', cue: 'Nikaon na ka? Gusto ka mokaon?' }
],
practicalTasks: [
{ title: 'Rollenspiel', text: 'Spiele ein kurzes Gespräch zwischen Gastgeber und Gast beim Essen.' }
]
},
'Zeitformen - Grundlagen': {
learningGoals: [
'Ni- und Mo- als einfache Zeitmarker unterscheiden.',
'Kurze Sätze in Vergangenheit und Zukunft bilden.',
'Das Muster laut mit mehreren Verben wiederholen.'
],
corePatterns: ['Ni-kaon ko.', 'Mo-kaon ko.', 'Ni-adto ko.', 'Mo-adto ko.'],
grammarFocus: [
{ title: 'Zeitpräfixe', text: 'Ni- verweist auf Vergangenes, Mo- auf Zukünftiges oder Bevorstehendes.', example: 'Ni-kaon ko. / Mo-kaon ko.' }
],
speakingPrompts: [
{ title: 'Vorher und nachher', prompt: 'Sage einen Satz über etwas, das du getan hast, und einen Satz über etwas, das du tun wirst.', cue: 'Ni-kaon ko. Mo-adto ko.' }
],
practicalTasks: [
{ title: 'Mustertraining', text: 'Nimm ein Verb und sprich es einmal mit Ni- und einmal mit Mo-.' }
]
},
'Woche 1 - Wiederholung': {
learningGoals: [
'Die Kernmuster der ersten Woche ohne Hilfe wiederholen.',
'Zwischen Begrüßung, Familie und Fürsorge schneller wechseln.',
'Eine kurze Alltagssequenz frei sprechen.'
],
corePatterns: ['Kumusta ka?', 'Palangga taka.', 'Nikaon na ka?', 'Wala ko kasabot.'],
speakingPrompts: [
{ title: 'Freie Wiederholung', prompt: 'Begrüße jemanden, drücke Zuneigung aus und frage fürsorglich nach dem Essen.', cue: 'Kumusta ka? Palangga taka. Nikaon na ka?' }
]
},
'Woche 1 - Vokabeltest': {
learningGoals: [
'Die wichtigsten Wörter der ersten Woche schnell abrufen.',
'Bedeutung und Gebrauch zentraler Wörter unterscheiden.',
'Von einzelnen Wörtern zu kurzen Sätzen übergehen.'
],
corePatterns: ['Kumusta', 'Salamat', 'Lami', 'Mingaw ko nimo']
}
};
async function updateBisayaDidactics() {
await sequelize.authenticate();
const [bisayaLanguage] = await sequelize.query(
`SELECT id FROM community.vocab_language WHERE name = 'Bisaya' LIMIT 1`,
{ type: sequelize.QueryTypes.SELECT }
);
if (!bisayaLanguage) {
console.error('❌ Bisaya-Sprache nicht gefunden.');
return;
}
const lessons = await sequelize.query(
`SELECT l.id
FROM community.vocab_course_lesson l
JOIN community.vocab_course c ON c.id = l.course_id
WHERE c.language_id = :languageId`,
{
replacements: { languageId: bisayaLanguage.id },
type: sequelize.QueryTypes.SELECT
}
);
let updated = 0;
for (const row of lessons) {
const lesson = await VocabCourseLesson.findByPk(row.id);
const didactics = LESSON_DIDACTICS[lesson.title];
if (!didactics) continue;
await lesson.update({
learningGoals: didactics.learningGoals || [],
corePatterns: didactics.corePatterns || [],
grammarFocus: didactics.grammarFocus || [],
speakingPrompts: didactics.speakingPrompts || [],
practicalTasks: didactics.practicalTasks || []
});
updated++;
console.log(`✅ Didaktik aktualisiert: Lektion ${lesson.lessonNumber} - ${lesson.title}`);
}
console.log(`\n🎉 Fertig. ${updated} Lektion(en) aktualisiert.`);
}
updateBisayaDidactics()
.then(() => {
sequelize.close();
process.exit(0);
})
.catch((error) => {
console.error('❌ Fehler:', error);
sequelize.close();
process.exit(1);
});

View File

@@ -14,6 +14,13 @@ import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js';
import User from '../models/community/user.js';
function withTypeName(exerciseTypeName, exercise) {
return {
...exercise,
exerciseTypeName
};
}
const LESSON_TITLES = ['Woche 1 - Wiederholung', 'Woche 1 - Vokabeltest'];
const BISAYA_EXERCISES = {
@@ -22,17 +29,45 @@ const BISAYA_EXERCISES = {
{ exerciseTypeId: 2, title: 'Wiederholung: Wie sagt man "Mutter" auf Bisaya?', instruction: 'Wähle die richtige Übersetzung.', questionData: { type: 'multiple_choice', question: 'Wie sagt man "Mutter" auf Bisaya?', options: ['Nanay', 'Tatay', 'Kuya', 'Ate'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Nanay" bedeutet "Mutter" auf Bisaya.' },
{ exerciseTypeId: 2, title: 'Wiederholung: Was bedeutet "Palangga taka"?', instruction: 'Wähle die richtige Bedeutung.', questionData: { type: 'multiple_choice', question: 'Was bedeutet "Palangga taka"?', options: ['Ich hab dich lieb', 'Danke', 'Guten Tag', 'Auf Wiedersehen'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Palangga taka" bedeutet "Ich hab dich lieb" - wärmer als "I love you" im Familienkontext.' },
{ exerciseTypeId: 2, title: 'Wiederholung: Was fragt man mit "Nikaon ka?"?', instruction: 'Wähle die richtige Bedeutung.', questionData: { type: 'multiple_choice', question: 'Was bedeutet "Nikaon ka?"?', options: ['Hast du schon gegessen?', 'Wie geht es dir?', 'Danke', 'Bitte'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Nikaon ka?" bedeutet "Hast du schon gegessen?" - typisch fürsorglich auf den Philippinen.' },
{ exerciseTypeId: 2, title: 'Wiederholung: Wie sagt man "Ich verstehe nicht"?', instruction: 'Wähle die richtige Übersetzung.', questionData: { type: 'multiple_choice', question: 'Wie sagt man "Ich verstehe nicht" auf Bisaya?', options: ['Wala ko kasabot', 'Salamat', 'Maayo', 'Palihug'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Wala ko kasabot" bedeutet "Ich verstehe nicht".' }
{ exerciseTypeId: 2, title: 'Wiederholung: Wie sagt man "Ich verstehe nicht"?', instruction: 'Wähle die richtige Übersetzung.', questionData: { type: 'multiple_choice', question: 'Wie sagt man "Ich verstehe nicht" auf Bisaya?', options: ['Wala ko kasabot', 'Salamat', 'Maayo', 'Palihug'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Wala ko kasabot" bedeutet "Ich verstehe nicht".' },
{ exerciseTypeId: 3, title: 'Woche 1: Minisatz bauen', instruction: 'Schreibe eine kurze Sequenz aus Begrüßung und Fürsorge.', questionData: { type: 'sentence_building', question: 'Baue: "Wie geht es dir? Hast du schon gegessen?"', tokens: ['Kumusta', 'ka', 'Nikaon', 'na', 'ka'] }, answerData: { correct: ['Kumusta ka? Nikaon na ka?', 'Kumusta ka. Nikaon na ka?'] }, explanation: 'Hier kombinierst du zwei wichtige Muster aus Woche 1.' },
withTypeName('dialog_completion', { title: 'Woche 1: Dialog ergänzen', instruction: 'Ergänze die passende liebevolle Reaktion.', questionData: { type: 'dialog_completion', question: 'Welche Antwort passt?', dialog: ['A: Mingaw ko nimo.', 'B: ...'] }, answerData: { modelAnswer: 'Palangga taka.', correct: ['Palangga taka.'] }, explanation: 'Die Kombination klingt im Familienkontext warm und natürlich.' })
],
'Woche 1 - Vokabeltest': [
{ exerciseTypeId: 2, title: 'Vokabeltest: Kumusta', instruction: 'Was bedeutet "Kumusta"?', questionData: { type: 'multiple_choice', question: 'Was bedeutet "Kumusta"?', options: ['Wie geht es dir?', 'Danke', 'Bitte', 'Auf Wiedersehen'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Kumusta" kommt von spanisch "¿Cómo está?" - "Wie geht es dir?"' },
{ exerciseTypeId: 2, title: 'Vokabeltest: Lola', instruction: 'Wähle die richtige Übersetzung.', questionData: { type: 'multiple_choice', question: 'Was bedeutet "Lola"?', options: ['Großmutter', 'Großvater', 'Mutter', 'Vater'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Lola" = Großmutter, "Lolo" = Großvater.' },
{ exerciseTypeId: 2, title: 'Vokabeltest: Salamat', instruction: 'Wähle die richtige Bedeutung.', questionData: { type: 'multiple_choice', question: 'Was bedeutet "Salamat"?', options: ['Danke', 'Bitte', 'Entschuldigung', 'Gern geschehen'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Salamat" bedeutet "Danke".' },
{ exerciseTypeId: 2, title: 'Vokabeltest: Lami', instruction: 'Was bedeutet "Lami"?', questionData: { type: 'multiple_choice', question: 'Was bedeutet "Lami"?', options: ['Lecker', 'Viel', 'Gut', 'Schnell'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Lami" bedeutet "lecker" oder "schmackhaft" - wichtig beim Essen!' },
{ exerciseTypeId: 2, title: 'Vokabeltest: Mingaw ko nimo', instruction: 'Wähle die richtige Bedeutung.', questionData: { type: 'multiple_choice', question: 'Was bedeutet "Mingaw ko nimo"?', options: ['Ich vermisse dich', 'Ich freue mich', 'Ich mag dich', 'Ich liebe dich'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Mingaw ko nimo" bedeutet "Ich vermisse dich".' }
{ exerciseTypeId: 2, title: 'Vokabeltest: Mingaw ko nimo', instruction: 'Wähle die richtige Bedeutung.', questionData: { type: 'multiple_choice', question: 'Was bedeutet "Mingaw ko nimo"?', options: ['Ich vermisse dich', 'Ich freue mich', 'Ich mag dich', 'Ich liebe dich'] }, answerData: { type: 'multiple_choice', correctAnswer: 0 }, explanation: '"Mingaw ko nimo" bedeutet "Ich vermisse dich".' },
withTypeName('situational_response', { title: 'Woche 1: Situative Kurzantwort', instruction: 'Reagiere passend auf die Situation.', questionData: { type: 'situational_response', question: 'Jemand fragt: "Kumusta ka?" Antworte kurz und höflich.', keywords: ['maayo', 'salamat'] }, answerData: { modelAnswer: 'Maayo ko, salamat.', keywords: ['maayo', 'salamat'] }, explanation: 'Eine kurze höfliche Antwort reicht hier völlig aus.' })
]
};
async function resolveExerciseTypeId(exercise) {
if (exercise.exerciseTypeId) {
return exercise.exerciseTypeId;
}
const name = exercise.exerciseTypeName;
if (name === undefined || name === null || String(name).trim() === '') {
throw new Error(`Kein exerciseTypeId oder exerciseTypeName für Übung "${exercise.title || 'unbenannt'}"`);
}
const [type] = await sequelize.query(
`SELECT id FROM community.vocab_grammar_exercise_type WHERE name = :name LIMIT 1`,
{
replacements: { name: String(name).trim() },
type: sequelize.QueryTypes.SELECT
}
);
if (!type) {
throw new Error(`Übungstyp "${String(name).trim()}" nicht gefunden`);
}
return Number(type.id);
}
async function updateWeek1BisayaExercises() {
await sequelize.authenticate();
console.log('Datenbankverbindung erfolgreich hergestellt.\n');
@@ -93,9 +128,10 @@ async function updateWeek1BisayaExercises() {
let exerciseNumber = 1;
for (const ex of exercises) {
const exerciseTypeId = await resolveExerciseTypeId(ex);
await VocabGrammarExercise.create({
lessonId: lesson.id,
exerciseTypeId: ex.exerciseTypeId,
exerciseTypeId,
exerciseNumber: exerciseNumber++,
title: ex.title,
instruction: ex.instruction,

View File

@@ -24,12 +24,69 @@ 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 UserParam from '../models/community/user_param.js';
import Image from '../models/community/image.js';
import EroticVideo from '../models/community/erotic_video.js';
import EroticContentReport from '../models/community/erotic_content_report.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';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { getAdultVerificationBaseDir, getLegacyAdultVerificationBaseDir } from '../utils/storagePaths.js';
import { notifyUser } from '../utils/socket.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
class AdminService {
resolveAdultVerificationFile(requestData) {
if (!requestData || typeof requestData !== 'object') {
return null;
}
const candidates = [];
if (requestData.fileName) {
candidates.push(path.join(getAdultVerificationBaseDir(), requestData.fileName));
candidates.push(path.join(getLegacyAdultVerificationBaseDir(), requestData.fileName));
}
if (requestData.storedFileName && requestData.storedFileName !== requestData.fileName) {
candidates.push(path.join(getAdultVerificationBaseDir(), requestData.storedFileName));
candidates.push(path.join(getLegacyAdultVerificationBaseDir(), requestData.storedFileName));
}
if (requestData.filePath) {
candidates.push(path.isAbsolute(requestData.filePath)
? requestData.filePath
: path.join(__dirname, '..', requestData.filePath));
}
return candidates.find((candidate) => candidate && fs.existsSync(candidate)) || null;
}
removeEroticStorageFile(type, hash) {
if (!hash) {
return;
}
const storageFolder = type === 'image' ? 'erotic' : 'erotic-video';
const filePath = path.join(__dirname, '..', 'images', storageFolder, hash);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
calculateAgeFromBirthdate(birthdate) {
if (!birthdate) return null;
const today = new Date();
const birthDateObj = new Date(birthdate);
let age = today.getFullYear() - birthDateObj.getFullYear();
const monthDiff = today.getMonth() - birthDateObj.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDateObj.getDate())) {
age--;
}
return age;
}
async hasUserAccess(userId, section) {
const userRights = await UserRight.findAll({
include: [{
@@ -232,6 +289,369 @@ class AdminService {
});
}
async getAdultVerificationRequests(userId, status = 'pending') {
if (!(await this.hasUserAccess(userId, 'useradministration'))) {
throw new Error('noaccess');
}
const users = await User.findAll({
attributes: ['id', 'hashedId', 'username', 'active'],
include: [
{
model: UserParam,
as: 'user_params',
required: false,
include: [
{
model: UserParamType,
as: 'paramType',
where: { description: ['birthdate', 'adult_verification_status', 'adult_verification_request'] }
}
]
}
],
order: [['username', 'ASC']]
});
const rows = users.map((user) => {
const birthdateParam = user.user_params.find((param) => param.paramType?.description === 'birthdate');
const statusParam = user.user_params.find((param) => param.paramType?.description === 'adult_verification_status');
const requestParam = user.user_params.find((param) => param.paramType?.description === 'adult_verification_request');
const age = this.calculateAgeFromBirthdate(birthdateParam?.value);
const verificationStatus = ['pending', 'approved', 'rejected'].includes(statusParam?.value)
? statusParam.value
: 'none';
let verificationRequest = null;
try {
verificationRequest = requestParam?.value ? JSON.parse(requestParam.value) : null;
} catch {
verificationRequest = null;
}
const resolvedDocumentPath = this.resolveAdultVerificationFile(verificationRequest);
return {
id: user.hashedId,
username: user.username,
active: !!user.active,
age,
adultVerificationStatus: verificationStatus,
adultVerificationRequest: verificationRequest,
adultVerificationDocumentAvailable: !!resolvedDocumentPath
};
}).filter((row) => row.age !== null && row.age >= 18);
if (status === 'all') {
return rows;
}
return rows.filter((row) => row.adultVerificationStatus === status);
}
async setAdultVerificationStatus(requesterId, targetHashedId, status) {
if (!(await this.hasUserAccess(requesterId, 'useradministration'))) {
throw new Error('noaccess');
}
if (!['approved', 'rejected', 'pending'].includes(status)) {
throw new Error('wrongstatus');
}
const user = await User.findOne({
where: { hashedId: targetHashedId },
attributes: ['id']
});
if (!user) {
throw new Error('notfound');
}
const birthdateParam = await UserParam.findOne({
where: { userId: user.id },
include: [{
model: UserParamType,
as: 'paramType',
where: { description: 'birthdate' }
}]
});
const age = this.calculateAgeFromBirthdate(birthdateParam?.value);
if (age === null || age < 18) {
throw new Error('notadult');
}
const paramType = await UserParamType.findOne({
where: { description: 'adult_verification_status' }
});
if (!paramType) {
throw new Error('missingparamtype');
}
const existing = await UserParam.findOne({
where: { userId: user.id, paramTypeId: paramType.id }
});
const previousStatus = existing?.value || 'none';
if (existing) {
await existing.update({ value: status });
} else {
await UserParam.create({
userId: user.id,
paramTypeId: paramType.id,
value: status
});
}
await notifyUser(targetHashedId, 'reloadmenu', {});
await notifyUser(targetHashedId, 'adultVerificationChanged', {
status,
previousStatus
});
return { success: true };
}
async getAdultVerificationDocument(requesterId, targetHashedId) {
if (!(await this.hasUserAccess(requesterId, 'useradministration'))) {
throw new Error('noaccess');
}
const user = await User.findOne({
where: { hashedId: targetHashedId },
attributes: ['id', 'username']
});
if (!user) {
throw new Error('notfound');
}
const requestParam = await UserParam.findOne({
where: { userId: user.id },
include: [{
model: UserParamType,
as: 'paramType',
where: { description: 'adult_verification_request' }
}]
});
if (!requestParam?.value) {
throw new Error('norequest');
}
let requestData;
try {
requestData = JSON.parse(requestParam.value);
} catch {
throw new Error('norequest');
}
const filePath = this.resolveAdultVerificationFile(requestData);
if (!filePath) {
throw new Error('nofile');
}
return {
filePath,
mimeType: requestData.mimeType || 'application/octet-stream',
originalName: requestData.originalName || `${user.username}-verification`
};
}
async ensureUserParam(userId, description, value) {
const paramType = await UserParamType.findOne({
where: { description }
});
if (!paramType) {
throw new Error('missingparamtype');
}
const existing = await UserParam.findOne({
where: { userId, paramTypeId: paramType.id }
});
if (existing) {
await existing.update({ value });
return existing;
}
return await UserParam.create({
userId,
paramTypeId: paramType.id,
value
});
}
async getEroticModerationReports(userId, status = 'open') {
if (!(await this.hasUserAccess(userId, 'useradministration'))) {
throw new Error('noaccess');
}
const where = status === 'all' ? {} : { status };
const reports = await EroticContentReport.findAll({
where,
include: [
{
model: User,
as: 'reporter',
attributes: ['id', 'hashedId', 'username']
},
{
model: User,
as: 'moderator',
attributes: ['id', 'hashedId', 'username'],
required: false
}
],
order: [['createdAt', 'DESC']]
});
const rows = [];
for (const report of reports) {
let target = null;
if (report.targetType === 'image') {
target = await Image.findByPk(report.targetId, {
attributes: ['id', 'title', 'hash', 'userId', 'isModeratedHidden']
});
} else if (report.targetType === 'video') {
target = await EroticVideo.findByPk(report.targetId, {
attributes: ['id', 'title', 'hash', 'userId', 'isModeratedHidden']
});
}
const owner = target ? await User.findByPk(target.userId, {
attributes: ['hashedId', 'username']
}) : null;
rows.push({
id: report.id,
targetType: report.targetType,
targetId: report.targetId,
reason: report.reason,
note: report.note,
status: report.status,
actionTaken: report.actionTaken,
handledAt: report.handledAt,
createdAt: report.createdAt,
reporter: report.reporter,
moderator: report.moderator,
target: target ? {
id: target.id,
title: target.title,
hash: target.hash,
isModeratedHidden: !!target.isModeratedHidden
} : null,
owner
});
}
return rows;
}
async applyEroticModerationAction(requesterId, reportId, action, note = null) {
if (!(await this.hasUserAccess(requesterId, 'useradministration'))) {
throw new Error('noaccess');
}
if (!['dismiss', 'hide_content', 'restore_content', 'delete_content', 'block_uploads', 'revoke_access'].includes(action)) {
throw new Error('wrongaction');
}
const moderator = await User.findOne({
where: { hashedId: requesterId },
attributes: ['id']
});
const report = await EroticContentReport.findByPk(reportId);
if (!report) {
throw new Error('notfound');
}
let target = null;
if (report.targetType === 'image') {
target = await Image.findByPk(report.targetId);
} else if (report.targetType === 'video') {
target = await EroticVideo.findByPk(report.targetId);
}
if (action === 'dismiss') {
await report.update({
status: 'dismissed',
actionTaken: 'dismiss',
handledBy: moderator?.id || null,
handledAt: new Date(),
note: note ?? report.note
});
return { success: true };
}
if (!target) {
throw new Error('targetnotfound');
}
if (action === 'hide_content') {
await target.update({ isModeratedHidden: true });
} else if (action === 'restore_content') {
await target.update({ isModeratedHidden: false });
} else if (action === 'delete_content') {
this.removeEroticStorageFile(report.targetType, target.hash);
await target.destroy();
} else if (action === 'block_uploads') {
await this.ensureUserParam(target.userId, 'adult_upload_blocked', 'true');
} else if (action === 'revoke_access') {
await this.ensureUserParam(target.userId, 'adult_verification_status', 'rejected');
}
await report.update({
status: 'actioned',
actionTaken: action,
handledBy: moderator?.id || null,
handledAt: new Date(),
note: note ?? report.note
});
return { success: true };
}
async getEroticModerationPreview(requesterId, targetType, targetId) {
if (!(await this.hasUserAccess(requesterId, 'useradministration'))) {
throw new Error('noaccess');
}
if (targetType === 'image') {
const target = await Image.findByPk(targetId, {
attributes: ['hash', 'originalFileName']
});
if (!target) {
throw new Error('notfound');
}
const filePath = path.join(__dirname, '..', 'images', 'erotic', target.hash);
if (!fs.existsSync(filePath)) {
throw new Error('nofile');
}
return {
filePath,
mimeType: this.getMimeTypeFromName(target.originalFileName) || 'image/jpeg',
originalName: target.originalFileName || target.hash
};
}
if (targetType === 'video') {
const target = await EroticVideo.findByPk(targetId, {
attributes: ['hash', 'mimeType', 'originalFileName']
});
if (!target) {
throw new Error('notfound');
}
const filePath = path.join(__dirname, '..', 'images', 'erotic-video', target.hash);
if (!fs.existsSync(filePath)) {
throw new Error('nofile');
}
return {
filePath,
mimeType: target.mimeType || this.getMimeTypeFromName(target.originalFileName) || 'application/octet-stream',
originalName: target.originalFileName || target.hash
};
}
throw new Error('wrongtype');
}
getMimeTypeFromName(fileName) {
const lower = String(fileName || '').toLowerCase();
if (lower.endsWith('.png')) return 'image/png';
if (lower.endsWith('.webp')) return 'image/webp';
if (lower.endsWith('.gif')) return 'image/gif';
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
return null;
}
async getFalukantUserById(userId, hashedId) {
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
throw new Error('noaccess');
@@ -682,6 +1102,7 @@ class AdminService {
'title',
'roomTypeId',
'isPublic',
'isAdultOnly',
'genderRestrictionId',
'minAge',
'maxAge',

View File

@@ -14,6 +14,14 @@ import Friendship from '../models/community/friendship.js';
const saltRounds = 10;
const buildEncryptedEmailCandidates = (email) => {
const encrypted = encrypt(email);
return [
Buffer.from(encrypted, 'utf8'),
Buffer.from(encrypted, 'hex')
];
};
const getFriends = async (userId) => {
console.log('getFriends', userId);
try {
@@ -54,13 +62,13 @@ const getFriends = async (userId) => {
};
export const registerUser = async ({ email, username, password, language }) => {
const encryptedEmail = encrypt(email);
const encryptedEmailCandidates = buildEncryptedEmailCandidates(email);
const query = `
SELECT id FROM community.user
WHERE email = :encryptedEmail
WHERE email = ANY(:encryptedEmails)
`;
const existingUser = await sequelize.query(query, {
replacements: { encryptedEmail },
replacements: { encryptedEmails: encryptedEmailCandidates },
type: sequelize.QueryTypes.SELECT,
});
if (existingUser.length > 0) {
@@ -170,7 +178,14 @@ export const logoutUser = async (hashedUserId) => {
};
export const handleForgotPassword = async ({ email }) => {
const user = await User.findOne({ where: { email } });
const encryptedEmailCandidates = buildEncryptedEmailCandidates(email);
const user = await User.findOne({
where: {
email: {
[Op.in]: encryptedEmailCandidates
}
}
});
if (!user) {
throw new Error('Email not found');
}

View File

@@ -2,6 +2,8 @@ import { v4 as uuidv4 } from 'uuid';
import amqp from 'amqplib/callback_api.js';
import User from '../models/community/user.js';
import Room from '../models/chat/room.js';
import UserParam from '../models/community/user_param.js';
import UserParamType from '../models/type/user_param.js';
const RABBITMQ_URL = process.env.AMQP_URL || 'amqp://localhost';
const QUEUE = 'oneToOne_messages';
@@ -167,16 +169,68 @@ class ChatService {
);
}
async getRoomList() {
calculateAge(birthdate) {
const birthDate = new Date(birthdate);
const ageDifMs = Date.now() - birthDate.getTime();
const ageDate = new Date(ageDifMs);
return Math.abs(ageDate.getUTCFullYear() - 1970);
}
normalizeAdultVerificationStatus(value) {
if (!value) return 'none';
const normalized = String(value).trim().toLowerCase();
return ['pending', 'approved', 'rejected'].includes(normalized) ? normalized : 'none';
}
async getAdultAccessState(hashedUserId) {
if (!hashedUserId) {
return { isAdult: false, adultVerificationStatus: 'none', adultAccessEnabled: false };
}
const user = await User.findOne({ where: { hashedId: hashedUserId }, attributes: ['id'] });
if (!user) {
throw new Error('user_not_found');
}
const params = await UserParam.findAll({
where: { userId: user.id },
include: [{
model: UserParamType,
as: 'paramType',
where: { description: ['birthdate', 'adult_verification_status'] }
}]
});
const birthdateParam = params.find(param => param.paramType?.description === 'birthdate');
const statusParam = params.find(param => param.paramType?.description === 'adult_verification_status');
const age = birthdateParam ? this.calculateAge(birthdateParam.value) : 0;
const adultVerificationStatus = this.normalizeAdultVerificationStatus(statusParam?.value);
return {
isAdult: age >= 18,
adultVerificationStatus,
adultAccessEnabled: age >= 18 && adultVerificationStatus === 'approved'
};
}
async getRoomList(hashedUserId, { adultOnly = false } = {}) {
// Nur öffentliche Räume, keine sensiblen Felder
const { default: Room } = await import('../models/chat/room.js');
const { default: RoomType } = await import('../models/chat/room_type.js');
const where = { isPublic: true, isAdultOnly: Boolean(adultOnly) };
if (adultOnly) {
const adultAccess = await this.getAdultAccessState(hashedUserId);
if (!adultAccess.adultAccessEnabled) {
return [];
}
}
return Room.findAll({
attributes: [
'id', 'title', 'roomTypeId', 'isPublic', 'genderRestrictionId',
'id', 'title', 'roomTypeId', 'isPublic', 'isAdultOnly', 'genderRestrictionId',
'minAge', 'maxAge', 'friendsOfOwnerOnly', 'requiredUserRightId'
],
where: { isPublic: true },
where,
include: [
{ model: RoomType, as: 'roomType' }
]
@@ -215,7 +269,7 @@ class ChatService {
return Room.findAll({
where: { ownerId: user.id },
attributes: ['id', 'title', 'isPublic', 'roomTypeId', 'ownerId'],
attributes: ['id', 'title', 'isPublic', 'isAdultOnly', 'roomTypeId', 'ownerId'],
order: [['title', 'ASC']]
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,9 +10,145 @@ import InterestTranslation from '../models/type/interest_translation.js';
import { Op } from 'sequelize';
import UserParamVisibilityType from '../models/type/user_param_visibility.js';
import UserParamVisibility from '../models/community/user_param_visibility.js';
import { generateIv } from '../utils/encryption.js';
import { encrypt } from '../utils/encryption.js';
import { sequelize } from '../utils/sequelize.js';
import fsPromises from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
import { v4 as uuidv4 } from 'uuid';
import { getAdultVerificationBaseDir } from '../utils/storagePaths.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/** Wie UserParam.value-Setter: bei Verschlüsselungsfehler leeren String speichern, nicht crashen. */
function encryptUserParamValue(plain) {
try {
return encrypt(plain);
} catch (error) {
console.error('Error encrypting user_param value:', error);
return '';
}
}
class SettingsService extends BaseService{
async ensureSpecialUserParamType(description) {
const specialTypes = {
adult_verification_status: { datatype: 'string', setting: 'account', orderId: 910, minAge: 18 },
adult_verification_request: { datatype: 'string', setting: 'account', orderId: 911, minAge: 18 },
adult_upload_blocked: { datatype: 'bool', setting: 'account', orderId: 912, minAge: 18 },
};
const definition = specialTypes[description];
if (!definition) {
return null;
}
const [settingsType] = await SettingsType.findOrCreate({
where: { name: definition.setting },
defaults: { name: definition.setting }
});
const [paramType] = await UserParamType.findOrCreate({
where: { description },
defaults: {
description,
datatype: definition.datatype,
settingsId: settingsType.id,
orderId: definition.orderId,
minAge: definition.minAge,
immutable: false
}
});
return paramType;
}
parseAdultVerificationRequest(value) {
if (!value) return null;
try {
return JSON.parse(value);
} catch {
return null;
}
}
normalizeAdultVerificationStatus(value) {
if (['pending', 'approved', 'rejected'].includes(value)) {
return value;
}
return 'none';
}
async getAdultAccessStateByUserId(userId) {
const userParams = await this.getUserParams(userId, ['birthdate', 'adult_verification_status', 'adult_verification_request']);
let birthdate = null;
let adultVerificationStatus = 'none';
let adultVerificationRequest = null;
for (const param of userParams) {
if (param.paramType.description === 'birthdate') {
birthdate = param.value;
}
if (param.paramType.description === 'adult_verification_status') {
adultVerificationStatus = this.normalizeAdultVerificationStatus(param.value);
}
if (param.paramType.description === 'adult_verification_request') {
adultVerificationRequest = this.parseAdultVerificationRequest(param.value);
}
}
const age = birthdate ? this.calculateAge(birthdate) : null;
const isAdult = age !== null && age >= 18;
return {
age,
isAdult,
adultVerificationStatus: isAdult ? adultVerificationStatus : 'none',
adultVerificationRequest: isAdult ? adultVerificationRequest : null,
adultAccessEnabled: isAdult && adultVerificationStatus === 'approved'
};
}
buildAdultVerificationFilePath(fileName) {
return path.join(getAdultVerificationBaseDir(), fileName);
}
async saveAdultVerificationDocument(file) {
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
if (!file || !file.buffer) {
throw new Error('No verification document provided');
}
if (!allowedMimeTypes.includes(file.mimetype)) {
throw new Error('Unsupported verification document type');
}
const ext = path.extname(file.originalname || '').toLowerCase();
const safeExt = ext && ext.length <= 8 ? ext : (file.mimetype === 'application/pdf' ? '.pdf' : '.bin');
const fileName = `${uuidv4()}${safeExt}`;
const filePath = this.buildAdultVerificationFilePath(fileName);
await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
await fsPromises.writeFile(filePath, file.buffer);
return { fileName, filePath };
}
async upsertUserParam(userId, description, value) {
let paramType = await UserParamType.findOne({ where: { description } });
if (!paramType) {
paramType = await this.ensureSpecialUserParamType(description);
}
if (!paramType) {
throw new Error(`Missing user param type: ${description}`);
}
const existingParam = await UserParam.findOne({
where: { userId, paramTypeId: paramType.id }
});
if (existingParam) {
await existingParam.update({ value });
return existingParam;
}
return UserParam.create({
userId,
paramTypeId: paramType.id,
value
});
}
async getUserParams(userId, paramDescriptions) {
return await UserParam.findAll({
where: { userId },
@@ -288,10 +424,13 @@ class SettingsService extends BaseService{
email = null;
}
const adultAccess = await this.getAdultAccessStateByUserId(user.id);
return {
username: user.username,
email: email,
showinsearch: user.searchable
showinsearch: user.searchable,
...adultAccess
};
} catch (error) {
console.error('Error getting account settings:', error);
@@ -306,6 +445,8 @@ class SettingsService extends BaseService{
throw new Error('User not found');
}
const adultAccess = await this.getAdultAccessStateByUserId(user.id);
// Update username if provided
if (settings.username !== undefined) {
await user.update({ username: settings.username });
@@ -321,6 +462,17 @@ class SettingsService extends BaseService{
await user.update({ searchable: settings.showinsearch });
}
if (settings.requestAdultVerification) {
if (!adultAccess.isAdult) {
throw new Error('Adult verification can only be requested by adult users');
}
const normalizedValue = adultAccess.adultVerificationStatus === 'approved'
? 'approved'
: 'pending';
await this.upsertUserParam(user.id, 'adult_verification_status', normalizedValue);
}
// Update password if provided and not empty
if (settings.newpassword && settings.newpassword.trim() !== '') {
if (!settings.oldpassword || settings.oldpassword.trim() === '') {
@@ -346,6 +498,36 @@ class SettingsService extends BaseService{
}
}
async submitAdultVerificationRequest(hashedUserId, { note }, file) {
const user = await this.getUserByHashedId(hashedUserId);
if (!user) {
throw new Error('User not found');
}
const adultAccess = await this.getAdultAccessStateByUserId(user.id);
if (!adultAccess.isAdult) {
throw new Error('Adult verification can only be requested by adult users');
}
if (!file) {
throw new Error('No verification document provided');
}
const savedFile = await this.saveAdultVerificationDocument(file);
const requestPayload = {
fileName: savedFile.fileName,
storedFileName: savedFile.fileName,
filePath: savedFile.filePath,
originalName: file.originalname,
mimeType: file.mimetype,
note: note || '',
submittedAt: new Date().toISOString()
};
await this.upsertUserParam(user.id, 'adult_verification_request', JSON.stringify(requestPayload));
await this.upsertUserParam(user.id, 'adult_verification_status', adultAccess.adultVerificationStatus === 'approved' ? 'approved' : 'pending');
return requestPayload;
}
async getVisibilities() {
return UserParamVisibilityType.findAll();
}
@@ -381,6 +563,137 @@ class SettingsService extends BaseService{
throw error;
}
}
/**
* LLM-/Sprachassistent: Werte in community.user_param, Typen in type.user_param,
* Gruppe type.settings.name = languageAssistant. API-Key separat (llm_api_key), Metadaten als JSON in llm_settings.
* Kein Klartext-Key an den Client.
*/
async getLlmSettings(hashedUserId) {
const user = await this.getUserByHashedId(hashedUserId);
const settingsType = await UserParamType.findOne({ where: { description: 'llm_settings' } });
const apiKeyType = await UserParamType.findOne({ where: { description: 'llm_api_key' } });
if (!settingsType || !apiKeyType) {
return {
enabled: true,
baseUrl: '',
model: 'gpt-4o-mini',
hasKey: false,
keyLast4: null
};
}
const settingsRow = await UserParam.findOne({
where: { userId: user.id, paramTypeId: settingsType.id }
});
const keyRow = await UserParam.findOne({
where: { userId: user.id, paramTypeId: apiKeyType.id }
});
let parsed = {};
if (settingsRow?.value) {
try {
parsed = JSON.parse(settingsRow.value);
} catch {
parsed = {};
}
}
const hasStoredKey = Boolean(keyRow && keyRow.getDataValue('value') && String(keyRow.getDataValue('value')).trim());
const hasReadableKey = Boolean(keyRow && keyRow.value && String(keyRow.value).trim());
return {
enabled: parsed.enabled !== false,
baseUrl: parsed.baseUrl || '',
model: parsed.model || 'gpt-4o-mini',
hasKey: hasStoredKey,
keyLast4: parsed.keyLast4 || null,
keyStatus: hasStoredKey ? (hasReadableKey ? 'stored' : 'invalid') : 'missing'
};
}
async saveLlmSettings(hashedUserId, payload) {
const user = await this.getUserByHashedId(hashedUserId);
const settingsType = await UserParamType.findOne({ where: { description: 'llm_settings' } });
const apiKeyType = await UserParamType.findOne({ where: { description: 'llm_api_key' } });
if (!settingsType || !apiKeyType) {
throw new Error(
'LLM-Einstellungstypen fehlen (languageAssistant / llm_settings / llm_api_key). initializeSettings & initializeTypes ausführen.'
);
}
const settingsRow = await UserParam.findOne({
where: { userId: user.id, paramTypeId: settingsType.id }
});
let parsed = {};
if (settingsRow?.value) {
try {
parsed = JSON.parse(settingsRow.value);
} catch {
parsed = {};
}
}
const { apiKey, clearKey, baseUrl, model, enabled } = payload;
await sequelize.transaction(async (transaction) => {
if (clearKey) {
const keyRow = await UserParam.findOne({
where: { userId: user.id, paramTypeId: apiKeyType.id },
transaction
});
if (keyRow) {
await keyRow.destroy({ transaction });
}
delete parsed.keyLast4;
} else if (apiKey !== undefined && String(apiKey).trim() !== '') {
const plain = String(apiKey).trim();
parsed.keyLast4 = plain.length >= 4 ? plain.slice(-4) : plain;
const encKey = encryptUserParamValue(plain);
const [keyRow] = await UserParam.findOrCreate({
where: { userId: user.id, paramTypeId: apiKeyType.id },
defaults: {
userId: user.id,
paramTypeId: apiKeyType.id,
// Platzhalter: Setter verschlüsselt; wird sofort durch encKey überschrieben.
value: ' '
},
transaction
});
keyRow.setDataValue('value', encKey);
await keyRow.save({ fields: ['value'], transaction });
}
if (baseUrl !== undefined) {
parsed.baseUrl = String(baseUrl).trim();
}
if (model !== undefined) {
parsed.model = String(model).trim() || 'gpt-4o-mini';
}
if (enabled !== undefined) {
parsed.enabled = Boolean(enabled);
}
if (!parsed.model) {
parsed.model = 'gpt-4o-mini';
}
const jsonStr = JSON.stringify(parsed);
const encMeta = encryptUserParamValue(jsonStr);
const [metaRow] = await UserParam.findOrCreate({
where: { userId: user.id, paramTypeId: settingsType.id },
defaults: {
userId: user.id,
paramTypeId: settingsType.id,
value: ' '
},
transaction
});
metaRow.setDataValue('value', encMeta);
await metaRow.save({ fields: ['value'], transaction });
});
return { success: true };
}
}
export default new SettingsService();

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,8 @@ import VocabCourseProgress from '../models/community/vocab_course_progress.js';
import VocabGrammarExerciseType from '../models/community/vocab_grammar_exercise_type.js';
import VocabGrammarExercise from '../models/community/vocab_grammar_exercise.js';
import VocabGrammarExerciseProgress from '../models/community/vocab_grammar_exercise_progress.js';
import UserParamType from '../models/type/user_param.js';
import UserParam from '../models/community/user_param.js';
import { sequelize } from '../utils/sequelize.js';
import { notifyUser } from '../utils/socket.js';
import { Op } from 'sequelize';
@@ -22,6 +24,117 @@ export default class VocabService {
return user;
}
async _getUserLlmConfig(userId) {
const [settingsType, apiKeyType] = await Promise.all([
UserParamType.findOne({ where: { description: 'llm_settings' } }),
UserParamType.findOne({ where: { description: 'llm_api_key' } })
]);
if (!settingsType || !apiKeyType) {
return {
enabled: false,
baseUrl: '',
model: 'gpt-4o-mini',
hasKey: false,
apiKey: null,
configured: false
};
}
const [settingsRow, keyRow] = await Promise.all([
UserParam.findOne({ where: { userId, paramTypeId: settingsType.id } }),
UserParam.findOne({ where: { userId, paramTypeId: apiKeyType.id } })
]);
let parsed = {};
if (settingsRow?.value) {
try {
parsed = JSON.parse(settingsRow.value);
} catch {
parsed = {};
}
}
const decryptedKey = keyRow?.value ? String(keyRow.value).trim() : null;
const hasKey = Boolean(decryptedKey && String(decryptedKey).trim());
const enabled = parsed.enabled !== false;
const baseUrl = String(parsed.baseUrl || '').trim();
return {
enabled,
baseUrl,
model: String(parsed.model || 'gpt-4o-mini').trim() || 'gpt-4o-mini',
hasKey,
apiKey: hasKey ? decryptedKey : null,
configured: enabled && (hasKey || Boolean(baseUrl))
};
}
_sanitizeAssistantHistory(history) {
if (!Array.isArray(history)) {
return [];
}
return history
.slice(-8)
.map((entry) => ({
role: entry?.role === 'assistant' ? 'assistant' : 'user',
content: String(entry?.content || '').trim()
}))
.filter((entry) => entry.content);
}
_buildLessonAssistantSystemPrompt(lesson, mode = 'practice') {
const didactics = lesson?.didactics || {};
const learningGoals = Array.isArray(didactics.learningGoals) ? didactics.learningGoals : [];
const corePatterns = Array.isArray(didactics.corePatterns) ? didactics.corePatterns : [];
const speakingPrompts = Array.isArray(didactics.speakingPrompts) ? didactics.speakingPrompts : [];
const practicalTasks = Array.isArray(didactics.practicalTasks) ? didactics.practicalTasks : [];
const modeDirectives = {
explain: 'Erkläre knapp und klar die Grammatik, Muster und typische Fehler dieser Lektion. Nutze kurze Beispiele.',
practice: 'Führe den Nutzer aktiv durch kurze Sprachpraxis. Stelle Rückfragen, gib kleine Aufgaben und fordere zu eigenen Antworten auf.',
correct: 'Korrigiere Eingaben freundlich, konkret und knapp. Zeige eine bessere Formulierung und erkläre den wichtigsten Fehler.'
};
return [
'Du bist ein didaktischer Sprachassistent innerhalb eines Sprachkurses.',
'Antworte auf Deutsch, aber verwende die Zielsprache der Lektion aktiv in Beispielen und Mini-Dialogen.',
modeDirectives[mode] || modeDirectives.practice,
'Halte Antworten kompakt, praxisnah und auf diese Lektion fokussiert.',
`Kurs: ${lesson?.course?.title || 'Unbekannter Kurs'}`,
`Lektion: ${lesson?.title || 'Unbekannte Lektion'}`,
lesson?.description ? `Beschreibung: ${lesson.description}` : '',
learningGoals.length ? `Lernziele: ${learningGoals.join(' | ')}` : '',
corePatterns.length ? `Kernmuster: ${corePatterns.join(' | ')}` : '',
speakingPrompts.length
? `Sprechaufträge: ${speakingPrompts.map((item) => item.prompt || item.title || '').filter(Boolean).join(' | ')}`
: '',
practicalTasks.length
? `Praxisaufgaben: ${practicalTasks.map((item) => item.text || item.title || '').filter(Boolean).join(' | ')}`
: '',
'Wenn der Nutzer eine Formulierung versucht, korrigiere sie präzise und gib eine verbesserte Version.'
].filter(Boolean).join('\n');
}
_extractAssistantContent(responseData) {
const rawContent = responseData?.choices?.[0]?.message?.content;
if (typeof rawContent === 'string') {
return rawContent.trim();
}
if (Array.isArray(rawContent)) {
return rawContent
.map((item) => {
if (typeof item === 'string') return item;
if (item?.type === 'text') return item.text || '';
return '';
})
.join('\n')
.trim();
}
return '';
}
_normalizeLexeme(text) {
return String(text || '')
.trim()
@@ -29,6 +142,126 @@ export default class VocabService {
.replace(/\s+/g, ' ');
}
_normalizeTextAnswer(text) {
return String(text || '')
.trim()
.toLowerCase()
.replace(/[.,!?;:¿¡"]/g, '')
.replace(/\s+/g, ' ');
}
_normalizeStringList(value) {
if (!value) return [];
if (Array.isArray(value)) {
return value
.map((entry) => String(entry || '').trim())
.filter(Boolean);
}
if (typeof value === 'string') {
return value
.split(/\r?\n|;/)
.map((entry) => entry.trim())
.filter(Boolean);
}
return [];
}
_normalizeStructuredList(value, keys = ['title', 'text']) {
if (!value) return [];
if (Array.isArray(value)) {
return value
.map((entry) => {
if (typeof entry === 'string') {
return { title: '', text: entry.trim() };
}
if (!entry || typeof entry !== 'object') return null;
const normalized = {};
keys.forEach((key) => {
if (entry[key] !== undefined && entry[key] !== null) {
normalized[key] = String(entry[key]).trim();
}
});
return Object.keys(normalized).length > 0 ? normalized : null;
})
.filter(Boolean);
}
return [];
}
_buildLessonDidactics(plainLesson) {
const grammarExercises = Array.isArray(plainLesson.grammarExercises) ? plainLesson.grammarExercises : [];
const grammarExplanations = [];
const patterns = [];
const speakingPrompts = [];
grammarExercises.forEach((exercise) => {
const questionData = typeof exercise.questionData === 'string'
? JSON.parse(exercise.questionData)
: (exercise.questionData || {});
if (exercise.explanation) {
grammarExplanations.push({
title: exercise.title || '',
text: exercise.explanation
});
}
const patternCandidates = [
questionData.pattern,
questionData.exampleSentence,
questionData.modelAnswer,
questionData.promptSentence
].filter(Boolean);
patternCandidates.forEach((candidate) => {
patterns.push(String(candidate).trim());
});
if (questionData.type === 'reading_aloud' || questionData.type === 'speaking_from_memory') {
speakingPrompts.push({
title: exercise.title || '',
prompt: questionData.question || questionData.text || '',
cue: questionData.expectedText || '',
keywords: Array.isArray(questionData.keywords) ? questionData.keywords : []
});
}
});
const uniqueGrammarExplanations = grammarExplanations.filter((item, index, list) => {
const signature = `${item.title}::${item.text}`;
return list.findIndex((entry) => `${entry.title}::${entry.text}` === signature) === index;
});
const uniquePatterns = [...new Set(patterns.map((item) => String(item || '').trim()).filter(Boolean))];
const learningGoals = this._normalizeStringList(plainLesson.learningGoals);
const corePatterns = this._normalizeStringList(plainLesson.corePatterns);
const grammarFocus = this._normalizeStructuredList(plainLesson.grammarFocus, ['title', 'text', 'example']);
const explicitSpeakingPrompts = this._normalizeStructuredList(plainLesson.speakingPrompts, ['title', 'prompt', 'cue']);
const practicalTasks = this._normalizeStructuredList(plainLesson.practicalTasks, ['title', 'text']);
return {
learningGoals: learningGoals.length > 0
? learningGoals
: [
'Die Schlüsselausdrücke der Lektion verstehen und wiedererkennen.',
'Ein bis zwei Satzmuster aktiv anwenden.',
'Kurze Sätze oder Mini-Dialoge zum Thema selbst bilden.'
],
corePatterns: corePatterns.length > 0 ? corePatterns : uniquePatterns.slice(0, 5),
grammarFocus: grammarFocus.length > 0 ? grammarFocus : uniqueGrammarExplanations.slice(0, 4),
speakingPrompts: explicitSpeakingPrompts.length > 0 ? explicitSpeakingPrompts : speakingPrompts.slice(0, 4),
practicalTasks: practicalTasks.length > 0
? practicalTasks
: [
{
title: 'Mini-Anwendung',
text: 'Formuliere zwei bis drei eigene Sätze oder einen kurzen Dialog mit dem Muster dieser Lektion.'
}
]
};
}
async _getLanguageAccess(userId, languageId) {
const id = Number.parseInt(languageId, 10);
if (!Number.isFinite(id)) {
@@ -895,18 +1128,111 @@ export default class VocabService {
plainLesson.reviewVocabExercises = plainLesson.previousLessonExercises || [];
}
console.log(`[getLesson] Lektion ${lessonId} geladen:`, {
id: plainLesson.id,
title: plainLesson.title,
lessonType: plainLesson.lessonType,
exerciseCount: plainLesson.grammarExercises ? plainLesson.grammarExercises.length : 0,
reviewLessonsCount: plainLesson.reviewLessons ? plainLesson.reviewLessons.length : 0,
reviewVocabExercisesCount: plainLesson.reviewVocabExercises ? plainLesson.reviewVocabExercises.length : 0,
previousLessonExercisesCount: plainLesson.previousLessonExercises ? plainLesson.previousLessonExercises.length : 0
});
plainLesson.didactics = this._buildLessonDidactics(plainLesson);
return plainLesson;
}
async sendLessonAssistantMessage(hashedUserId, lessonId, payload = {}) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await this.getLesson(hashedUserId, lessonId);
const config = await this._getUserLlmConfig(user.id);
if (!config.enabled) {
const err = new Error('Der Sprachassistent ist in deinen Einstellungen derzeit deaktiviert.');
err.status = 400;
throw err;
}
if (!config.configured) {
const err = new Error('Der Sprachassistent ist noch nicht eingerichtet. Bitte hinterlege zuerst Modell und API-Zugang in den Einstellungen.');
err.status = 400;
throw err;
}
const message = String(payload?.message || '').trim();
if (!message) {
const err = new Error('Bitte gib eine Nachricht für den Sprachassistenten ein.');
err.status = 400;
throw err;
}
const mode = ['explain', 'practice', 'correct'].includes(payload?.mode) ? payload.mode : 'practice';
const history = this._sanitizeAssistantHistory(payload?.history);
const baseUrl = config.baseUrl || 'https://api.openai.com/v1';
const endpoint = `${baseUrl.replace(/\/$/, '')}/chat/completions`;
const headers = {
'Content-Type': 'application/json'
};
if (config.apiKey) {
headers.Authorization = `Bearer ${config.apiKey}`;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30000);
let response;
try {
response = await fetch(endpoint, {
method: 'POST',
headers,
signal: controller.signal,
body: JSON.stringify({
model: config.model,
temperature: 0.7,
messages: [
{
role: 'system',
content: this._buildLessonAssistantSystemPrompt(lesson, mode)
},
...history,
{
role: 'user',
content: message
}
]
})
});
} catch (error) {
const err = new Error(
error?.name === 'AbortError'
? 'Der Sprachassistent hat zu lange für eine Antwort gebraucht.'
: 'Der Sprachassistent konnte nicht erreicht werden.'
);
err.status = 502;
throw err;
} finally {
clearTimeout(timeout);
}
let responseData = null;
try {
responseData = await response.json();
} catch {
responseData = null;
}
if (!response.ok) {
const messageFromApi = responseData?.error?.message || responseData?.message || 'Der Sprachassistent hat die Anfrage abgelehnt.';
const err = new Error(messageFromApi);
err.status = response.status || 502;
throw err;
}
const reply = this._extractAssistantContent(responseData);
if (!reply) {
const err = new Error('Der Sprachassistent hat keine verwertbare Antwort geliefert.');
err.status = 502;
throw err;
}
return {
reply,
model: responseData?.model || config.model,
mode
};
}
/**
* Sammelt alle Lektionen, die in einer Wiederholungslektion wiederholt werden sollen
*/
@@ -975,7 +1301,7 @@ export default class VocabService {
return exercises.map(e => e.get({ plain: true }));
}
async addLessonToCourse(hashedUserId, courseId, { chapterId, lessonNumber, title, description, weekNumber, dayNumber, lessonType, audioUrl, culturalNotes, targetMinutes, targetScorePercent, requiresReview }) {
async addLessonToCourse(hashedUserId, courseId, { chapterId, lessonNumber, title, description, weekNumber, dayNumber, lessonType, audioUrl, culturalNotes, learningGoals, corePatterns, grammarFocus, speakingPrompts, practicalTasks, targetMinutes, targetScorePercent, requiresReview }) {
const user = await this._getUserByHashedId(hashedUserId);
const course = await VocabCourse.findByPk(courseId);
@@ -1019,6 +1345,11 @@ export default class VocabService {
lessonType: lessonType || 'vocab',
audioUrl: audioUrl || null,
culturalNotes: culturalNotes || null,
learningGoals: this._normalizeStringList(learningGoals),
corePatterns: this._normalizeStringList(corePatterns),
grammarFocus: this._normalizeStructuredList(grammarFocus, ['title', 'text', 'example']),
speakingPrompts: this._normalizeStructuredList(speakingPrompts, ['title', 'prompt', 'cue']),
practicalTasks: this._normalizeStructuredList(practicalTasks, ['title', 'text']),
targetMinutes: targetMinutes ? Number(targetMinutes) : null,
targetScorePercent: targetScorePercent ? Number(targetScorePercent) : 80,
requiresReview: requiresReview !== undefined ? Boolean(requiresReview) : false
@@ -1027,7 +1358,7 @@ export default class VocabService {
return lesson.get({ plain: true });
}
async updateLesson(hashedUserId, lessonId, { title, description, lessonNumber, weekNumber, dayNumber, lessonType, audioUrl, culturalNotes, targetMinutes, targetScorePercent, requiresReview }) {
async updateLesson(hashedUserId, lessonId, { title, description, lessonNumber, weekNumber, dayNumber, lessonType, audioUrl, culturalNotes, learningGoals, corePatterns, grammarFocus, speakingPrompts, practicalTasks, targetMinutes, targetScorePercent, requiresReview }) {
const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [{ model: VocabCourse, as: 'course' }]
@@ -1054,6 +1385,11 @@ export default class VocabService {
if (lessonType !== undefined) updates.lessonType = lessonType;
if (audioUrl !== undefined) updates.audioUrl = audioUrl;
if (culturalNotes !== undefined) updates.culturalNotes = culturalNotes;
if (learningGoals !== undefined) updates.learningGoals = this._normalizeStringList(learningGoals);
if (corePatterns !== undefined) updates.corePatterns = this._normalizeStringList(corePatterns);
if (grammarFocus !== undefined) updates.grammarFocus = this._normalizeStructuredList(grammarFocus, ['title', 'text', 'example']);
if (speakingPrompts !== undefined) updates.speakingPrompts = this._normalizeStructuredList(speakingPrompts, ['title', 'prompt', 'cue']);
if (practicalTasks !== undefined) updates.practicalTasks = this._normalizeStructuredList(practicalTasks, ['title', 'text']);
if (targetMinutes !== undefined) updates.targetMinutes = targetMinutes ? Number(targetMinutes) : null;
if (targetScorePercent !== undefined) updates.targetScorePercent = Number(targetScorePercent);
if (requiresReview !== undefined) updates.requiresReview = Boolean(requiresReview);
@@ -1450,6 +1786,15 @@ export default class VocabService {
correctAnswer = questionData.expectedText || questionData.text || '';
alternatives = questionData.keywords || [];
}
else if (questionData.type === 'sentence_building' || questionData.type === 'dialog_completion' || questionData.type === 'situational_response' || questionData.type === 'pattern_drill') {
const rawCorrect = answerData.correct ?? answerData.correctAnswer ?? answerData.answers ?? answerData.modelAnswer;
if (Array.isArray(rawCorrect)) {
correctAnswer = rawCorrect.join(' / ');
} else {
correctAnswer = rawCorrect || questionData.modelAnswer || '';
}
alternatives = answerData.alternatives || questionData.keywords || [];
}
// Fallback: Versuche correct oder correctAnswer
else {
correctAnswer = Array.isArray(answerData.correct)
@@ -1531,10 +1876,9 @@ export default class VocabService {
// Für Reading Aloud: userAnswer ist der erkannte Text (String)
// Vergleiche mit dem erwarteten Text aus questionData.text
if (parsedQuestionData.type === 'reading_aloud' || parsedQuestionData.type === 'speaking_from_memory') {
const normalize = (str) => String(str || '').trim().toLowerCase().replace(/[.,!?;:]/g, '');
const expectedText = parsedQuestionData.text || parsedQuestionData.expectedText || '';
const normalizedExpected = normalize(expectedText);
const normalizedUser = normalize(userAnswer);
const normalizedExpected = this._normalizeTextAnswer(expectedText);
const normalizedUser = this._normalizeTextAnswer(userAnswer);
// Für reading_aloud: Exakter Vergleich oder Levenshtein-Distanz
if (parsedQuestionData.type === 'reading_aloud') {
@@ -1550,16 +1894,33 @@ export default class VocabService {
return normalizedUser === normalizedExpected;
}
// Prüfe ob alle Schlüsselwörter vorhanden sind
return keywords.every(keyword => normalizedUser.includes(normalize(keyword)));
return keywords.every(keyword => normalizedUser.includes(this._normalizeTextAnswer(keyword)));
}
}
if (parsedQuestionData.type === 'sentence_building' || parsedQuestionData.type === 'dialog_completion' || parsedQuestionData.type === 'situational_response' || parsedQuestionData.type === 'pattern_drill') {
const candidateAnswers = parsedAnswerData.correct ?? parsedAnswerData.correctAnswer ?? parsedAnswerData.answers ?? parsedAnswerData.modelAnswer ?? [];
const normalizedUser = this._normalizeTextAnswer(userAnswer);
const answers = Array.isArray(candidateAnswers) ? candidateAnswers : [candidateAnswers];
if (parsedQuestionData.type === 'situational_response') {
const keywords = parsedQuestionData.keywords || parsedAnswerData.keywords || [];
if (keywords.length > 0) {
return keywords.every((keyword) => normalizedUser.includes(this._normalizeTextAnswer(keyword)));
}
}
return answers
.map((answer) => this._normalizeTextAnswer(answer))
.filter(Boolean)
.some((answer) => answer === normalizedUser);
}
// Für andere Typen: einfacher String-Vergleich (kann später erweitert werden)
const normalize = (str) => String(str || '').trim().toLowerCase();
const correctAnswers = parsedAnswerData.correct || parsedAnswerData.correctAnswer || [];
const correctAnswersArray = Array.isArray(correctAnswers) ? correctAnswers : [correctAnswers];
const normalizedUserAnswer = normalize(userAnswer);
return correctAnswersArray.some(correct => normalize(correct) === normalizedUserAnswer);
const normalizedUserAnswer = this._normalizeTextAnswer(userAnswer);
return correctAnswersArray.some(correct => this._normalizeTextAnswer(correct) === normalizedUserAnswer);
}
async getGrammarExerciseProgress(hashedUserId, lessonId) {
@@ -1638,5 +1999,3 @@ export default class VocabService {
return { success: true };
}
}

View File

@@ -0,0 +1,5 @@
ALTER TABLE community.folder
ADD COLUMN IF NOT EXISTS is_adult_area BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE community.image
ADD COLUMN IF NOT EXISTS is_adult_content BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -0,0 +1,36 @@
ALTER TABLE community.image
ADD COLUMN IF NOT EXISTS is_moderated_hidden BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE community.erotic_video
ADD COLUMN IF NOT EXISTS is_moderated_hidden BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE IF NOT EXISTS community.erotic_content_report (
id SERIAL PRIMARY KEY,
reporter_id INTEGER NOT NULL REFERENCES community."user"(id) ON DELETE CASCADE,
target_type VARCHAR(20) NOT NULL,
target_id INTEGER NOT NULL,
reason VARCHAR(80) NOT NULL,
note TEXT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'open',
action_taken VARCHAR(40) NULL,
handled_by INTEGER NULL REFERENCES community."user"(id) ON DELETE SET NULL,
handled_at TIMESTAMP WITH TIME ZONE NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS erotic_content_report_status_idx
ON community.erotic_content_report (status, created_at DESC);
CREATE INDEX IF NOT EXISTS erotic_content_report_target_idx
ON community.erotic_content_report (target_type, target_id);
INSERT INTO type.user_param (description, datatype, settings_id, order_id, min_age)
SELECT 'adult_upload_blocked', 'bool', st.id, 999, 18
FROM type.settings st
WHERE st.name = 'account'
AND NOT EXISTS (
SELECT 1
FROM type.user_param upt
WHERE upt.description = 'adult_upload_blocked'
);

View File

@@ -0,0 +1,21 @@
-- Erotikbereich: Sichtbar ab 18, nutzbar erst nach Moderatorfreigabe
INSERT INTO type.user_param (description, datatype, settings_id, order_id, immutable, min_age, gender, unit)
SELECT 'adult_verification_status', 'string', s.id, 910, false, 18, NULL, NULL
FROM type.settings s
WHERE s.name = 'account'
AND NOT EXISTS (
SELECT 1
FROM type.user_param p
WHERE p.description = 'adult_verification_status'
);
INSERT INTO type.user_param (description, datatype, settings_id, order_id, immutable, min_age, gender, unit)
SELECT 'adult_verification_request', 'string', s.id, 911, false, 18, NULL, NULL
FROM type.settings s
WHERE s.name = 'account'
AND NOT EXISTS (
SELECT 1
FROM type.user_param p
WHERE p.description = 'adult_verification_request'
);

View File

@@ -0,0 +1,5 @@
ALTER TABLE falukant_data.user_house
ADD COLUMN IF NOT EXISTS household_tension_score integer NOT NULL DEFAULT 10;
ALTER TABLE falukant_data.user_house
ADD COLUMN IF NOT EXISTS household_tension_reasons_json jsonb NULL;

View File

@@ -0,0 +1,2 @@
ALTER TABLE chat.room
ADD COLUMN IF NOT EXISTS is_adult_only BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -0,0 +1,24 @@
-- Sprachassistent / LLM: Einstellungen über type.settings + type.user_param + community.user_param
-- (keine Spalten mehr an community.user).
--
-- Falls du vorher add_user_llm_columns.sql ausgeführt hast: Spalten an user wieder entfernen.
ALTER TABLE community."user" DROP COLUMN IF EXISTS llm_api_key_encrypted;
ALTER TABLE community."user" DROP COLUMN IF EXISTS llm_settings;
-- Gruppe „languageAssistant“
INSERT INTO type.settings (name)
SELECT 'languageAssistant'
WHERE NOT EXISTS (SELECT 1 FROM type.settings WHERE name = 'languageAssistant');
-- Param-Typen (description eindeutig)
INSERT INTO type.user_param (description, datatype, settings_id, order_id, immutable, min_age, gender, unit)
SELECT 'llm_settings', 'string', s.id, 900, false, NULL, NULL, NULL
FROM type.settings s
WHERE s.name = 'languageAssistant'
AND NOT EXISTS (SELECT 1 FROM type.user_param p WHERE p.description = 'llm_settings');
INSERT INTO type.user_param (description, datatype, settings_id, order_id, immutable, min_age, gender, unit)
SELECT 'llm_api_key', 'string', s.id, 901, false, NULL, NULL, NULL
FROM type.settings s
WHERE s.name = 'languageAssistant'
AND NOT EXISTS (SELECT 1 FROM type.user_param p WHERE p.description = 'llm_api_key');

View File

@@ -0,0 +1,88 @@
-- PostgreSQL-only migration script.
-- Dieses Projekt-Backend nutzt Schemas, JSONB und PostgreSQL-Datentypen.
-- Nicht auf MariaDB/MySQL ausführen.
BEGIN;
CREATE TABLE IF NOT EXISTS falukant_data.relationship_state (
id serial PRIMARY KEY,
relationship_id integer NOT NULL UNIQUE,
marriage_satisfaction integer NOT NULL DEFAULT 55 CHECK (marriage_satisfaction >= 0 AND marriage_satisfaction <= 100),
marriage_public_stability integer NOT NULL DEFAULT 55 CHECK (marriage_public_stability >= 0 AND marriage_public_stability <= 100),
lover_role text NULL CHECK (lover_role IN ('secret_affair', 'lover', 'mistress_or_favorite')),
affection integer NOT NULL DEFAULT 50 CHECK (affection >= 0 AND affection <= 100),
visibility integer NOT NULL DEFAULT 15 CHECK (visibility >= 0 AND visibility <= 100),
discretion integer NOT NULL DEFAULT 50 CHECK (discretion >= 0 AND discretion <= 100),
maintenance_level integer NOT NULL DEFAULT 50 CHECK (maintenance_level >= 0 AND maintenance_level <= 100),
status_fit integer NOT NULL DEFAULT 0 CHECK (status_fit >= -2 AND status_fit <= 2),
monthly_base_cost integer NOT NULL DEFAULT 0 CHECK (monthly_base_cost >= 0),
months_underfunded integer NOT NULL DEFAULT 0 CHECK (months_underfunded >= 0),
active boolean NOT NULL DEFAULT true,
acknowledged boolean NOT NULL DEFAULT false,
exclusive_flag boolean NOT NULL DEFAULT false,
last_monthly_processed_at timestamp with time zone NULL,
last_daily_processed_at timestamp with time zone NULL,
notes_json jsonb NULL,
flags_json jsonb NULL,
created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT relationship_state_relationship_fk
FOREIGN KEY (relationship_id)
REFERENCES falukant_data.relationship(id)
ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS relationship_state_active_idx
ON falukant_data.relationship_state (active);
CREATE INDEX IF NOT EXISTS relationship_state_lover_role_idx
ON falukant_data.relationship_state (lover_role);
ALTER TABLE IF EXISTS falukant_data.child_relation
ADD COLUMN IF NOT EXISTS legitimacy text NOT NULL DEFAULT 'legitimate';
ALTER TABLE IF EXISTS falukant_data.child_relation
ADD COLUMN IF NOT EXISTS birth_context text NOT NULL DEFAULT 'marriage';
ALTER TABLE IF EXISTS falukant_data.child_relation
ADD COLUMN IF NOT EXISTS public_known boolean NOT NULL DEFAULT false;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'child_relation_legitimacy_chk'
) THEN
ALTER TABLE falukant_data.child_relation
ADD CONSTRAINT child_relation_legitimacy_chk
CHECK (legitimacy IN ('legitimate', 'acknowledged_bastard', 'hidden_bastard'));
END IF;
END
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'child_relation_birth_context_chk'
) THEN
ALTER TABLE falukant_data.child_relation
ADD CONSTRAINT child_relation_birth_context_chk
CHECK (birth_context IN ('marriage', 'lover'));
END IF;
END
$$;
COMMIT;
-- Rollback separat bei Bedarf:
-- BEGIN;
-- ALTER TABLE falukant_data.child_relation DROP CONSTRAINT IF EXISTS child_relation_birth_context_chk;
-- ALTER TABLE falukant_data.child_relation DROP CONSTRAINT IF EXISTS child_relation_legitimacy_chk;
-- ALTER TABLE falukant_data.child_relation DROP COLUMN IF EXISTS public_known;
-- ALTER TABLE falukant_data.child_relation DROP COLUMN IF EXISTS birth_context;
-- ALTER TABLE falukant_data.child_relation DROP COLUMN IF EXISTS legitimacy;
-- DROP TABLE IF EXISTS falukant_data.relationship_state;
-- COMMIT;

View File

@@ -0,0 +1,7 @@
-- PostgreSQL only
ALTER TABLE falukant_data.user_house
ADD COLUMN IF NOT EXISTS servant_count integer NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS servant_quality integer NOT NULL DEFAULT 50,
ADD COLUMN IF NOT EXISTS servant_pay_level varchar(20) NOT NULL DEFAULT 'normal',
ADD COLUMN IF NOT EXISTS household_order integer NOT NULL DEFAULT 55;

View File

@@ -0,0 +1,7 @@
-- PostgreSQL only
ALTER TABLE falukant_data.transport
ADD COLUMN IF NOT EXISTS guard_count integer NOT NULL DEFAULT 0;
ALTER TABLE falukant_data.underground
ALTER COLUMN victim_id DROP NOT NULL;

View File

@@ -0,0 +1,5 @@
-- PostgreSQL-only
INSERT INTO falukant_type.underground (tr, cost)
VALUES ('investigate_affair', 7000)
ON CONFLICT (tr) DO UPDATE
SET cost = EXCLUDED.cost;

View File

@@ -0,0 +1,5 @@
-- PostgreSQL only
INSERT INTO falukant_type.underground (tr, cost)
VALUES ('raid_transport', 9000)
ON CONFLICT (tr) DO UPDATE
SET cost = EXCLUDED.cost;

View File

@@ -0,0 +1,14 @@
ALTER TABLE community.vocab_course_lesson
ADD COLUMN IF NOT EXISTS learning_goals JSONB,
ADD COLUMN IF NOT EXISTS core_patterns JSONB,
ADD COLUMN IF NOT EXISTS grammar_focus JSONB,
ADD COLUMN IF NOT EXISTS speaking_prompts JSONB,
ADD COLUMN IF NOT EXISTS practical_tasks JSONB;
INSERT INTO community.vocab_grammar_exercise_type (name, description, created_at) VALUES
('dialog_completion', 'Dialogergänzung', NOW()),
('situational_response', 'Situative Antwort', NOW()),
('pattern_drill', 'Muster-Drill', NOW()),
('reading_aloud', 'Lautlese-Übung', NOW()),
('speaking_from_memory', 'Freies Sprechen', NOW())
ON CONFLICT (name) DO NOTHING;

View File

@@ -0,0 +1,50 @@
-- PostgreSQL-only backfill script.
-- Dieses Projekt-Backend nutzt Schemas und PostgreSQL-spezifische SQL-Strukturen.
-- Nicht auf MariaDB/MySQL ausführen.
BEGIN;
INSERT INTO falukant_data.relationship_state (
relationship_id,
marriage_satisfaction,
marriage_public_stability,
lover_role,
affection,
visibility,
discretion,
maintenance_level,
status_fit,
monthly_base_cost,
months_underfunded,
active,
acknowledged,
exclusive_flag,
created_at,
updated_at
)
SELECT
r.id,
55,
55,
CASE WHEN rt.tr = 'lover' THEN 'lover' ELSE NULL END,
50,
CASE WHEN rt.tr = 'lover' THEN 20 ELSE 15 END,
CASE WHEN rt.tr = 'lover' THEN 45 ELSE 50 END,
50,
0,
CASE WHEN rt.tr = 'lover' THEN 30 ELSE 0 END,
0,
true,
false,
false,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM falukant_data.relationship r
INNER JOIN falukant_type.relationship rt
ON rt.id = r.relationship_type_id
LEFT JOIN falukant_data.relationship_state rs
ON rs.relationship_id = r.id
WHERE rs.id IS NULL
AND rt.tr IN ('lover', 'wooing', 'engaged', 'married');
COMMIT;

View File

@@ -0,0 +1,20 @@
-- Karotte: Tempo und Preis wie andere Kat.-1-Waren (sell_cost 11).
-- Invariante (siehe backend/utils/falukant/falukantProductEconomy.js): bei Zertifikat=Kategorie und
-- 100 % Wissen muss sell_cost mindestens ceil(Stückkosten * 100 / 75) sein (Kat. 1 → min. 10).
-- Nach manuell zu niedrigem sell_cost (z. B. Erlös ~3) ausführen.
BEGIN;
UPDATE falukant_type.product
SET production_time = 2
WHERE label_tr = 'carrot';
-- Basispreis angleichen (ohne Steuer-Aufschreibung; ggf. danach update_product_sell_costs.sql)
UPDATE falukant_type.product
SET sell_cost = 11
WHERE label_tr = 'carrot';
COMMIT;
-- Optional: Spalte original_sell_cost mitpflegen, falls ihr die MAX-STRATEGY aus update_product_sell_costs.sql nutzt
-- UPDATE falukant_type.product SET original_sell_cost = 11 WHERE label_tr = 'carrot';

View File

@@ -44,6 +44,11 @@ CREATE TABLE IF NOT EXISTS community.vocab_course_lesson (
lesson_type TEXT DEFAULT 'vocab',
audio_url TEXT,
cultural_notes TEXT,
learning_goals JSONB,
core_patterns JSONB,
grammar_focus JSONB,
speaking_prompts JSONB,
practical_tasks JSONB,
target_minutes INTEGER,
target_score_percent INTEGER DEFAULT 80,
requires_review BOOLEAN DEFAULT false,
@@ -213,13 +218,18 @@ CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_progress_exercise_idx
-- ============================================
-- Standard-Übungstypen für Grammatik
INSERT INTO community.vocab_grammar_exercise_type (name, description) VALUES
('gap_fill', 'Lückentext-Übung'),
('multiple_choice', 'Multiple-Choice-Fragen'),
('sentence_building', 'Satzbau-Übung'),
('transformation', 'Satzumformung'),
('conjugation', 'Konjugations-Übung'),
('declension', 'Deklinations-Übung')
INSERT INTO community.vocab_grammar_exercise_type (name, description, created_at) VALUES
('gap_fill', 'Lückentext-Übung', NOW()),
('multiple_choice', 'Multiple-Choice-Fragen', NOW()),
('sentence_building', 'Satzbau-Übung', NOW()),
('transformation', 'Satzumformung', NOW()),
('conjugation', 'Konjugations-Übung', NOW()),
('declension', 'Deklinations-Übung', NOW()),
('dialog_completion', 'Dialogergänzung', NOW()),
('situational_response', 'Situative Antwort', NOW()),
('pattern_drill', 'Muster-Drill', NOW()),
('reading_aloud', 'Lautlese-Übung', NOW()),
('speaking_from_memory', 'Freies Sprechen', NOW())
ON CONFLICT (name) DO NOTHING;
-- ============================================
@@ -230,6 +240,16 @@ COMMENT ON COLUMN community.vocab_course_lesson.lesson_type IS
'Type: vocab, grammar, conversation, culture, review';
COMMENT ON COLUMN community.vocab_course_lesson.target_minutes IS
'Zielzeit in Minuten für diese Lektion';
COMMENT ON COLUMN community.vocab_course_lesson.learning_goals IS
'Lernziele der Lektion als JSON-Array';
COMMENT ON COLUMN community.vocab_course_lesson.core_patterns IS
'Kernmuster und Beispielsätze als JSON-Array';
COMMENT ON COLUMN community.vocab_course_lesson.grammar_focus IS
'Grammatik-Impulse als JSON-Array von Objekten';
COMMENT ON COLUMN community.vocab_course_lesson.speaking_prompts IS
'Sprechaufträge als JSON-Array von Objekten';
COMMENT ON COLUMN community.vocab_course_lesson.practical_tasks IS
'Praxisaufgaben als JSON-Array von Objekten';
COMMENT ON COLUMN community.vocab_course_lesson.target_score_percent IS
'Mindestpunktzahl in Prozent zum Abschluss (z.B. 80)';
COMMENT ON COLUMN community.vocab_course_lesson.requires_review IS

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS community.erotic_video (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT NULL,
original_file_name VARCHAR(255) NOT NULL,
hash VARCHAR(255) NOT NULL UNIQUE,
mime_type VARCHAR(255) NOT NULL,
user_id INTEGER NOT NULL REFERENCES community."user"(id) ON UPDATE CASCADE ON DELETE CASCADE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -0,0 +1,31 @@
-- Falukant: Geldbewegungen aus falukant_log.moneyflow (jede Zeile = ein Aufruf von
-- falukant_data.update_money → activity-String wie im Backend übergeben).
--
-- Typische activity-Werte (siehe falukantService.js → updateFalukantUserMoney):
-- Wirtschaft: Production cost; Product sale (net); Sell all products (net);
-- Steueranteil Region (Verkauf); Sales tax (…); Buy/Sell storage (type: …)
-- Transport/Fahrzeuge: transport; build_vehicles; buy_vehicles; repair_vehicle; repair_all_vehicles
-- Filiale: create_branch
-- Haus: housebuy; servants_hired; household_order; renovation_*; renovation_all
-- Soziales: marriage_gift; Marriage cost; Gift cost; partyOrder; Baptism; Reputation action: …
-- Bildung: learnAll; learnItem:<productId>
-- Sonst: new nobility title; health.<aktivität>; credit taken (Kredit = positiver change_value)
--
-- Platzhalter (ersetzt durch scripts/falukant-moneyflow-report.mjs):
-- __DIAG_DAYS__ → positive Ganzzahl (Tage zurück)
-- __DIAG_USER_FILTER__ → leer ODER " AND m.falukant_user_id = <id>"
--
-- Manuell in psql: __DIAG_DAYS__ durch z. B. 30 ersetzen, __DIAG_USER_FILTER__ leer lassen.
SELECT
m.activity,
COUNT(*)::bigint AS n,
ROUND(SUM(m.change_value)::numeric, 2) AS sum_change,
ROUND(SUM(CASE WHEN m.change_value < 0 THEN -m.change_value ELSE 0 END)::numeric, 2) AS total_outflow,
ROUND(SUM(CASE WHEN m.change_value > 0 THEN m.change_value ELSE 0 END)::numeric, 2) AS total_inflow,
ROUND(AVG(m.change_value)::numeric, 4) AS avg_change
FROM falukant_log.moneyflow m
WHERE m."time" >= NOW() - (INTERVAL '1 day' * __DIAG_DAYS__)
__DIAG_USER_FILTER__
GROUP BY m.activity
ORDER BY sum_change ASC NULLS LAST;

View File

@@ -0,0 +1,14 @@
-- Kurzüberblick: Zeilen, Nutzer, Summen Ein-/Ausgang im gleichen Fenster wie
-- falukant_moneyflow_by_activity.sql (Platzhalter identisch).
SELECT
COUNT(*)::bigint AS row_count,
COUNT(DISTINCT m.falukant_user_id)::bigint AS distinct_falukant_users,
ROUND(SUM(m.change_value)::numeric, 2) AS net_sum_all,
ROUND(SUM(CASE WHEN m.change_value < 0 THEN m.change_value ELSE 0 END)::numeric, 2) AS sum_negative_only,
ROUND(SUM(CASE WHEN m.change_value > 0 THEN m.change_value ELSE 0 END)::numeric, 2) AS sum_positive_only,
MIN(m."time") AS first_ts,
MAX(m."time") AS last_ts
FROM falukant_log.moneyflow m
WHERE m."time" >= NOW() - (INTERVAL '1 day' * __DIAG_DAYS__)
__DIAG_USER_FILTER__;

View File

@@ -0,0 +1,16 @@
-- Übersicht: regionaler Warenwert je Produkt (Nachfrage / „worth“).
-- Niedrige Mittelwerte erklären schwache Verkaufspreise; siehe auch falukant_data.town_product_worth Hooks im Backend-Model.
SELECT
p.id,
p.label_tr,
p.category,
p.sell_cost::numeric AS sell_cost,
ROUND(AVG(tpw.worth_percent)::numeric, 2) AS avg_worth_pct,
ROUND(MIN(tpw.worth_percent)::numeric, 2) AS min_worth_pct,
ROUND(MAX(tpw.worth_percent)::numeric, 2) AS max_worth_pct,
COUNT(tpw.region_id) AS region_rows
FROM falukant_type.product p
LEFT JOIN falukant_data.town_product_worth tpw ON tpw.product_id = p.id
GROUP BY p.id, p.label_tr, p.category, p.sell_cost
ORDER BY p.category, p.label_tr;

View File

@@ -0,0 +1,32 @@
ALTER TABLE falukant_data.debtors_prism
ADD COLUMN IF NOT EXISTS status varchar(255) NOT NULL DEFAULT 'delinquent';
ALTER TABLE falukant_data.debtors_prism
ADD COLUMN IF NOT EXISTS entered_at timestamp with time zone NULL;
ALTER TABLE falukant_data.debtors_prism
ADD COLUMN IF NOT EXISTS released_at timestamp with time zone NULL;
ALTER TABLE falukant_data.debtors_prism
ADD COLUMN IF NOT EXISTS debt_at_entry numeric(14,2) NULL;
ALTER TABLE falukant_data.debtors_prism
ADD COLUMN IF NOT EXISTS remaining_debt numeric(14,2) NULL;
ALTER TABLE falukant_data.debtors_prism
ADD COLUMN IF NOT EXISTS days_overdue integer NOT NULL DEFAULT 0;
ALTER TABLE falukant_data.debtors_prism
ADD COLUMN IF NOT EXISTS reason varchar(255) NULL;
ALTER TABLE falukant_data.debtors_prism
ADD COLUMN IF NOT EXISTS creditworthiness_penalty integer NOT NULL DEFAULT 0;
ALTER TABLE falukant_data.debtors_prism
ADD COLUMN IF NOT EXISTS next_forced_action varchar(255) NULL;
ALTER TABLE falukant_data.debtors_prism
ADD COLUMN IF NOT EXISTS assets_seized_json jsonb NULL;
ALTER TABLE falukant_data.debtors_prism
ADD COLUMN IF NOT EXISTS public_known boolean NOT NULL DEFAULT false;

View File

@@ -0,0 +1,2 @@
ALTER TABLE community.user_param
ALTER COLUMN value TYPE TEXT;

View File

@@ -0,0 +1,51 @@
BEGIN;
UPDATE falukant_type.product
SET sell_cost = CASE label_tr
WHEN 'wheat' THEN 11
WHEN 'grain' THEN 11
WHEN 'carrot' THEN 11
WHEN 'fish' THEN 11
WHEN 'meat' THEN 11
WHEN 'leather' THEN 11
WHEN 'wood' THEN 11
WHEN 'stone' THEN 11
WHEN 'milk' THEN 11
WHEN 'cheese' THEN 11
WHEN 'bread' THEN 11
WHEN 'beer' THEN 22
WHEN 'iron' THEN 24
WHEN 'copper' THEN 24
WHEN 'spices' THEN 42
WHEN 'salt' THEN 24
WHEN 'sugar' THEN 24
WHEN 'vinegar' THEN 24
WHEN 'cotton' THEN 24
WHEN 'wine' THEN 24
WHEN 'gold' THEN 40
WHEN 'diamond' THEN 40
WHEN 'furniture' THEN 40
WHEN 'clothing' THEN 40
WHEN 'jewelry' THEN 58
WHEN 'painting' THEN 58
WHEN 'book' THEN 58
WHEN 'weapon' THEN 58
WHEN 'armor' THEN 58
WHEN 'shield' THEN 58
WHEN 'horse' THEN 78
WHEN 'ox' THEN 78
ELSE sell_cost
END,
production_time = CASE label_tr
WHEN 'carrot' THEN 2
ELSE production_time
END
WHERE label_tr IN (
'wheat', 'grain', 'carrot', 'fish', 'meat', 'leather', 'wood', 'stone',
'milk', 'cheese', 'bread', 'beer', 'iron', 'copper', 'spices', 'salt',
'sugar', 'vinegar', 'cotton', 'wine', 'gold', 'diamond', 'furniture',
'clothing', 'jewelry', 'painting', 'book', 'weapon', 'armor', 'shield',
'horse', 'ox'
);
COMMIT;

View File

@@ -19,6 +19,11 @@ ADD COLUMN IF NOT EXISTS day_number INTEGER,
ADD COLUMN IF NOT EXISTS lesson_type TEXT DEFAULT 'vocab',
ADD COLUMN IF NOT EXISTS audio_url TEXT,
ADD COLUMN IF NOT EXISTS cultural_notes TEXT,
ADD COLUMN IF NOT EXISTS learning_goals JSONB,
ADD COLUMN IF NOT EXISTS core_patterns JSONB,
ADD COLUMN IF NOT EXISTS grammar_focus JSONB,
ADD COLUMN IF NOT EXISTS speaking_prompts JSONB,
ADD COLUMN IF NOT EXISTS practical_tasks JSONB,
ADD COLUMN IF NOT EXISTS target_minutes INTEGER,
ADD COLUMN IF NOT EXISTS target_score_percent INTEGER DEFAULT 80,
ADD COLUMN IF NOT EXISTS requires_review BOOLEAN DEFAULT false;
@@ -105,13 +110,18 @@ CREATE INDEX IF NOT EXISTS vocab_grammar_exercise_progress_exercise_idx
-- ============================================
-- 5. Standard-Daten einfügen
-- ============================================
INSERT INTO community.vocab_grammar_exercise_type (name, description) VALUES
('gap_fill', 'Lückentext-Übung'),
('multiple_choice', 'Multiple-Choice-Fragen'),
('sentence_building', 'Satzbau-Übung'),
('transformation', 'Satzumformung'),
('conjugation', 'Konjugations-Übung'),
('declension', 'Deklinations-Übung')
INSERT INTO community.vocab_grammar_exercise_type (name, description, created_at) VALUES
('gap_fill', 'Lückentext-Übung', NOW()),
('multiple_choice', 'Multiple-Choice-Fragen', NOW()),
('sentence_building', 'Satzbau-Übung', NOW()),
('transformation', 'Satzumformung', NOW()),
('conjugation', 'Konjugations-Übung', NOW()),
('declension', 'Deklinations-Übung', NOW()),
('dialog_completion', 'Dialogergänzung', NOW()),
('situational_response', 'Situative Antwort', NOW()),
('pattern_drill', 'Muster-Drill', NOW()),
('reading_aloud', 'Lautlese-Übung', NOW()),
('speaking_from_memory', 'Freies Sprechen', NOW())
ON CONFLICT (name) DO NOTHING;
-- ============================================
@@ -121,6 +131,16 @@ COMMENT ON COLUMN community.vocab_course_lesson.lesson_type IS
'Type: vocab, grammar, conversation, culture, review';
COMMENT ON COLUMN community.vocab_course_lesson.target_minutes IS
'Zielzeit in Minuten für diese Lektion';
COMMENT ON COLUMN community.vocab_course_lesson.learning_goals IS
'Lernziele der Lektion als JSON-Array';
COMMENT ON COLUMN community.vocab_course_lesson.core_patterns IS
'Kernmuster und Beispielsätze als JSON-Array';
COMMENT ON COLUMN community.vocab_course_lesson.grammar_focus IS
'Grammatik-Impulse als JSON-Array von Objekten';
COMMENT ON COLUMN community.vocab_course_lesson.speaking_prompts IS
'Sprechaufträge als JSON-Array von Objekten';
COMMENT ON COLUMN community.vocab_course_lesson.practical_tasks IS
'Praxisaufgaben als JSON-Array von Objekten';
COMMENT ON COLUMN community.vocab_course_lesson.target_score_percent IS
'Mindestpunktzahl in Prozent zum Abschluss (z.B. 80)';
COMMENT ON COLUMN community.vocab_course_lesson.requires_review IS

View File

@@ -0,0 +1,132 @@
-- PostgreSQL only
-- Ersetzt die Standesanforderungen fuer Falukant durch das erweiterte Profilmodell.
DELETE FROM falukant_type.title_requirement
WHERE title_id IN (
SELECT id
FROM falukant_type.title
WHERE label_tr IN (
'civil', 'sir', 'townlord', 'by', 'landlord', 'knight',
'baron', 'count', 'palsgrave', 'margrave', 'landgrave',
'ruler', 'elector', 'imperial-prince', 'duke', 'grand-duke',
'prince-regent', 'king'
)
);
INSERT INTO falukant_type.title_requirement (title_id, requirement_type, requirement_value)
SELECT tm.id, req.requirement_type, req.requirement_value
FROM (
SELECT id, label_tr
FROM falukant_type.title
WHERE label_tr IN (
'civil', 'sir', 'townlord', 'by', 'landlord', 'knight',
'baron', 'count', 'palsgrave', 'margrave', 'landgrave',
'ruler', 'elector', 'imperial-prince', 'duke', 'grand-duke',
'prince-regent', 'king'
)
) tm
JOIN (
VALUES
('civil', 'money', 5000::numeric),
('civil', 'cost', 500::numeric),
('civil', 'house_position', 2::numeric),
('sir', 'branches', 2::numeric),
('sir', 'cost', 1000::numeric),
('sir', 'house_position', 3::numeric),
('townlord', 'cost', 3000::numeric),
('townlord', 'money', 12000::numeric),
('townlord', 'reputation', 18::numeric),
('townlord', 'house_position', 4::numeric),
('by', 'cost', 5000::numeric),
('by', 'money', 18000::numeric),
('by', 'house_position', 4::numeric),
('landlord', 'cost', 7500::numeric),
('landlord', 'money', 26000::numeric),
('landlord', 'reputation', 24::numeric),
('landlord', 'house_position', 4::numeric),
('landlord', 'house_condition', 60::numeric),
('knight', 'cost', 11000::numeric),
('knight', 'money', 38000::numeric),
('knight', 'office_rank_any', 1::numeric),
('knight', 'house_position', 5::numeric),
('baron', 'branches', 4::numeric),
('baron', 'cost', 16000::numeric),
('baron', 'money', 55000::numeric),
('baron', 'house_position', 5::numeric),
('count', 'cost', 23000::numeric),
('count', 'money', 80000::numeric),
('count', 'reputation', 32::numeric),
('count', 'house_position', 5::numeric),
('count', 'house_condition', 68::numeric),
('palsgrave', 'cost', 32000::numeric),
('palsgrave', 'money', 115000::numeric),
('palsgrave', 'office_rank_any', 2::numeric),
('palsgrave', 'house_position', 6::numeric),
('margrave', 'cost', 45000::numeric),
('margrave', 'money', 165000::numeric),
('margrave', 'reputation', 40::numeric),
('margrave', 'house_position', 6::numeric),
('margrave', 'house_condition', 72::numeric),
('margrave', 'lover_count_min', 1::numeric),
('landgrave', 'cost', 62000::numeric),
('landgrave', 'money', 230000::numeric),
('landgrave', 'office_rank_any', 3::numeric),
('landgrave', 'house_position', 6::numeric),
('ruler', 'cost', 85000::numeric),
('ruler', 'money', 320000::numeric),
('ruler', 'reputation', 48::numeric),
('ruler', 'house_position', 7::numeric),
('ruler', 'house_condition', 76::numeric),
('elector', 'cost', 115000::numeric),
('elector', 'money', 440000::numeric),
('elector', 'office_rank_political', 4::numeric),
('elector', 'house_position', 7::numeric),
('elector', 'lover_count_max', 3::numeric),
('imperial-prince', 'cost', 155000::numeric),
('imperial-prince', 'money', 600000::numeric),
('imperial-prince', 'reputation', 56::numeric),
('imperial-prince', 'house_position', 7::numeric),
('imperial-prince', 'house_condition', 80::numeric),
('duke', 'cost', 205000::numeric),
('duke', 'money', 820000::numeric),
('duke', 'office_rank_any', 5::numeric),
('duke', 'house_position', 8::numeric),
('grand-duke', 'cost', 270000::numeric),
('grand-duke', 'money', 1120000::numeric),
('grand-duke', 'reputation', 64::numeric),
('grand-duke', 'house_position', 8::numeric),
('grand-duke', 'house_condition', 84::numeric),
('grand-duke', 'lover_count_min', 1::numeric),
('grand-duke', 'lover_count_max', 3::numeric),
('prince-regent', 'cost', 360000::numeric),
('prince-regent', 'money', 1520000::numeric),
('prince-regent', 'office_rank_any', 6::numeric),
('prince-regent', 'house_position', 9::numeric),
('king', 'cost', 500000::numeric),
('king', 'money', 2100000::numeric),
('king', 'reputation', 72::numeric),
('king', 'house_position', 9::numeric),
('king', 'house_condition', 88::numeric),
('king', 'lover_count_min', 1::numeric),
('king', 'lover_count_max', 4::numeric)
) AS req(label_tr, requirement_type, requirement_value)
ON req.label_tr = tm.label_tr
ON CONFLICT (title_id, requirement_type)
DO UPDATE SET requirement_value = EXCLUDED.requirement_value;

View File

@@ -14,19 +14,32 @@ export const generateIv = () => {
export const encrypt = (text) => {
const cipher = crypto.createCipheriv(algorithm, key, null);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
let encrypted = cipher.update(text, 'utf8', 'base64');
encrypted += cipher.final('base64');
return encrypted;
};
export const decrypt = (text) => {
try {
const decipher = crypto.createDecipheriv(algorithm, key, null);
let decrypted = decipher.update(text, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
console.log(error);
if (!text) {
return null;
}
const input = String(text);
try {
const decipher = crypto.createDecipheriv(algorithm, key, null);
let decrypted = decipher.update(input, 'base64', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (base64Error) {
try {
// Rueckwaertskompatibel fuer bereits gespeicherte Hex-Werte.
const decipher = crypto.createDecipheriv(algorithm, key, null);
let decrypted = decipher.update(input, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (hexError) {
console.log(hexError);
return null;
}
}
};

View File

@@ -0,0 +1,96 @@
/**
* Zentrale Produktions- und Preisformeln (muss mit der Spielwirtschaft konsistent bleiben).
* Wird von falukantService und der Produkt-Initialisierung genutzt.
*
* Mindest-Erlös (Ertrags-Tabelle, Branch): bei 100 % Produktwissen ist der Verkaufspreis
* das obere Ende der Spanne = basePrice = sellCost * (effectiveWorth/100), mit
* effectiveWorth >= 75 (siehe effectiveWorthPercent in falukantService).
* Engster Fall für Gewinn/Stück: Zertifikat = Produktkategorie (kein Headroom-Rabatt auf
* Stückkosten) und regionale Nachfrage am Boden (75 %).
*/
export const PRODUCTION_COST_BASE = 6.0;
export const PRODUCTION_COST_PER_PRODUCT_CATEGORY = 1.0;
export const PRODUCTION_HEADROOM_DISCOUNT_PER_STEP = 0.035;
export const PRODUCTION_HEADROOM_DISCOUNT_CAP = 0.14;
export function productionPieceCost(certificate, category) {
const c = Math.max(1, Number(category) || 1);
const cert = Math.max(1, Number(certificate) || 1);
const raw = PRODUCTION_COST_BASE + (c * PRODUCTION_COST_PER_PRODUCT_CATEGORY);
const headroom = Math.max(0, cert - c);
const discount = Math.min(
headroom * PRODUCTION_HEADROOM_DISCOUNT_PER_STEP,
PRODUCTION_HEADROOM_DISCOUNT_CAP
);
return raw * (1 - discount);
}
export function productionCostTotal(quantity, category, certificate) {
const q = Math.min(100, Math.max(1, Number(quantity) || 1));
return q * productionPieceCost(certificate, category);
}
export function effectiveWorthPercent(worthPercent) {
const w = Number(worthPercent);
if (Number.isNaN(w)) return 75;
return Math.min(100, Math.max(75, w));
}
/** Untergrenze für den Wissens-Multiplikator auf den regionalen Basispreis. */
export const KNOWLEDGE_PRICE_FLOOR = 0.7;
export function calcSellPrice(product, knowledgeFactor = 0) {
const max = product.sellCost;
const min = max * KNOWLEDGE_PRICE_FLOOR;
return min + (max - min) * (knowledgeFactor / 100);
}
export function calcRegionalSellPriceSync(product, knowledgeFactor, worthPercent) {
if (product.sellCost === null || product.sellCost === undefined) return null;
const w = effectiveWorthPercent(worthPercent);
const basePrice = product.sellCost * (w / 100);
const min = basePrice * KNOWLEDGE_PRICE_FLOOR;
const max = basePrice;
return min + (max - min) * (knowledgeFactor / 100);
}
/** Untergrenze für worthPercent nach effectiveWorthPercent (75100). */
export const EFFECTIVE_WORTH_PERCENT_MIN = 75;
/**
* Minimaler ganzzahliger Basis-sell_cost (vor Steuer-/Regions-Faktoren in der DB),
* sodass bei Zertifikat = Produktkategorie, 100 % Wissen und 75 % Nachfrage
* der Erlös pro Stück >= Stückkosten (kein struktureller Verlust in der Ertrags-Tabelle).
*/
export function minBaseSellCostForTightProduction(category) {
const c = Math.max(1, Number(category) || 1);
const cost = productionPieceCost(c, c);
return Math.ceil((cost * 100) / EFFECTIVE_WORTH_PERCENT_MIN);
}
/**
* Prüft Vordefinierungen; meldet Abweichungen nur per warn (kein Throw), damit Deployments
* mit alter DB nicht brechen — Balance-Anpassung erfolgt bewusst im Code/SQL.
*/
export function validateProductBaseSellCosts(products, { warn = console.warn } = {}) {
const issues = [];
for (const p of products) {
const min = minBaseSellCostForTightProduction(p.category);
if (Number(p.sellCost) < min) {
issues.push({
labelTr: p.labelTr,
category: p.category,
sellCost: p.sellCost,
minRequired: min,
});
}
}
if (issues.length && typeof warn === 'function') {
warn(
'[falukantProductEconomy] sell_cost unter Mindestbedarf (Zertifikat=Kategorie, 100% Wissen, 75% Nachfrage):',
issues
);
}
return issues;
}

View File

@@ -6,6 +6,7 @@ import FalukantStockType from "../../models/falukant/type/stock.js";
import TitleOfNobility from "../../models/falukant/type/title_of_nobility.js";
import TitleRequirement from "../../models/falukant/type/title_requirement.js";
import { sequelize } from "../sequelize.js";
import { validateProductBaseSellCosts } from "./falukantProductEconomy.js";
export const initializeFalukantPredefines = async () => {
await initializeFalukantFirstnames();
@@ -248,40 +249,42 @@ async function initializeFalukantProducts() {
const factorMax = (maxTax >= 100) ? 1 : (1 / (1 - maxTax / 100));
const baseProducts = [
{ labelTr: 'wheat', category: 1, productionTime: 2, sellCost: 7 },
{ labelTr: 'grain', category: 1, productionTime: 2, sellCost: 7 },
{ labelTr: 'carrot', category: 1, productionTime: 1, sellCost: 5},
{ labelTr: 'fish', category: 1, productionTime: 2, sellCost: 7 },
{ labelTr: 'meat', category: 1, productionTime: 2, sellCost: 7 },
{ labelTr: 'leather', category: 1, productionTime: 2, sellCost: 7 },
{ labelTr: 'wood', category: 1, productionTime: 2, sellCost: 7 },
{ labelTr: 'stone', category: 1, productionTime: 2, sellCost: 7 },
{ labelTr: 'milk', category: 1, productionTime: 1, sellCost: 6 },
{ labelTr: 'cheese', category: 1, productionTime: 1, sellCost: 6 },
{ labelTr: 'bread', category: 1, productionTime: 1, sellCost: 6 },
{ labelTr: 'beer', category: 2, productionTime: 3, sellCost: 6 },
{ labelTr: 'iron', category: 2, productionTime: 4, sellCost: 15 },
{ labelTr: 'copper', category: 2, productionTime: 4, sellCost: 15 },
{ labelTr: 'spices', category: 2, productionTime: 8, sellCost: 30 },
{ labelTr: 'salt', category: 2, productionTime: 4, sellCost: 15 },
{ labelTr: 'sugar', category: 2, productionTime: 4, sellCost: 15 },
{ labelTr: 'vinegar', category: 2, productionTime: 4, sellCost: 15 },
{ labelTr: 'cotton', category: 2, productionTime: 4, sellCost: 15 },
{ labelTr: 'wine', category: 2, productionTime: 4, sellCost: 15 },
{ labelTr: 'gold', category: 3, productionTime: 4, sellCost: 30 },
{ labelTr: 'diamond', category: 3, productionTime: 4, sellCost: 30 },
{ labelTr: 'furniture', category: 3, productionTime: 4, sellCost: 30 },
{ labelTr: 'clothing', category: 3, productionTime: 4, sellCost: 30 },
{ labelTr: 'jewelry', category: 4, productionTime: 5, sellCost: 60 },
{ labelTr: 'painting', category: 4, productionTime: 5, sellCost: 60 },
{ labelTr: 'book', category: 4, productionTime: 5, sellCost: 60 },
{ labelTr: 'weapon', category: 4, productionTime: 5, sellCost: 60 },
{ labelTr: 'armor', category: 4, productionTime: 5, sellCost: 60 },
{ labelTr: 'shield', category: 4, productionTime: 5, sellCost: 60 },
{ labelTr: 'horse', category: 5, productionTime: 5, sellCost: 60 },
{ labelTr: 'ox', category: 5, productionTime: 5, sellCost: 60 },
{ labelTr: 'wheat', category: 1, productionTime: 2, sellCost: 11 },
{ labelTr: 'grain', category: 1, productionTime: 2, sellCost: 11 },
{ labelTr: 'carrot', category: 1, productionTime: 2, sellCost: 11 },
{ labelTr: 'fish', category: 1, productionTime: 2, sellCost: 11 },
{ labelTr: 'meat', category: 1, productionTime: 2, sellCost: 11 },
{ labelTr: 'leather', category: 1, productionTime: 2, sellCost: 11 },
{ labelTr: 'wood', category: 1, productionTime: 2, sellCost: 11 },
{ labelTr: 'stone', category: 1, productionTime: 2, sellCost: 11 },
{ labelTr: 'milk', category: 1, productionTime: 1, sellCost: 11 },
{ labelTr: 'cheese', category: 1, productionTime: 1, sellCost: 11 },
{ labelTr: 'bread', category: 1, productionTime: 1, sellCost: 11 },
{ labelTr: 'beer', category: 2, productionTime: 3, sellCost: 22 },
{ labelTr: 'iron', category: 2, productionTime: 4, sellCost: 24 },
{ labelTr: 'copper', category: 2, productionTime: 4, sellCost: 24 },
{ labelTr: 'spices', category: 2, productionTime: 8, sellCost: 42 },
{ labelTr: 'salt', category: 2, productionTime: 4, sellCost: 24 },
{ labelTr: 'sugar', category: 2, productionTime: 4, sellCost: 24 },
{ labelTr: 'vinegar', category: 2, productionTime: 4, sellCost: 24 },
{ labelTr: 'cotton', category: 2, productionTime: 4, sellCost: 24 },
{ labelTr: 'wine', category: 2, productionTime: 4, sellCost: 24 },
{ labelTr: 'gold', category: 3, productionTime: 4, sellCost: 40 },
{ labelTr: 'diamond', category: 3, productionTime: 4, sellCost: 40 },
{ labelTr: 'furniture', category: 3, productionTime: 4, sellCost: 40 },
{ labelTr: 'clothing', category: 3, productionTime: 4, sellCost: 40 },
{ labelTr: 'jewelry', category: 4, productionTime: 5, sellCost: 58 },
{ labelTr: 'painting', category: 4, productionTime: 5, sellCost: 58 },
{ labelTr: 'book', category: 4, productionTime: 5, sellCost: 58 },
{ labelTr: 'weapon', category: 4, productionTime: 5, sellCost: 58 },
{ labelTr: 'armor', category: 4, productionTime: 5, sellCost: 58 },
{ labelTr: 'shield', category: 4, productionTime: 5, sellCost: 58 },
{ labelTr: 'horse', category: 5, productionTime: 5, sellCost: 78 },
{ labelTr: 'ox', category: 5, productionTime: 5, sellCost: 78 },
];
validateProductBaseSellCosts(baseProducts);
const productsToInsert = baseProducts.map(p => ({
...p,
sellCostMinNeutral: Math.ceil(p.sellCost * factorMin),
@@ -297,24 +300,24 @@ async function initializeFalukantProducts() {
async function initializeFalukantTitleRequirements() {
const titleRequirements = [
{ labelTr: "civil", requirements: [{ type: "money", value: 5000 }, { type: "cost", value: 500 }] },
{ labelTr: "sir", requirements: [{ type: "branches", value: 2 }, { type: "cost", value: 1000 }] },
{ labelTr: "townlord", requirements: [{ type: "cost", value: 3000 }] },
{ labelTr: "by", requirements: [{ type: "cost", value: 6000 }] },
{ labelTr: "landlord", requirements: [{ type: "cost", value: 9000 }] },
{ labelTr: "knight", requirements: [{ type: "cost", value: 11000 }] },
{ labelTr: "baron", requirements: [{ type: "branches", value: 4 }, { type: "cost", value: 15000 }] },
{ labelTr: "count", requirements: [{ type: "cost", value: 19000 }] },
{ labelTr: "palsgrave", requirements: [{ type: "cost", value: 25000 }] },
{ labelTr: "margrave", requirements: [{ type: "cost", value: 33000 }] },
{ labelTr: "landgrave", requirements: [{ type: "cost", value: 47000 }] },
{ labelTr: "ruler", requirements: [{ type: "cost", value: 66000 }] },
{ labelTr: "elector", requirements: [{ type: "cost", value: 79000 }] },
{ labelTr: "imperial-prince", requirements: [{ type: "cost", value: 99999 }] },
{ labelTr: "duke", requirements: [{ type: "cost", value: 130000 }] },
{ labelTr: "grand-duke",requirements: [{ type: "cost", value: 170000 }] },
{ labelTr: "prince-regent", requirements: [{ type: "cost", value: 270000 }] },
{ labelTr: "king", requirements: [{ type: "cost", value: 500000 }] },
{ labelTr: "civil", requirements: [{ type: "money", value: 5000 }, { type: "cost", value: 500 }, { type: "house_position", value: 2 }] },
{ labelTr: "sir", requirements: [{ type: "branches", value: 2 }, { type: "cost", value: 1000 }, { type: "house_position", value: 3 }] },
{ labelTr: "townlord", requirements: [{ type: "cost", value: 3000 }, { type: "money", value: 12000 }, { type: "reputation", value: 18 }, { type: "house_position", value: 4 }] },
{ labelTr: "by", requirements: [{ type: "cost", value: 5000 }, { type: "money", value: 18000 }, { type: "house_position", value: 4 }] },
{ labelTr: "landlord", requirements: [{ type: "cost", value: 7500 }, { type: "money", value: 26000 }, { type: "reputation", value: 24 }, { type: "house_position", value: 4 }, { type: "house_condition", value: 60 }] },
{ labelTr: "knight", requirements: [{ type: "cost", value: 11000 }, { type: "money", value: 38000 }, { type: "office_rank_any", value: 1 }, { type: "house_position", value: 5 }] },
{ labelTr: "baron", requirements: [{ type: "branches", value: 4 }, { type: "cost", value: 16000 }, { type: "money", value: 55000 }, { type: "house_position", value: 5 }] },
{ labelTr: "count", requirements: [{ type: "cost", value: 23000 }, { type: "money", value: 80000 }, { type: "reputation", value: 32 }, { type: "house_position", value: 5 }, { type: "house_condition", value: 68 }] },
{ labelTr: "palsgrave", requirements: [{ type: "cost", value: 32000 }, { type: "money", value: 115000 }, { type: "office_rank_any", value: 2 }, { type: "house_position", value: 6 }] },
{ labelTr: "margrave", requirements: [{ type: "cost", value: 45000 }, { type: "money", value: 165000 }, { type: "reputation", value: 40 }, { type: "house_position", value: 6 }, { type: "house_condition", value: 72 }, { type: "lover_count_min", value: 1 }] },
{ labelTr: "landgrave", requirements: [{ type: "cost", value: 62000 }, { type: "money", value: 230000 }, { type: "office_rank_any", value: 3 }, { type: "house_position", value: 6 }] },
{ labelTr: "ruler", requirements: [{ type: "cost", value: 85000 }, { type: "money", value: 320000 }, { type: "reputation", value: 48 }, { type: "house_position", value: 7 }, { type: "house_condition", value: 76 }] },
{ labelTr: "elector", requirements: [{ type: "cost", value: 115000 }, { type: "money", value: 440000 }, { type: "office_rank_political", value: 4 }, { type: "house_position", value: 7 }, { type: "lover_count_max", value: 3 }] },
{ labelTr: "imperial-prince", requirements: [{ type: "cost", value: 155000 }, { type: "money", value: 600000 }, { type: "reputation", value: 56 }, { type: "house_position", value: 7 }, { type: "house_condition", value: 80 }] },
{ labelTr: "duke", requirements: [{ type: "cost", value: 205000 }, { type: "money", value: 820000 }, { type: "office_rank_any", value: 5 }, { type: "house_position", value: 8 }] },
{ labelTr: "grand-duke",requirements: [{ type: "cost", value: 270000 }, { type: "money", value: 1120000 }, { type: "reputation", value: 64 }, { type: "house_position", value: 8 }, { type: "house_condition", value: 84 }, { type: "lover_count_min", value: 1 }, { type: "lover_count_max", value: 3 }] },
{ labelTr: "prince-regent", requirements: [{ type: "cost", value: 360000 }, { type: "money", value: 1520000 }, { type: "office_rank_any", value: 6 }, { type: "house_position", value: 9 }] },
{ labelTr: "king", requirements: [{ type: "cost", value: 500000 }, { type: "money", value: 2100000 }, { type: "reputation", value: 72 }, { type: "house_position", value: 9 }, { type: "house_condition", value: 88 }, { type: "lover_count_min", value: 1 }, { type: "lover_count_max", value: 4 }] },
];
const titles = await TitleOfNobility.findAll();
@@ -325,13 +328,6 @@ async function initializeFalukantTitleRequirements() {
const title = titles.find(t => t.labelTr === titleReq.labelTr);
if (!title) continue;
if (i > 1) {
titleReq.requirements.push({
type: "money",
value: 5000 * Math.pow(3, i - 1),
});
}
for (const req of titleReq.requirements) {
requirementsToInsert.push({
titleId: title.id,
@@ -341,6 +337,7 @@ async function initializeFalukantTitleRequirements() {
}
}
await TitleRequirement.destroy({ where: {} });
await TitleRequirement.bulkCreate(requirementsToInsert, { ignoreDuplicates: true });
}

View File

@@ -659,6 +659,14 @@ const undergroundTypes = [
"tr": "rob",
"cost": 500
},
{
"tr": "investigate_affair",
"cost": 7000
},
{
"tr": "raid_transport",
"cost": 9000
},
];
{

View File

@@ -17,6 +17,14 @@ const initializeSettings = async () => {
where: { name: 'flirt' },
defaults: { name: 'flirt' }
});
await SettingsType.findOrCreate({
where: { name: 'account' },
defaults: { name: 'account' }
});
await SettingsType.findOrCreate({
where: { name: 'languageAssistant' },
defaults: { name: 'languageAssistant' }
});
};
export default initializeSettings;

View File

@@ -46,6 +46,11 @@ const initializeTypes = async () => {
willChildren: { type: 'bool', setting: 'flirt', minAge: 14 },
smokes: { type: 'singleselect', setting: 'flirt', minAge: 14},
drinks: { type: 'singleselect', setting: 'flirt', minAge: 14 },
adult_verification_status: { type: 'string', setting: 'account', minAge: 18 },
adult_verification_request: { type: 'string', setting: 'account', minAge: 18 },
adult_upload_blocked: { type: 'bool', setting: 'account', minAge: 18 },
llm_settings: { type: 'string', setting: 'languageAssistant' },
llm_api_key: { type: 'string', setting: 'languageAssistant' },
};
let orderId = 1;
for (const key of Object.keys(userParams)) {

View File

@@ -1,7 +1,18 @@
import { Sequelize, DataTypes } from 'sequelize';
import dotenv from 'dotenv';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
dotenv.config();
const _dotenvQuiet = process.env.QUIET_ENV_LOGS === '1' || process.env.DOTENV_CONFIG_QUIET === '1';
dotenv.config({ quiet: _dotenvQuiet });
// backend/.env.local — Tunnel/Entwicklung (override), auch wenn loadEnv.js nicht importiert wurde
const _sequelizeDir = path.dirname(fileURLToPath(import.meta.url));
const _envLocalPath = path.join(_sequelizeDir, '../.env.local');
if (fs.existsSync(_envLocalPath)) {
dotenv.config({ path: _envLocalPath, override: true, quiet: _dotenvQuiet });
}
// Optionales Performance-Logging (aktivierbar per ENV)
// - SQL_BENCHMARK=1: Sequelize liefert Query-Timing (ms) an logger
@@ -27,12 +38,20 @@ const dbName = process.env.DB_NAME;
const dbUser = process.env.DB_USER;
const dbPass = process.env.DB_PASS || ''; // Fallback auf leeren String
const dbHost = process.env.DB_HOST;
const dbPort = Number.parseInt(process.env.DB_PORT || '5432', 10);
console.log('Database configuration:');
console.log('DB_NAME:', dbName);
console.log('DB_USER:', dbUser);
console.log('DB_PASS:', dbPass ? '[SET]' : '[NOT SET]');
console.log('DB_HOST:', dbHost);
const useSsl = process.env.DB_SSL === '1' || process.env.PGSSLMODE === 'require';
const connectTimeoutMs = Number.parseInt(process.env.DB_CONNECT_TIMEOUT_MS || '30000', 10);
if (process.env.QUIET_ENV_LOGS !== '1') {
console.log('Database configuration:');
console.log('DB_NAME:', dbName);
console.log('DB_USER:', dbUser);
console.log('DB_PASS:', dbPass ? '[SET]' : '[NOT SET]');
console.log('DB_HOST:', dbHost);
console.log('DB_PORT:', dbPort);
console.log('DB_SSL:', useSsl ? 'on' : 'off');
}
if (!dbName || !dbUser || !dbHost) {
throw new Error('Missing required database environment variables: DB_NAME, DB_USER, or DB_HOST');
@@ -44,8 +63,22 @@ const poolAcquire = Number.parseInt(process.env.DB_POOL_ACQUIRE || '30000', 10);
const poolIdle = Number.parseInt(process.env.DB_POOL_IDLE || '10000', 10);
const poolEvict = Number.parseInt(process.env.DB_POOL_EVICT || '1000', 10);
const dialectOptions = {
connectTimeout: connectTimeoutMs,
...(useSsl
? {
// node-pg: true oder { rejectUnauthorized: false } bei selbstsigniertem Zertifikat
ssl:
process.env.DB_SSL_REJECT_UNAUTHORIZED === '0'
? { rejectUnauthorized: false }
: true
}
: {})
};
const sequelize = new Sequelize(dbName, dbUser, dbPass, {
host: dbHost,
port: dbPort,
dialect: 'postgres',
define: {
timestamps: false,
@@ -61,9 +94,7 @@ const sequelize = new Sequelize(dbName, dbUser, dbPass, {
evict: poolEvict, // Intervall (ms) zum Prüfen auf idle Verbindungen
handleDisconnects: true // Automatisches Reconnect bei Verbindungsverlust
},
dialectOptions: {
connectTimeout: 30000 // Timeout für Verbindungsaufbau (30 Sekunden)
},
dialectOptions,
retry: {
max: 3, // Maximale Anzahl von Wiederholungsversuchen
match: [

View File

@@ -0,0 +1,26 @@
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function getBackendRoot() {
return path.join(__dirname, '..');
}
export function getAdultVerificationBaseDir() {
if (process.env.ADULT_VERIFICATION_DIR && process.env.ADULT_VERIFICATION_DIR.trim()) {
return process.env.ADULT_VERIFICATION_DIR.trim();
}
if (process.env.YOURPART_PERSISTENT_DATA_DIR && process.env.YOURPART_PERSISTENT_DATA_DIR.trim()) {
return path.join(process.env.YOURPART_PERSISTENT_DATA_DIR.trim(), 'adult-verification');
}
if (process.env.STAGE === 'production' || process.env.NODE_ENV === 'production') {
return '/opt/yourpart-data/adult-verification';
}
return path.join(getBackendRoot(), 'images', 'adult-verification');
}
export function getLegacyAdultVerificationBaseDir() {
return path.join(getBackendRoot(), 'images', 'adult-verification');
}

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