Compare commits

40 Commits

Author SHA1 Message Date
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
234 changed files with 25528 additions and 2241 deletions

1
.gitignore vendored
View File

@@ -7,6 +7,7 @@ node_modules
node_modules/*
**/package-lock.json
backend/.env
backend/.env.local
backend/images
backend/images/*
backend/node_modules

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

@@ -214,6 +214,10 @@ const menuStructure = {
visible: ["all"],
path: "/settings/account"
},
languageAssistant: {
visible: ["all"],
path: "/settings/language-assistant"
},
personal: {
visible: ["all"],
path: "/settings/personal"
@@ -254,6 +258,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 +351,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 +372,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 +409,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,16 @@ 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.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.uploadEroticVideo = this.uploadEroticVideo.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);
@@ -187,6 +197,138 @@ 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 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 } = req.body;
const folderId = await this.socialNetworkService.changeAdultImage(userId, imageId, title, visibilities);
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 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 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

@@ -18,6 +18,8 @@ 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';
@@ -68,6 +70,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 +211,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,
@@ -460,6 +471,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 +1108,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

@@ -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,6 +18,8 @@ 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';
@@ -67,6 +69,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,6 +172,8 @@ const models = {
UserParamVisibility,
Folder,
Image,
EroticVideo,
EroticContentReport,
ImageVisibilityType,
ImageVisibilityUser,
FolderImageVisibility,
@@ -219,6 +224,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);
}
}
},

View File

@@ -12,38 +12,40 @@
"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",
"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",
"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",
"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",
"@gltf-transform/cli": "^4.3.0"
},
"devDependencies": {
"sequelize-cli": "^6.6.2"
"sequelize-cli": "^6.6.5"
}
}

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

@@ -47,6 +47,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 +68,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 +112,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,16 @@ 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('/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.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,44 @@ 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';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
class AdminService {
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 +264,359 @@ 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'] }
}
]
}
],
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;
}
return {
id: user.hashedId,
username: user.username,
active: !!user.active,
age,
adultVerificationStatus: verificationStatus,
adultVerificationRequest: verificationRequest
};
}).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 }
});
if (existing) {
await existing.update({ value: status });
} else {
await UserParam.create({
userId: user.id,
paramTypeId: paramType.id,
value: status
});
}
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 = path.join(__dirname, '..', 'images', 'adult-verification', requestData.fileName);
if (!fs.existsSync(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 +1067,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,111 @@ 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';
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{
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(__dirname, '..', 'images', 'adult-verification', 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) {
const paramType = await UserParamType.findOne({ where: { 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 +390,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 +411,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 +428,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 +464,34 @@ 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,
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 +527,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();

View File

@@ -8,6 +8,8 @@ import UserParamVisibility from '../models/community/user_param_visibility.js';
import UserParamVisibilityType from '../models/type/user_param_visibility.js';
import Folder from '../models/community/folder.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 ImageVisibilityType from '../models/type/image_visibility.js';
import FolderImageVisibility from '../models/community/folder_image_visibility.js';
import ImageImageVisibility from '../models/community/image_image_visibility.js';
@@ -30,6 +32,150 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
class SocialNetworkService extends BaseService {
normalizeAdultVerificationStatus(value) {
if (!value) return 'none';
const normalized = String(value).trim().toLowerCase();
return ['pending', 'approved', 'rejected'].includes(normalized) ? normalized : 'none';
}
async getAdultAccessState(userId) {
const params = await this.getUserParams(userId, ['birthdate', 'adult_verification_status', 'adult_upload_blocked']);
const birthdateParam = params.find(param => param.paramType.description === 'birthdate');
const statusParam = params.find(param => param.paramType.description === 'adult_verification_status');
const uploadBlockedParam = params.find(param => param.paramType.description === 'adult_upload_blocked');
const age = birthdateParam ? this.calculateAge(birthdateParam.value) : 0;
const adultVerificationStatus = this.normalizeAdultVerificationStatus(statusParam?.value);
return {
age,
isAdult: age >= 18,
adultVerificationStatus,
adultAccessEnabled: age >= 18 && adultVerificationStatus === 'approved',
adultUploadBlocked: String(uploadBlockedParam?.value).toLowerCase() === 'true'
};
}
async requireAdultAreaAccessByHash(hashedId) {
const userId = await this.checkUserAccess(hashedId);
const adultAccess = await this.getAdultAccessState(userId);
if (!adultAccess.adultAccessEnabled) {
const error = new Error('Adult area access denied');
error.status = 403;
throw error;
}
return userId;
}
async ensureAdultUploadsAllowed(userId) {
const adultAccess = await this.getAdultAccessState(userId);
if (adultAccess.adultUploadBlocked) {
const error = new Error('Adult uploads are blocked');
error.status = 403;
throw error;
}
}
async resolveEroticTarget(targetType, targetId) {
if (targetType === 'image') {
const image = await Image.findOne({
where: {
id: targetId,
isAdultContent: true
}
});
if (!image) {
throw new Error('Target not found');
}
return { targetType, target: image, ownerId: image.userId };
}
if (targetType === 'video') {
const video = await EroticVideo.findByPk(targetId);
if (!video) {
throw new Error('Target not found');
}
return { targetType, target: video, ownerId: video.userId };
}
throw new Error('Unsupported target type');
}
async ensureRootFolder(userId) {
let rootFolder = await Folder.findOne({
where: { parentId: null, userId, isAdultArea: false },
include: [{
model: ImageVisibilityType,
through: { model: FolderImageVisibility },
attributes: ['id'],
}]
});
if (rootFolder) {
return rootFolder;
}
const user = await User.findOne({ where: { id: userId } });
const visibility = await ImageVisibilityType.findOne({ where: { description: 'everyone' } });
rootFolder = await Folder.create({
name: user.username,
parentId: null,
userId,
isAdultArea: false
});
if (visibility) {
await FolderImageVisibility.create({
folderId: rootFolder.id,
visibilityTypeId: visibility.id
});
}
return await Folder.findOne({
where: { id: rootFolder.id },
include: [{
model: ImageVisibilityType,
through: { model: FolderImageVisibility },
attributes: ['id'],
}]
});
}
async ensureAdultRootFolder(userId) {
const rootFolder = await this.ensureRootFolder(userId);
let adultRoot = await Folder.findOne({
where: {
parentId: rootFolder.id,
userId,
isAdultArea: true,
name: 'Erotik'
},
include: [{
model: ImageVisibilityType,
through: { model: FolderImageVisibility },
attributes: ['id'],
}]
});
if (adultRoot) {
return adultRoot;
}
const adultsVisibility = await ImageVisibilityType.findOne({ where: { description: 'adults' } });
adultRoot = await Folder.create({
name: 'Erotik',
parentId: rootFolder.id,
userId,
isAdultArea: true
});
if (adultsVisibility) {
await FolderImageVisibility.create({
folderId: adultRoot.id,
visibilityTypeId: adultsVisibility.id
});
}
return await Folder.findOne({
where: { id: adultRoot.id },
include: [{
model: ImageVisibilityType,
through: { model: FolderImageVisibility },
attributes: ['id'],
}]
});
}
async searchUsers({ hashedUserId, username, ageFrom, ageTo, genders }) {
const whereClause = this.buildSearchWhereClause(username);
const user = await this.loadUserByHash(hashedUserId);
@@ -49,15 +195,19 @@ class SocialNetworkService extends BaseService {
return this.constructUserProfile(user, requestingUserId);
}
async createFolder(hashedUserId, data, folderId) {
async createFolder(hashedUserId, data, folderId, options = {}) {
await this.checkUserAccess(hashedUserId);
const user = await this.loadUserByHash(hashedUserId);
if (!user) {
throw new Error('User not found');
}
const isAdultArea = Boolean(options.isAdultArea);
if (isAdultArea) {
await this.requireAdultAreaAccessByHash(hashedUserId);
}
console.log('given data', data, folderId);
const parentFolder = data.parentId ? await Folder.findOne({
where: { id: data.parentId, userId: user.id }
where: { id: data.parentId, userId: user.id, isAdultArea }
}) : null;
if (data.parentId && !parentFolder) {
throw new Error('Parent folder not found');
@@ -68,11 +218,12 @@ class SocialNetworkService extends BaseService {
newFolder = await Folder.create({
parentId: parentFolder.id || null,
userId: user.id,
name: data.name
name: data.name,
isAdultArea
});
} else {
newFolder = await Folder.findOne({
where: { id: folderId, userId: user.id }
where: { id: folderId, userId: user.id, isAdultArea }
});
if (!newFolder) {
throw new Error('Folder not found or user does not own the folder');
@@ -94,38 +245,8 @@ class SocialNetworkService extends BaseService {
async getFolders(hashedId) {
const userId = await this.checkUserAccess(hashedId);
let rootFolder = await Folder.findOne({
where: { parentId: null, userId },
include: [{
model: ImageVisibilityType,
through: { model: FolderImageVisibility },
attributes: ['id'],
}]
});
if (!rootFolder) {
const user = await User.findOne({ where: { id: userId } });
const visibility = await ImageVisibilityType.findOne({
where: { description: 'everyone' }
});
rootFolder = await Folder.create({
name: user.username,
parentId: null,
userId
});
await FolderImageVisibility.create({
folderId: rootFolder.id,
visibilityTypeId: visibility.id
});
rootFolder = await Folder.findOne({
where: { id: rootFolder.id },
include: [{
model: ImageVisibilityType,
through: { model: FolderImageVisibility },
attributes: ['id'],
}]
});
}
const children = await this.getSubFolders(rootFolder.id, userId);
const rootFolder = await this.ensureRootFolder(userId);
const children = await this.getSubFolders(rootFolder.id, userId, false);
rootFolder = rootFolder.get();
rootFolder.visibilityTypeIds = rootFolder.image_visibility_types.map(v => v.id);
delete rootFolder.image_visibility_types;
@@ -133,9 +254,9 @@ class SocialNetworkService extends BaseService {
return rootFolder;
}
async getSubFolders(parentId, userId) {
async getSubFolders(parentId, userId, isAdultArea = false) {
const folders = await Folder.findAll({
where: { parentId, userId },
where: { parentId, userId, isAdultArea },
include: [{
model: ImageVisibilityType,
through: { model: FolderImageVisibility },
@@ -146,7 +267,7 @@ class SocialNetworkService extends BaseService {
]
});
for (const folder of folders) {
const children = await this.getSubFolders(folder.id, userId);
const children = await this.getSubFolders(folder.id, userId, isAdultArea);
const visibilityTypeIds = folder.image_visibility_types.map(v => v.id);
folder.setDataValue('visibilityTypeIds', visibilityTypeIds);
folder.setDataValue('children', children);
@@ -160,13 +281,15 @@ class SocialNetworkService extends BaseService {
const folder = await Folder.findOne({
where: {
id: folderId,
userId
userId,
isAdultArea: false
}
});
if (!folder) throw new Error('Folder not found');
return await Image.findAll({
where: {
folderId: folder.id
folderId: folder.id,
isAdultContent: false
},
order: [
['title', 'asc']
@@ -176,13 +299,13 @@ class SocialNetworkService extends BaseService {
async uploadImage(hashedId, file, formData) {
const userId = await this.getUserId(hashedId);
const processedImageName = await this.processAndUploadUserImage(file);
const newImage = await this.createImageRecord(formData, userId, file, processedImageName);
const processedImageName = await this.processAndUploadUserImage(file, 'user');
const newImage = await this.createImageRecord(formData, userId, file, processedImageName, { isAdultContent: false });
await this.saveImageVisibilities(newImage.id, formData.visibility);
return newImage;
}
async processAndUploadUserImage(file) {
async processAndUploadUserImage(file, storageType = 'user') {
try {
const img = sharp(file.buffer);
const metadata = await img.metadata();
@@ -199,7 +322,7 @@ class SocialNetworkService extends BaseService {
withoutEnlargement: true
});
const newFileName = this.generateUniqueFileName(file.originalname);
const filePath = this.buildFilePath(newFileName, 'user');
const filePath = this.buildFilePath(newFileName, storageType);
await resizedImg.toFile(filePath);
return newFileName;
} catch (error) {
@@ -231,7 +354,7 @@ class SocialNetworkService extends BaseService {
}
}
async createImageRecord(formData, userId, file, fileName) {
async createImageRecord(formData, userId, file, fileName, options = {}) {
try {
return await Image.create({
title: formData.title,
@@ -240,6 +363,7 @@ class SocialNetworkService extends BaseService {
hash: fileName,
folderId: formData.folderId,
userId: userId,
isAdultContent: Boolean(options.isAdultContent),
});
} catch (error) {
throw new Error(`Failed to create image record: ${error.message}`);
@@ -271,6 +395,7 @@ class SocialNetworkService extends BaseService {
async getImage(imageId) {
const image = await Image.findByPk(imageId);
if (!image) throw new Error('Image not found');
if (image.isAdultContent) throw new Error('Access denied');
await this.checkUserAccess(image.userId);
return image;
}
@@ -455,6 +580,9 @@ class SocialNetworkService extends BaseService {
if (!image) {
throw new Error('Image not found');
}
if (image.isAdultContent) {
throw new Error('Access denied');
}
const userId = await this.checkUserAccess(hashedUserId);
const hasAccess = await this.checkUserImageAccess(userId, image.id);
if (!hasAccess) {
@@ -467,6 +595,178 @@ class SocialNetworkService extends BaseService {
return imagePath;
}
async getAdultFolders(hashedId) {
const userId = await this.requireAdultAreaAccessByHash(hashedId);
const rootFolder = await this.ensureAdultRootFolder(userId);
const children = await this.getSubFolders(rootFolder.id, userId, true);
const data = rootFolder.get();
data.visibilityTypeIds = data.image_visibility_types.map(v => v.id);
delete data.image_visibility_types;
data.children = children;
return data;
}
async getAdultFolderImageList(hashedId, folderId) {
const userId = await this.requireAdultAreaAccessByHash(hashedId);
const folder = await Folder.findOne({
where: { id: folderId, userId, isAdultArea: true }
});
if (!folder) {
throw new Error('Folder not found');
}
return await Image.findAll({
where: {
folderId: folder.id,
isAdultContent: true,
userId
},
order: [['title', 'asc']]
});
}
async createAdultFolder(hashedId, data, folderId) {
await this.requireAdultAreaAccessByHash(hashedId);
if (!data.parentId) {
const userId = await this.checkUserAccess(hashedId);
const adultRoot = await this.ensureAdultRootFolder(userId);
data.parentId = adultRoot.id;
}
return this.createFolder(hashedId, data, folderId, { isAdultArea: true });
}
async uploadAdultImage(hashedId, file, formData) {
const userId = await this.requireAdultAreaAccessByHash(hashedId);
await this.ensureAdultUploadsAllowed(userId);
const folder = await Folder.findOne({
where: {
id: formData.folderId,
userId,
isAdultArea: true
}
});
if (!folder) {
throw new Error('Folder not found');
}
const processedImageName = await this.processAndUploadUserImage(file, 'erotic');
const newImage = await this.createImageRecord(formData, userId, file, processedImageName, { isAdultContent: true });
await this.saveImageVisibilities(newImage.id, formData.visibility);
return newImage;
}
async getAdultImageFilePath(hashedId, hash) {
const userId = await this.requireAdultAreaAccessByHash(hashedId);
const image = await Image.findOne({
where: {
hash,
userId,
isAdultContent: true
}
});
if (!image) {
throw new Error('Image not found');
}
if (image.isModeratedHidden) {
throw new Error('Image hidden by moderation');
}
const imagePath = this.buildFilePath(image.hash, 'erotic');
if (!fs.existsSync(imagePath)) {
throw new Error(`File "${imagePath}" not found`);
}
return imagePath;
}
async listEroticVideos(hashedId) {
const userId = await this.requireAdultAreaAccessByHash(hashedId);
return await EroticVideo.findAll({
where: { userId },
order: [['createdAt', 'DESC']]
});
}
async uploadEroticVideo(hashedId, file, formData) {
const userId = await this.requireAdultAreaAccessByHash(hashedId);
await this.ensureAdultUploadsAllowed(userId);
if (!file) {
throw new Error('Video file is required');
}
const allowedMimeTypes = ['video/mp4', 'video/webm', 'video/ogg', 'video/quicktime'];
if (!allowedMimeTypes.includes(file.mimetype)) {
throw new Error('Unsupported video format');
}
const fileName = this.generateUniqueFileName(file.originalname);
const filePath = this.buildFilePath(fileName, 'erotic-video');
await this.saveFile(file.buffer, filePath);
return await EroticVideo.create({
title: formData.title || file.originalname,
description: formData.description || null,
originalFileName: file.originalname,
hash: fileName,
mimeType: file.mimetype,
userId
});
}
async getEroticVideoFilePath(hashedId, hash) {
const userId = await this.requireAdultAreaAccessByHash(hashedId);
const video = await EroticVideo.findOne({
where: { hash, userId }
});
if (!video) {
throw new Error('Video not found');
}
if (video.isModeratedHidden) {
throw new Error('Video hidden by moderation');
}
const videoPath = this.buildFilePath(video.hash, 'erotic-video');
if (!fs.existsSync(videoPath)) {
throw new Error(`File "${videoPath}" not found`);
}
return { filePath: videoPath, mimeType: video.mimeType };
}
async createEroticContentReport(hashedId, payload) {
const reporterId = await this.requireAdultAreaAccessByHash(hashedId);
const targetType = String(payload.targetType || '').trim().toLowerCase();
const targetId = Number(payload.targetId);
const reason = String(payload.reason || '').trim().toLowerCase();
const note = payload.note ? String(payload.note).trim() : null;
if (!['image', 'video'].includes(targetType) || !Number.isInteger(targetId) || targetId <= 0) {
throw new Error('Invalid report target');
}
if (!['suspected_minor', 'non_consensual', 'violence', 'harassment', 'spam', 'other'].includes(reason)) {
throw new Error('Invalid report reason');
}
const { ownerId } = await this.resolveEroticTarget(targetType, targetId);
if (ownerId === reporterId) {
throw new Error('Own content cannot be reported');
}
const existingOpen = await EroticContentReport.findOne({
where: {
reporterId,
targetType,
targetId,
status: 'open'
}
});
if (existingOpen) {
return existingOpen;
}
return await EroticContentReport.create({
reporterId,
targetType,
targetId,
reason,
note,
status: 'open'
});
}
// Public variant used by blog: allow access if the image's folder is visible to 'everyone'.
async getImageFilePathPublicByHash(hash) {
const image = await Image.findOne({ where: { hash } });
@@ -510,7 +810,7 @@ class SocialNetworkService extends BaseService {
async changeImage(hashedUserId, imageId, title, visibilities) {
const userId = await this.checkUserAccess(hashedUserId);
await this.checkUserImageAccess(userId, imageId);
const image = await Image.findOne({ where: { id: imageId } });
const image = await Image.findOne({ where: { id: imageId, isAdultContent: false } });
if (!image) {
throw new Error('image not found')
}
@@ -522,13 +822,33 @@ class SocialNetworkService extends BaseService {
return image.folderId;
}
async changeAdultImage(hashedUserId, imageId, title, visibilities) {
const userId = await this.requireAdultAreaAccessByHash(hashedUserId);
const image = await Image.findOne({
where: {
id: imageId,
userId,
isAdultContent: true
}
});
if (!image) {
throw new Error('image not found');
}
await image.update({ title });
await ImageImageVisibility.destroy({ where: { imageId } });
for (const visibility of visibilities) {
await ImageImageVisibility.create({ imageId, visibilityTypeId: visibility.id });
}
return image.folderId;
}
async getFoldersByUsername(username, hashedUserId) {
const user = await this.loadUserByName(username);
if (!user) {
throw new Error('User not found');
}
const requestingUserId = await this.checkUserAccess(hashedUserId);
let rootFolder = await Folder.findOne({ where: { parentId: null, userId: user.id } });
let rootFolder = await Folder.findOne({ where: { parentId: null, userId: user.id, isAdultArea: false } });
if (!rootFolder) {
return null;
}
@@ -542,9 +862,9 @@ class SocialNetworkService extends BaseService {
const folderIdString = String(folderId);
const requestingUserIdString = String(requestingUserId);
const requestingUser = await User.findOne({ where: { id: requestingUserIdString } });
const isAdult = this.isUserAdult(requestingUser.id);
const isAdult = await this.isUserAdult(requestingUser.id);
const accessibleFolders = await Folder.findAll({
where: { parentId: folderIdString },
where: { parentId: folderIdString, isAdultArea: false },
include: [
{
model: ImageVisibilityType,

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,10 @@ const initializeSettings = async () => {
where: { name: 'flirt' },
defaults: { name: 'flirt' }
});
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,406 @@
# yourPart: Konzept für den Erotikbereich
## 1. Ausgangspunkt
Im Menü ist die klare Trennung bereits vorgesehen:
- `Social Network -> Galerie`
- `Social Network -> Erotik -> Bilder`
- `Social Network -> Erotik -> Videos`
Zusätzlich existiert im Chat-Umfeld bereits die Idee `Erotikchat`.
Damit sollte der 18+-Bereich **nicht** als bloßer Filter der normalen Galerie gedacht werden, sondern als **eigener Social-Bereich für Erwachsene**.
## 2. Zielbild
Der Erotikbereich ist ein eigener, abgegrenzter Teil des Social Networks für volljährige Nutzer.
Wichtig für den Zugang:
- **Erotik -> Bilder**
- **Erotik -> Videos**
- später zusätzlich **Erotik -> Chat** oder klar markierte 18+-Chaträume
Der Erotikbereich soll:
- ab **18 Jahren** im Menü sichtbar sein
- aber erst nach **Moderatorfreigabe** wirklich nutzbar sein
- technisch und visuell **klar vom normalen Social-Bereich getrennt** sein
- nicht versehentlich in allgemeine Feeds, Galerien oder Raumlisten hineinlaufen
- serverseitig abgesichert sein
Wichtig:
- **nicht** die gesamte Plattform wird auf Erwachsene beschränkt
- **nicht** das gesamte Social Network wird auf Erwachsene beschränkt
- ausschließlich die Module unter `Social Network -> Erotik -> ...` sind volljährigen Nutzern vorbehalten
- normale Bereiche wie Suche, Forum, Galerie, Freunde, Tagebuch und Chat bleiben davon getrennt
## 3. Bestand heute
Vorhanden:
- Menüstruktur für `Erotik -> Bilder` und `Erotik -> Videos` in [navigationController.js](/mnt/share/torsten/Programs/YourPart3/backend/controllers/navigationController.js)
- Navigationstexte in [navigation.json](/mnt/share/torsten/Programs/YourPart3/frontend/src/i18n/locales/de/navigation.json)
- normale Galerie in [GalleryView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/social/GalleryView.vue)
- Mehrraum-Chat in [MultiChatDialog.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/dialogues/chat/MultiChatDialog.vue)
- vorhandene Erwachsenensichtbarkeiten in der Galerie (`adults`, `friends-and-adults`)
Noch nicht fertig:
- echte Moderationsfreischaltung für Erwachsene
- eigene Erotik-Bilderansicht
- eigenes Erotik-Video-Modul
- 18+-Chatanbindung
- harte serverseitige Sperren für nicht berechtigte Nutzer
- Moderation speziell für Adult-Inhalte
## 4. Grundentscheidung
Erotik wird als **eigener Bereich** modelliert, nicht als Untermenge der Standard-Galerie.
Das bedeutet:
- normale Galerie bleibt normaler Social-Bereich
- Erotik-Bilder sind ein eigenes Modul
- Erotik-Videos sind ein eigenes Modul
- späterer Erotik-Chat ist ebenfalls ein eigenes Modul oder klar abgegrenzte Raumgruppe
Vorteile:
- klare UX
- weniger Vermischung
- einfachere Berechtigungslogik
- sauberere Moderation
- spätere Erweiterung auf Videos ohne Umbau
## 5. Zugangsmodell
## 5.1 Volljährigkeit
Nur Nutzer mit:
- `Alter >= 18`
dürfen den Erotikbereich überhaupt sehen.
## 5.2 Moderationsfreigabe
Zusätzlich braucht es eine echte Freischaltung:
- `adultVerificationStatus = none | pending | approved | rejected`
Dabei gilt:
- volljährig allein reicht nicht für die Nutzung
- erst `approved` schaltet Bilder, Videos und später Chats frei
- die Freigabe erfolgt durch Moderation auf Basis eines Nachweises
## 5.3 Sicht im Menü
Empfehlung:
- unter 18: `Erotik` erscheint gar nicht
- ab 18 ohne Freigabe: `Erotik` erscheint, die Unterpunkte sind sichtbar, aber gesperrt
- ab 18 mit `pending`: sichtbar, weiterhin gesperrt
- ab 18 mit `approved`: normal nutzbar
- ab 18 mit `rejected`: sichtbar, aber weiter gesperrt mit Hinweis auf erneute Anfrage
Alle anderen Social-Network-Bereiche bleiben unverändert erreichbar, sofern ihre eigenen Altersregeln nichts anderes verlangen.
## 6. Fachmodell
## 6.1 Nutzer
Benötigte logische Zustände:
- `isAdult`
- `adultVerificationStatus`
- optional später zusätzlich `adultModeEnabled` als freiwilliger Opt-in nach Freigabe
`isAdult` sollte aus vorhandenen Altersdaten abgeleitet werden, nicht frei gesetzt.
## 6.2 Erotik-Bilder
Eigenes Inhaltsmodell:
- Bild gehört zum Erotikbereich
- zusätzlich Sichtbarkeit wie bisher möglich
Empfohlene Felder:
- `isAdultContent` oder `contentRating = adult`
- optional `adultVisibility`
Wichtig:
- das ist mehr als bloß `adults` als Sichtbarkeit
- wir müssen auch fachlich erkennen können, dass der Inhalt **zum Erotikbereich** gehört
## 6.3 Erotik-Videos
Eigenes Inhaltsmodell analog zu Bildern:
- Video gehört zum Erotikbereich
- eigenes Metadatenmodell
- später Vorschaubild, Dauer, Format, Moderationsstatus
Empfohlene Felder:
- `isAdultContent`
- `processingStatus`
- `thumbnail`
## 6.4 Erotik-Chat
Für Chat reicht fachlich:
- bestehender Raumtyp `chat`
- plus Flag `isAdultOnly`
Optional zusätzlich:
- Raumtyp `erotic_chat`
## 7. Module
## 7.1 Erotik -> Bilder
Eigene View:
- zeigt nur Inhalte aus dem Erotikbereich
- kein Vermischen mit normaler Galerie
Funktionen:
- hochladen
- organisieren
- ansehen
- Sichtbarkeit steuern
- melden
Regeln:
- keine Ausgabe an nicht berechtigte Nutzer
- keine Thumbnails für nicht berechtigte Nutzer
- Direktaufruf serverseitig blocken
## 7.2 Erotik -> Videos
Eigene View:
- separat von Bildern
- gleiche Erwachsenensperren
Funktionen:
- Video-Upload
- Videoliste
- Vorschau
- Wiedergabe
- melden
Erste Ausbaustufe:
- einfache Liste
- keine komplexe Transcoding- oder Streaminglogik nötig, falls noch nicht vorhanden
## 7.3 Erotik -> Chat
Nicht zwingend sofort als eigener Menüpunkt nötig, aber fachlich vorbereiten.
Variante A:
- eigener Menüpunkt `Erotikchat`
Variante B:
- innerhalb des Mehrraum-Chats klar abgetrennte `18+`-Raumgruppe
Empfehlung:
- später eigener Einstieg oder klarer Erwachsenentab
- nicht bloß unauffällige Räume in der normalen Liste
## 8. Galerie- und Videologik
## 8.1 Keine Vermischung
Normale Galerie:
- zeigt keine Adult-Inhalte
Erotik-Bilder:
- zeigen nur Adult-Bilder
Erotik-Videos:
- zeigen nur Adult-Videos
## 8.2 Uploadregeln
Nur erlaubt für:
- `isAdult = true`
- `adultVerificationStatus = approved`
Beim Upload muss der Bereich eindeutig sein:
- normales Bild
- Erotik-Bild
- normales Video
- Erotik-Video
## 8.3 Vorschaulogik
Nicht berechtigte Nutzer dürfen:
- weder Originaldateien
- noch Vorschaubilder
- noch Metadatenlisten
erhalten.
## 9. Chatlogik
## 9.1 Raumlistenfilter
Nicht berechtigte Nutzer:
- sehen keine Adult-Räume
Berechtigte Nutzer:
- sehen Adult-Räume in klarer Erwachsenengruppe
## 9.2 Beitritt
Server prüft beim Join:
- Nutzer volljährig
- Moderation hat den Bereich freigeschaltet
- Raum `isAdultOnly`
## 9.3 Random Chat
Erste Version:
- kein erotischer Random Chat
Begründung:
- höheres Missbrauchsrisiko
- kompliziertere Consent- und Moderationslage
## 10. Moderation
Adult-Bereich braucht eigene Moderationslogik.
## 10.1 Meldegründe
- Minderjährigkeitsverdacht
- unerlaubte Inhalte
- Belästigung
- Druck / Nötigung
- Gewalt-/Missbrauchskontext
- Spam / Scam
## 10.2 Adminsicht
Admins brauchen:
- Adult-Kennzeichnung an Bildern
- Adult-Kennzeichnung an Videos
- Adult-Kennzeichnung an Räumen
- schnelle Sperrung einzelner Inhalte
- schnelle Sperrung von Uploadrechten
## 10.3 Nulltoleranz
Nicht erlaubt:
- Minderjährige oder minderjährig wirkende Sexualdarstellung
- Gewalt-/Missbrauchsdarstellung
- Umgehung von Altersgrenzen
## 11. Technische Umsetzung
## 11.1 Backend
Benötigt:
- Prüfung `isAdult`
- Prüfung `adultVerificationStatus`
- Filterung von Erotik-Menü/API-Daten
- getrennte Endpunkte oder klare Adult-Filter für Bilder
- eigenes Video-Modul oder klare Adult-Video-Endpunkte
- Chatraumfilter für `isAdultOnly`
## 11.2 Frontend
Benötigt:
- Sicht auf Freischaltungsstatus und Anfrage
- eigene Views:
- `ErotikBilderView`
- `ErotikVideosView`
- klare Zugangshinweise bei gesperrtem Bereich
- später Adult-Chat-Einstieg
## 11.3 Serverseitige Pflicht
Wichtig:
- Frontend-Sperren reichen nie aus
- jede Dateiausgabe und jeder Chatzugang muss serverseitig geprüft werden
## 12. Umsetzungsphasen
## Phase A1: Zugang
- `isAdult` sauber ableiten
- `adultVerificationStatus = none | pending | approved | rejected`
- Einstellungs-UI und Freischaltungsansicht
- Menü ab 18 sichtbar, bis Freigabe gesperrt
- serverseitige Sperren für Adult-Routen
## Phase A2: Erotik-Bilder
- eigener Erotik-Bilderpfad
- Adult-Kennzeichnung für Bilder
- Upload- und Anzeige-Logik
## Phase A3: Erotik-Videos
- eigenes Videomodul
- Adult-Kennzeichnung für Videos
- Upload und Anzeige
## Phase A4: Erotik-Chat
- Adult-Raumflag
- Raumlistenfilter
- Join-Sperren
- klarer UI-Einstieg
## Phase A5: Moderation
- Meldegründe
- Adminsicht
- Sperrpfade
## 13. Empfehlung für den Start
Die erste sinnvolle, kontrollierbare Version ist:
- `A1` Zugang
- `A2` Erotik-Bilder
Danach:
- `A3` Erotik-Videos
- `A4` Erotik-Chat
So nutzt ihr die bereits vorhandene Menüstruktur sauber aus und baut nicht auf halbe Übergangslösungen wie bloße Galeriefilter.

View File

@@ -0,0 +1,328 @@
# Falukant: Kirchenämter, Aufstieg und NPC-Besetzung
Dieses Dokument beschreibt das Zielmodell für das Kirchensystem in Falukant. Es fokussiert auf drei Probleme:
- Spieler können sich derzeit nicht sinnvoll auf höhere kirchliche Ämter bewerben.
- Nicht alle Ämter werden besetzt.
- NPCs sollen sich ebenfalls bewerben und Ämter aktiv besetzen.
Der Daemon soll die laufende Besetzung und Beförderung übernehmen. Der eigentliche Antrag des Spielers bleibt ein aktiver Spielzug in der UI.
## 1. Zielbild
Kirchliche Ämter sollen ein lebendes Hierarchiesystem sein:
- Leere Ämter werden nach und nach besetzt.
- Spieler und NPCs konkurrieren um offene Positionen.
- Höhere Amtsträger entscheiden über untere Ebenen.
- Wo kein Spieler-Entscheider vorhanden ist, übernimmt ein NPC-Amtsträger die Entscheidung.
- Wo ganze Hierarchieebenen leer sind, darf das System kontrolliert von unten nach oben oder über Interimslogik nachbesetzen.
## 2. Grundregeln
### 2.1 Bewerbung
- Ein Spieler beantragt ein Amt weiterhin aktiv über die UI.
- NPCs bewerben sich nicht per UI, sondern durch den Daemon.
- Es darf gleichzeitig mehrere Bewerber für dieselbe Position geben.
- Eine Bewerbung ist immer regionsbezogen.
### 2.2 Hierarchie
Kirchenämter bleiben über `church_office_type.hierarchy_level` geordnet.
Der normale Aufstiegspfad ist:
1. `lay-preacher`
2. `village-priest`
3. `parish-priest`
4. `dean`
5. `archdeacon`
6. `bishop`
7. `archbishop`
8. `cardinal`
9. `pope`
### 2.3 Bewerbung auf höhere Ämter
Der aktuelle Fehler "man kann sich nicht auf höhere Positionen bewerben als man gerade hat" soll ersetzt werden durch:
- Ein Charakter darf sich auf das nächsthöhere sinnvolle Amt bewerben.
- Zusätzlich darf ein Charakter sich auf ein höheres Amt bewerben, wenn sein bisher höchstes Kirchenamt die Mindestvoraussetzung erfüllt.
- Das System soll nicht nur aktuelle Ämter, sondern auch die bisher höchste kirchliche Laufbahn berücksichtigen.
Daraus folgt:
- Es reicht nicht, nur aktuelle `church_office` zu prüfen.
- Es muss ein Konzept von `highestChurchOfficeRankEver` geben.
## 3. Entscheidungsmodell für Bewerbungen
### 3.1 Grundsatz
Über eine Bewerbung entscheidet immer das direkt übergeordnete Amt.
Beispiele:
- Über `village-priest` entscheidet `parish-priest`.
- Über `parish-priest` entscheidet `dean`.
- Über `dean` entscheidet `archdeacon`.
### 3.2 Wenn der direkte Vorgesetzte fehlt
Falls das direkt übergeordnete Amt in der relevanten Aufsichtskette nicht besetzt ist:
- Das System sucht das nächsthöhere besetzte Amt.
- Falls überhaupt kein höheres Amt vorhanden ist, greift ein Interimsmodus.
Interimsmodus:
- Für die untersten Ebenen darf der Daemon nach Reputation und Eignung direkt besetzen.
- Für hohe Ämter oberhalb von `bishop` soll das nur sehr zurückhaltend geschehen.
## 4. NPC-Bewerbungen
### 4.1 Ziel
NPCs sollen das Kirchensystem lebendig halten und offene Ämter nach und nach füllen.
### 4.2 Wann NPCs sich bewerben
Der Daemon prüft täglich:
- offene Sitze pro Region und Amt
- vorhandene Spielerbewerbungen
- vorhandene NPC-Kandidaten
NPC-Bewerbungen entstehen bevorzugt wenn:
- ein Amt offen ist
- keine ausreichende Zahl an Bewerbungen existiert
- in der Region oder der Elternregion geeignete NPCs vorhanden sind
### 4.3 Geeignete NPCs
Ein NPC ist grundsätzlich geeignet, wenn:
- er lebt
- er nicht bereits ein gleiches oder höheres unvereinbares Kirchenamt innehat
- sein bisher höchstes Kirchenamt oder seine bisherige Laufbahn die Stufe plausibel macht
- seine Reputation ausreichend ist
Zusätzliche Faktoren für NPC-Eignung:
- Alter
- Gesundheit
- Adelstitel
- Reputation
- bestehendes Kirchenamt
- bisher höchstes Kirchenamt
## 5. Auswahl- und Beförderungslogik
### 5.1 Bewertungswert
Für jede Bewerbung wird ein Score berechnet:
`churchCandidateScore`
Bestandteile:
- bisher höchstes Kirchenamt
- aktuelles Kirchenamt
- Reputation
- Adelstitel
- Alter in idealem Bereich
- regionale Nähe
- ggf. geringe Bonuspunkte für lange Wartezeit
### 5.2 Entscheidung durch Spieler
Wenn der zuständige Vorgesetzte ein Spielercharakter ist:
- Die Bewerbung erscheint wie bisher in der UI.
- Der Spieler kann annehmen oder ablehnen.
- Solange eine Spielerentscheidung aussteht, entscheidet der Daemon nicht automatisch.
Optionaler Timeout:
- Nach längerer Untätigkeit darf später ein automatischer Verfall oder eine automatische Daemon-Entscheidung ergänzt werden.
- Das ist nicht Teil der ersten Ausbaustufe.
### 5.3 Entscheidung durch NPC
Wenn der zuständige Vorgesetzte ein NPC ist:
- Der Daemon entscheidet automatisch.
- Maßgeblich ist primär der Bewerber-Score.
- Zusätzlich wirkt die Reputation des NPC-Vorgesetzten als "Strengefaktor".
## 6. Reputation des NPC-Vorgesetzten
Wenn ein NPC ein Amt innehat, entscheidet er über die unter ihm liegende Position anhand von Reputation.
Das bedeutet:
- Ein angesehener NPC-Vorgesetzter bevorzugt reputationsstarke, standesgemäße und stabile Bewerber.
- Ein schwacher oder verrufener NPC-Vorgesetzter entscheidet unberechenbarer.
Empfohlenes Modell:
- `supervisorInfluence = supervisor.reputation / 100`
- je höher dieser Wert, desto stärker zählt der objektive Bewerber-Score
- bei niedriger Reputation steigt der Zufallsanteil
Praktische Wirkung:
- Hohe NPC-Reputation:
- bessere, berechenbarere Besetzung
- Niedrige NPC-Reputation:
- mehr Fehlbesetzungen
- mehr schwankende Entscheidungen
## 7. Fehlende historische Kirchenlaufbahn
Damit ein Charakter sich später auf höhere Ämter bewerben kann, braucht das System mehr als nur aktuelle `church_office`.
Es wird deshalb ein persistierter Höchstwert benötigt:
- `highestChurchOfficeRankEver`
Empfehlung:
- eigener Wert am Charakter oder in einer Laufbahntabelle
- beim erstmaligen Erreichen eines höheren Kirchenamts aktualisieren
- bei Verlust des Amts nicht zurücksetzen
Ohne diesen Wert bleibt höherer Aufstieg nach Amtsverlust oder Umstrukturierung unzuverlässig.
## 8. Verfügbarkeit in der UI
Die UI soll später drei Dinge klar darstellen:
- aktuelle Ämter
- verfügbare Bewerbungen
- eigene höchste Kirchenlaufbahn
Zusätzlich sinnvoll:
- ob die Entscheidung durch einen Spieler oder NPC getroffen wird
- wer der zuständige Vorgesetzte ist
- ob eine Position automatisch nachbesetzt wird
## 9. Daemon-Aufgaben
Der Daemon soll täglich folgende Schritte ausführen:
### 9.1 Kirchenlage erfassen
- offene Sitze je `church_office_type` und Region zählen
- aktuelle Amtsträger laden
- Spielerbewerbungen laden
- NPC-Kandidaten bestimmen
### 9.2 NPC-Bewerbungen erzeugen
- für vakante Positionen fehlende NPC-Bewerbungen anlegen
- keine Doppelbewerbungen für dieselbe Position erzeugen
### 9.3 Bewerbungen bewerten
- Bewerber-Score berechnen
- zuständigen Vorgesetzten ermitteln
- falls NPC-Vorgesetzter: Entscheidung automatisch treffen
- falls Spieler-Vorgesetzter: Bewerbung offen lassen
### 9.4 Beförderungen und Besetzungen durchführen
- `church_office` anlegen oder aktualisieren
- alte widersprechende Bewerbungen schließen
- `highestChurchOfficeRankEver` aktualisieren
### 9.5 Sonderfall komplett leere Hierarchie
Wenn eine Hierarchiestufe samt Vorgesetzten fehlt:
- untere Ebene darf durch den Daemon interimistisch mit dem besten Kandidaten besetzt werden
- dies soll selten und regelgeleitet geschehen
- für hohe Spitzenämter deutlich restriktiver als für niedrige Ämter
## 10. Event-Kommunikation zwischen Daemon und UI
Neue oder präzisierte Events:
### 10.1 `falukantUpdateChurch`
```json
{
"event": "falukantUpdateChurch",
"user_id": 123,
"reason": "applications"
}
```
Zulässige `reason`-Werte:
- `applications`
- `appointment`
- `promotion`
- `vacancy_fill`
- `npc_decision`
### 10.2 UI-Reaktion
- `applications`:
- Bewerbungslisten neu laden
- `appointment`:
- aktuelle Ämter und verfügbare Ämter neu laden
- `promotion`:
- aktuelle Ämter, verfügbare Ämter, ggf. Sozialstatus/Ansehen neu laden
- `vacancy_fill`:
- aktuelle Ämter und verfügbare Positionen neu laden
- `npc_decision`:
- supervised applications und current positions neu laden
Zusätzlich kann weiterhin `falukantUpdateStatus` gesendet werden.
## 11. Backend-Anpassungen außerhalb des Daemons
Die Daemon-Logik allein reicht nicht. Das Backend muss angepasst werden:
- `getAvailableChurchPositions()` darf nicht nur aktuelle Ämter als Voraussetzung ansehen
- es muss die bisher höchste Kirchenlaufbahn berücksichtigen
- freie Positionen dürfen nicht nur an schon exakt lineare Amtshalter gebunden sein
- Spielerbewerbungen und NPC-Bewerbungen müssen dieselbe Bewertungslogik unterstützen
## 12. Empfohlene Umsetzung in Phasen
### Phase C1
- Konzept `highestChurchOfficeRankEver` einführen
- `getAvailableChurchPositions()` auf höchste Kirchenlaufbahn erweitern
- UI lesbar machen
### Phase C2
- NPC-Bewerbungen im Daemon
- automatische NPC-Entscheidungen
### Phase C3
- Interimsbesetzung für leere Hierarchien
- Feintuning von Reputation und Zufall
## 13. Wichtige Designentscheidungen
- Spieleraufstieg bleibt antragsbasiert
- NPCs füllen das System aktiv auf
- hohe Reputation eines NPC-Vorgesetzten verbessert die Besetzungsqualität
- höhere Ämter sollen auch dann erreichbar bleiben, wenn der Charakter das Voramt nicht mehr aktuell innehat
- komplett leere Kirchenstrukturen dürfen sich wieder aufbauen
## 14. Offene Punkte
- Wo genau `highestChurchOfficeRankEver` gespeichert wird
- ob es zusätzlich `highestChurchOfficeTypeEver` geben soll
- ob automatische NPC-Entscheidungen ein Timeout für offene Spielerbewerbungen bekommen
- wie stark Reputation gegenüber Adelstitel und Alter gewichtet wird

View File

@@ -0,0 +1,403 @@
# Falukant: Schuldturm, Pfändung und wirtschaftlicher Zusammenbruch
Dieses Dokument beschreibt das Zielmodell für den **Schuldturm** in Falukant. Ausgangspunkt ist das bestehende Kreditsystem mit `credit` und dem bereits vorhandenen, aber noch ungenutzten Datenmodell `debtors_prism`.
## 1. Bestandsaufnahme
Bereits vorhanden:
- Kredite in `falukant_data.credit`
- `amount`
- `remaining_amount`
- `interest_rate`
- `falukant_user_id`
- Bankübersicht in [BankView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/falukant/BankView.vue)
- Modell `falukant_data.debtors_prism` über [debtors_prism.js](/mnt/share/torsten/Programs/YourPart3/backend/models/falukant/data/debtors_prism.js)
- Kreditaufnahme und Bankübersicht im Backend in [falukantService.js](/mnt/share/torsten/Programs/YourPart3/backend/services/falukantService.js)
Noch nicht vorhanden:
- fällige Kreditraten mit Verzug
- automatische Mahnlogik
- echte Schuldturm-Logik
- Pfändung / Verwertung von Vermögen
- Reputations- und Sozialfolgen
- Beziehungsfolgen für Liebhaber/Mätressen
- UI für Haftstatus / wirtschaftlichen Zusammenbruch
Wichtig:
- `debtors_prism` existiert bereits, ist aber funktional bisher nicht eingebunden.
- Ein Teil der eigentlichen Tick-Logik gehört in den externen Daemon.
- Das Backend muss dennoch Datenmodell, APIs und UI-Basis bereitstellen.
## 2. Kernidee
Wer seine Kreditverpflichtungen **über 3 Tage** nicht bedient, kommt in den **Schuldturm**.
Schuldturm bedeutet:
- Verlust wirtschaftlicher Handlungsfähigkeit
- staatliche / herrschaftliche Pfändung
- Zwangsverwertung veräußerbarer Güter
- sozialer und familiärer Absturz
Das System soll nicht nur eine Geldstrafe sein, sondern ein spürbarer Statuswechsel im Spiel.
## 3. Auslöser
### 3.1 Kreditverzug
Der Daemon prüft täglich:
- welche Kreditrate fällig war
- ob sie bedient wurde
- wie viele Verzugstage bestehen
Regel:
- `missed_days >= 3` bei mindestens einem aktiven Kredit
- danach Eintritt in den Schuldturm
### 3.2 Verhältnis zu Bankrott
Schuldturm ist der **harte Bankrottpfad für private Kreditverschuldung**.
Das bedeutet:
- nicht jeder Geldmangel führt sofort in den Schuldturm
- aber anhaltender Kreditverzug schon
Bankrott und Schuldturm können später getrennt modelliert werden:
- `wirtschaftlicher Bankrott`
- `privater Kreditverzug / Schuldturm`
Für die erste Stufe dürfen sie aber gekoppelt sein.
## 4. Zustand "im Schuldturm"
Ein Charakter im Schuldturm hat:
- kein normales wirtschaftliches Standing
- stark eingeschränkten Zugriff auf Vermögen
- massive Reputations- und Standesfolgen
Empfohlene Effekte:
- keine neuen Kredite
- keine neuen großen Investitionen
- keine Standeserhöhung
- keine neuen prestigeträchtigen Ämter
- evtl. eingeschränkte politische / kirchliche Karriere
## 5. Pfändungsreihenfolge
Beim Eintritt in den Schuldturm oder im Anschluss über mehrere Ticks wird Vermögen verwertet.
Empfohlene Reihenfolge:
1. frei verfügbares Geld
2. Transportmittel / Fahrzeuge
3. Lagerbestände / verwertbare Waren
4. Häuser / Hausbesitz
5. Schließung von Standorten / Niederlassungen
Wichtig:
- Nicht alles muss in einem Tick geschehen.
- Sinnvoll ist ein mehrstufiger Abbau, damit die UI den Prozess sichtbar machen kann.
## 6. Verwertbare Güter
### 6.1 Fahrzeuge
Transportmittel sollen verkauft werden, sofern sie nicht unpfändbar markiert sind.
Folgen:
- geringere Handlungsfähigkeit
- weniger Handelsoptionen
### 6.2 Lager und Waren
Lagerbestände und handelbare Waren sollen mit Abschlag verwertet werden.
Ziel:
- offene Kreditschuld reduzieren
- laufende Produktion destabilisieren
### 6.3 Haus
Das Haus soll gepfändet werden, wenn die Schuld nicht anders gedeckt werden kann.
Folgen:
- Rückfall auf ein niedrigeres Haus
- Einbruch bei Hauszustand, Hausstand und Dienerschaft
- negative Effekte auf Ehe, Haushalt und Stand
### 6.4 Niederlassungen
Standorte sollen geschlossen werden können, wenn Fahrzeuge/Waren/Haus nicht ausreichen.
Empfehlung:
- zuerst unrentable oder niedrigwertige Niederlassungen
- danach teurere / prestigeträchtigere
## 7. Soziale Folgen
### 7.1 Reputation
Beim Eintritt in den Schuldturm:
- spürbarer einmaliger Reputationsverlust
Während der Haft:
- täglicher oder periodischer weiterer Malus
### 7.2 Kreditwürdigkeit
Es braucht einen eigenen Zustand oder Wert:
- `creditworthiness`
oder
- `credit_penalty_level`
Folgen:
- geringere `availableCredit`
- höhere Gebühren
- evtl. komplette Kreditsperre für längere Zeit
### 7.3 Liebhaber / Mätressen
Liebhaber/Mätressen können abspringen.
Wirkung:
- hohe Chance bei geringer Zuneigung oder niedriger Finanzierung
- höhere Chance bei öffentlich gewordenem Schuldturm
- repräsentative Beziehungen brechen eher bei massivem Statusverlust
Mögliche Folgen:
- Beziehungsende
- starke Senkung von `affection`
- Sichtbarkeit eines Skandals
### 7.4 Ehe und Familie
Der Schuldturm soll auch auf Ehe und Hausfrieden wirken:
- `marriage_satisfaction` sinkt
- `household_tension_score` steigt
- Kinder-/Erbpfad kann instabiler werden
## 8. Bezug zu bereits existierenden Systemen
Der Schuldturm soll sich an bestehende Falukant-Systeme ankoppeln:
- Kredite
- Haus / Dienerschaft
- Familie / Liebschaften
- Reputation
- Produktionszertifikat
- Sozialstatus
### 8.1 Produktionszertifikat
Bankrott / Schuldturm kann ein Sonderfall für Zertifikatsverlust sein.
Das passt zur bereits dokumentierten Regel:
- Herabstufung bei `Bankrott`
### 8.2 Sozialstatus
Während oder nach schwerem Schuldturm:
- kein Aufstieg im Stand
- evtl. spätere Herabstufung im Extremfall
Für die erste Stufe reicht:
- Aufstieg blockieren
## 9. Daemon-Aufgaben
Der externe Daemon soll:
### 9.1 täglich prüfen
- fällige Kreditraten
- bezahlte / unbezahlte Beträge
- Verzugstage je Kredit oder Nutzer
### 9.2 Schuldturm auslösen
Wenn Verzug >= 3 Tage:
- Schuldturmstatus setzen
- Reputations- und Kreditwürdigkeits-Malus anwenden
- Socket-Events senden
### 9.3 Verwertung durchführen
In geordneter Reihenfolge:
- Geld abbuchen
- Fahrzeuge verkaufen
- Waren verwerten
- Häuser pfänden
- Niederlassungen schließen
### 9.4 Familienfolgen anwenden
- Ehe verschlechtern
- Haushaltsspannung erhöhen
- Liebschaften destabilisieren
## 10. Event-Kommunikation zwischen Daemon und UI
Neue Events:
### 10.1 `falukantUpdateDebt`
```json
{
"event": "falukantUpdateDebt",
"user_id": 123,
"reason": "delinquency"
}
```
Mögliche `reason`:
- `delinquency`
- `debtors_prison_entered`
- `asset_seizure`
- `branch_closure`
- `vehicle_liquidation`
- `house_seizure`
- `debtors_prison_released`
### 10.2 UI-Reaktion
- Bankansicht neu laden
- Haus neu laden
- Niederlassungen neu laden
- Statusbar / Dashboard neu laden
- Familienansicht ggf. neu laden
Zusätzlich sinnvoll:
- Toast für Eintritt in den Schuldturm
- Toast für Pfändung / Zwangsverkauf
## 11. Backend-Aufgaben außerhalb des Daemons
Das Backend muss:
- Schuldturmstatus lesbar machen
- Bankansicht um Verzug / Haftstatus erweitern
- veräußerbare Güter für den Daemon eindeutig bereitstellen
- Endpunkte und UI-Infos für den Schuldturm liefern
### 11.1 Datenmodell
Da `debtors_prism` bereits existiert, bietet sich dieses Modell an für:
- `character_id`
- `entered_at`
- `released_at`
- `status`
- `debt_at_entry`
- `remaining_debt`
- `reason`
Falls die Tabelle noch nur `character_id` enthält, muss sie erweitert werden.
### 11.2 Bank-API
Die Bankübersicht soll später zusätzlich liefern:
- `inDebtorsPrison`
- `daysOverdue`
- `nextForcedAction`
- `creditworthiness`
## 12. UI-Anforderungen
### 12.1 Bank
In [BankView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/falukant/BankView.vue):
- Hinweis auf Zahlungsverzug
- Warnstufe bei 1 / 2 / 3 Tagen
- eigener Block für Schuldturmstatus
### 12.2 Übersicht / Status
In Falukant-Overview / StatusBar:
- sichtbarer Status "Schuldturm"
- evtl. reduzierter Handlungsstatus
### 12.3 Haus / Niederlassungen
- Hinweise bei Pfändung / Zwangsverkauf
- Schließungsereignisse sichtbar machen
### 12.4 Familie
- Hinweise auf abgesprungene Liebhaber / Mätressen
- Auswirkungen auf Ehe / Haushalt sichtbar
## 13. Empfohlene Umsetzung in Phasen
### Phase D1: Basis
- `debtors_prism` fachlich ausbauen
- Bank-API um Verzug und Haftstatus erweitern
- UI-Warnungen in Bank und Status
### Phase D2: Verwertung
- Fahrzeuge, Waren und Häuser als verwertbare Assets modellieren
- Daemon führt Pfändung schrittweise aus
### Phase D3: Soziale Folgen
- Reputation
- Kreditwürdigkeit
- Liebhaber / Mätressen
- Ehe / Hausfrieden
### Phase D4: Langfristige Folgen
- Produktionszertifikat
- Stand / Karriereblockaden
- eventuelle spätere Herabstufung
## 14. Offene Punkte
- genaue Kreditratenlogik im Daemon
- wie stark Häuser und Niederlassungen mit Abschlag verkauft werden
- ob Schuldturm zeitlich begrenzt oder rein schuldgetrieben endet
- ob Kreditwürdigkeit als eigener numerischer Wert gespeichert wird
## 15. Empfehlung
Für die erste echte Umsetzung:
1. `debtors_prism` ausbauen
2. Verzugstage im Daemon sauber pflegen
3. Eintritt in den Schuldturm sichtbar machen
4. zuerst Fahrzeuge/Waren/Haus, erst danach Niederlassungen
So bleibt der Spielzustand hart, aber nachvollziehbar und technisch gut integrierbar.

View File

@@ -0,0 +1,447 @@
# Falukant: Schuldturm und Pfändung - Daemon-Spezifikation
Dieses Dokument beschreibt die Umsetzung des **Schuldturm-Systems** im externen Daemon.
Wichtig:
- Die projektseitigen DB-Felder, API-Erweiterungen, UI-Warnungen und Aktionssperren sind bereits umgesetzt.
- Der Daemon ist die führende Quelle für:
- Verzugstage
- Eintritt in den Schuldturm
- Pfändung und Verwertung
- soziale Folgen
- Freilassung
## 1. Bereits vorhandene Datenbasis
Bereits im Projekt vorhanden:
- `falukant_data.credit`
- `falukant_data.debtors_prism`
- `falukant_data.user_house`
- inkl. `household_tension_score`
- inkl. `household_tension_reasons_json`
- Familien-/Liebschaftsdaten in:
- `falukant_data.relationship`
- `falukant_data.relationship_state`
- `falukant_data.child_relation`
Bereits erweitert:
- `debtors_prism.status`
- `debtors_prism.entered_at`
- `debtors_prism.released_at`
- `debtors_prism.debt_at_entry`
- `debtors_prism.remaining_debt`
- `debtors_prism.days_overdue`
- `debtors_prism.reason`
- `debtors_prism.creditworthiness_penalty`
- `debtors_prism.next_forced_action`
- `debtors_prism.assets_seized_json`
- `debtors_prism.public_known`
Es sind für den Daemon derzeit keine weiteren DB-Änderungen nötig.
## 2. Grundregel
Ein Charakter kommt in den Schuldturm, wenn:
- mindestens ein aktiver Kredit offen ist
- fällige Kreditbedienung ausbleibt
- und `days_overdue >= 3`
Der Daemon prüft dies im Daily-Tick.
## 3. Zustände
`debtors_prism.status` verwendet mindestens:
- `delinquent`
- `imprisoned`
- `released`
Bedeutung:
- `delinquent`: Kreditverzug, aber noch nicht im Schuldturm
- `imprisoned`: im Schuldturm, Verwertung läuft
- `released`: historischer abgeschlossener Fall
## 4. Daily-Tick
Der Daily-Tick prüft pro Falukant-Nutzer:
1. aktive Kredite
2. verbleibende Schuld
3. geleistete Bedienung seit letztem Tick
4. neue Verzugstage
5. Schuldturm-Eintritt
6. laufende soziale Folgen
7. Verwertungsschritt
### 4.1 Verzugstage
Regel:
- wenn offene Schuld vorhanden und fällige Bedienung ausbleibt:
- `days_overdue += 1`
- wenn Kreditpflicht erfüllt wurde:
- `days_overdue = 0`
- falls nicht im Schuldturm
Wenn noch kein aktiver `debtors_prism`-Eintrag existiert:
- bei erstem Verzug `debtors_prism` anlegen mit
- `status = 'delinquent'`
- `days_overdue = 1`
- `remaining_debt = aktuelle offene Schuld`
- `next_forced_action = 'reminder'`
### 4.2 Warnstufen
Bei Verzug:
- Tag 1:
- `next_forced_action = 'reminder'`
- Event `falukantUpdateDebt` mit `reason: 'delinquency'`
- Tag 2:
- `next_forced_action = 'final_warning'`
- Event `falukantUpdateDebt` mit `reason: 'delinquency'`
- Tag 3:
- Schuldturm-Eintritt
Für Warnstufen senden:
- `falukantUpdateDebt`
- zusätzlich `falukantUpdateStatus`
## 5. Eintritt in den Schuldturm
Bei `days_overdue >= 3`:
- `status = 'imprisoned'`
- `entered_at = now()`
- `released_at = null`
- `debt_at_entry = aktuelle offene Schuld`
- `remaining_debt = aktuelle offene Schuld`
- `reason = 'credit_default'`
- `creditworthiness_penalty += 45`
- `next_forced_action = 'asset_seizure'`
- `public_known = true`
### 5.1 Sofortfolgen bei Eintritt
Einmalig anwenden:
- Reputation deutlich senken
- Empfehlung: `-12`
- `marriage_satisfaction` senken
- Empfehlung: `-10`
- `household_tension_score` erhöhen
- Empfehlung: `+15`
- `household_tension_reasons_json` um `debtorsPrison` ergänzen
Zusätzlich:
- aktive Liebhaber/Mätressen sichtbar destabilisieren
- mindestens `affection -= 4`
- Kreditaufnahme und aktive Falukant-Aktionen bleiben projektseitig bereits gesperrt
Bei Eintritt senden:
- `falukantUpdateDebt`
- `reason: 'debtors_prison_entered'`
- `falukantUpdateStatus`
- `falukantUpdateFamily`
- `reason: 'daily'`
- `falukantHouseUpdate`
- `falukantBranchUpdate`
## 6. Verwertung / Pfändung
Die Verwertung läuft nicht alles auf einmal, sondern schrittweise pro Tick.
Reihenfolge:
1. freies Geld
2. Fahrzeuge
3. Waren / Lagerbestände
4. Haus
5. Niederlassungen
Ziel:
- `remaining_debt` schrittweise senken
- Fortschritt im UI sichtbar machen
### 6.1 Geld
Wenn `falukant_user.money > 0`:
- direkt zur Schuld tilgen
- `remaining_debt -= eingezogener_betrag`
Events:
- `falukantUpdateDebt`
- `reason: 'asset_seizure'`
- `falukantUpdateStatus`
### 6.2 Fahrzeuge
Verkaufe zuerst:
- freie Fahrzeuge
- dann weniger wertvolle Typen
- keine Fahrzeuge in aktiven Transporten im selben Tick anfassen, falls technisch problematisch
Erlös:
- Empfehlung: `vehicle_type.cost * condition_factor * 0.55`
Zusätzlich in `assets_seized_json` protokollieren:
- Typ
- Anzahl
- Erlös
Events:
- `falukantUpdateDebt`
- `reason: 'vehicle_liquidation'`
- `falukantUpdateStatus`
### 6.3 Waren / Lager
Verwertbare Güter:
- Lagerbestände
- Inventar
- handelbare Waren
Erlös:
- Empfehlung: Marktwert mit Abschlag von `35% bis 50%`
Events:
- `falukantUpdateDebt`
- `reason: 'asset_seizure'`
- `falukantUpdateStatus`
- `falukantBranchUpdate`
### 6.4 Haus
Wenn Restschuld nach Geld/Fahrzeugen/Waren weiter hoch ist:
- Haus pfänden
- Spieler auf niedrigeres Haus oder Minimalhaus zurücksetzen
- Dienerschaft reduzieren
- `household_order` senken
Events:
- `falukantUpdateDebt`
- `reason: 'house_seizure'`
- `falukantHouseUpdate`
- `falukantUpdateStatus`
- `falukantUpdateFamily`
- `reason: 'daily'`
### 6.5 Niederlassungen
Wenn weiter nicht gedeckt:
- Niederlassungen schließen
- zuerst niedrige Stufe / niedriger Wert
- Hauptniederlassung nur als letzter Schritt
Events:
- `falukantUpdateDebt`
- `reason: 'branch_closure'`
- `falukantBranchUpdate`
- `falukantUpdateStatus`
## 7. Laufende soziale Folgen im Schuldturm
Solange `status = 'imprisoned'`:
- täglicher Reputationsmalus
- Empfehlung: `-2`
- zusätzliche `creditworthiness_penalty += 1` pro Tag
- `marriage_satisfaction -= 1`
- `household_tension_score += 2`
Wenn aktive Liebschaften bestehen:
- `affection -= 2`
- bei niedriger Zuneigung oder hoher Sichtbarkeit kann Beziehung enden
Empfohlene Absprungregel:
- wenn `affection <= 30` oder `months_underfunded >= 2`
- Chance auf Beziehungsende prüfen
- bei repräsentativen Beziehungen zusätzlich höhere Absprungchance, wenn `public_known = true`
Events bei sozialen Folgewirkungen:
- `falukantUpdateFamily`
- `reason: 'daily'`
- zusätzlich `falukantUpdateStatus`
## 8. Kreditwürdigkeit
Die UI rechnet bereits aus `creditworthiness_penalty` und Status einen sichtbaren Wert.
Der Daemon muss pflegen:
- `creditworthiness_penalty`
- `status`
- `days_overdue`
Empfehlung:
- Eintritt Schuldturm: `+45`
- pro weiterem Hafttag: `+1`
- Hauspfändung: zusätzlich `+10`
- Niederlassungsschließung: zusätzlich `+8`
## 9. Freilassung
Freilassung, wenn:
- keine relevante Restschuld mehr offen ist
oder
- ein definierter Restwert unterschritten wird, falls ihr einen Bagatellgrenzwert wollt
Dann:
- `status = 'released'`
- `released_at = now()`
- `next_forced_action = null`
- `days_overdue = 0`
- `remaining_debt = 0`
Events:
- `falukantUpdateDebt`
- `reason: 'debtors_prison_released'`
- `falukantUpdateStatus`
- `falukantUpdateFamily`
- `reason: 'daily'`
- `falukantHouseUpdate`
- `falukantBranchUpdate`
Keine automatische vollständige soziale Heilung:
- Reputation bleibt reduziert
- Kreditwürdigkeit bleibt reduziert
- Familie/Haus bleiben in Folgezuständen
## 10. Event-Kommunikation zur UI
Der Daemon sendet als Primärevent:
```json
{
"event": "falukantUpdateDebt",
"user_id": 123,
"reason": "delinquency"
}
```
Mögliche `reason`:
- `delinquency`
- `debtors_prison_entered`
- `asset_seizure`
- `vehicle_liquidation`
- `house_seizure`
- `branch_closure`
- `debtors_prison_released`
### 10.1 Begleitevents
Je nach Folge zusätzlich:
- `falukantUpdateStatus`
- `falukantHouseUpdate`
- `falukantBranchUpdate`
- `falukantUpdateFamily`
### 10.2 Empfohlene Minimalregeln
- `delinquency`:
- `falukantUpdateDebt`
- `falukantUpdateStatus`
- `debtors_prison_entered`:
- `falukantUpdateDebt`
- `falukantUpdateStatus`
- `falukantUpdateFamily`
- `falukantHouseUpdate`
- `falukantBranchUpdate`
- `asset_seizure`:
- `falukantUpdateDebt`
- `falukantUpdateStatus`
- optional `falukantBranchUpdate`
- `vehicle_liquidation`:
- `falukantUpdateDebt`
- `falukantUpdateStatus`
- `house_seizure`:
- `falukantUpdateDebt`
- `falukantUpdateStatus`
- `falukantHouseUpdate`
- `falukantUpdateFamily`
- `branch_closure`:
- `falukantUpdateDebt`
- `falukantUpdateStatus`
- `falukantBranchUpdate`
- `debtors_prison_released`:
- `falukantUpdateDebt`
- `falukantUpdateStatus`
- `falukantUpdateFamily`
- `falukantHouseUpdate`
- `falukantBranchUpdate`
## 11. Idempotenz
Der Worker muss idempotent arbeiten.
Wichtig:
- Eintritt in den Schuldturm nicht mehrfach für denselben aktiven Fall auslösen
- Verwertungsschritte nur einmal je Asset anwenden
- `released` nicht erneut freisetzen
Empfehlung:
- pro Tick Transaktion
- pro Nutzer eine klare Reihenfolge
- Änderungen in `assets_seized_json` protokollieren
## 12. Mindestumsetzung für Version 1
Pflicht:
1. Verzugstage pflegen
2. Eintritt nach 3 Tagen
3. Status und Penalty schreiben
4. Geld zuerst einziehen
5. danach Fahrzeuge
6. Events senden
Danach:
7. Hauspfändung
8. Niederlassungsschließung
9. volle Familienfolgen
## 13. Hinweis an den Daemon
Die projektseitigen Grundlagen sind bereits umgesetzt:
- `debtors_prism` ist erweitert
- Bank-/Haus-/Familien-/Übersichts-UI reagiert auf den Status
- aktive Falukant-Aktionen werden im Backend bereits gesperrt, sobald `inDebtorsPrison = true`
Der Daemon muss daher vor allem die Zustände und Folgen zuverlässig schreiben und die dokumentierten Events senden.

View File

@@ -0,0 +1,342 @@
# Falukant: Konzept für Liebhaber, Geliebte und Mätressen
## Ziel
Das Familiensystem von Falukant soll neben Ehe, Verlobung und Nachkommen auch außereheliche Bindungen abbilden. Im frühen Mittelalter sind Liebhaberinnen, Geliebte und Mätressen kein moderner Privatbereich, sondern ein sozialer, wirtschaftlicher und standesabhängiger Faktor. Das System soll deshalb:
- zur Spielwelt passen
- je nach Stand unterschiedlich bewertet werden
- Ansehen, Frömmigkeit und Familienfrieden beeinflussen
- laufende Kosten verursachen
- Stoff für Ereignisse, Skandale und Machtspiele liefern
## Grundprinzip
Eine außereheliche Beziehung ist in Falukant weder pauschal erlaubt noch pauschal verboten. Entscheidend sind:
- öffentlicher Bekanntheitsgrad
- sozialer Stand der Spielfigur
- Familienstand der Spielfigur
- gesellschaftliche Erwartung der Umgebung
- Fähigkeit, die Beziehung finanziell und politisch zu tragen
Die gleiche Beziehung kann für einen niedrigen Stand ruinös, für einen reichen Stadtadeligen unerquicklich, aber handhabbar und für einen hohen Adeligen unter Bedingungen tolerierbar sein.
## Begriffe
Für die Mechanik sollten drei Hauptformen unterschieden werden:
### Heimliche Liebschaft
- diskrete Beziehung ohne offizielle Duldung
- geringe laufende Grundkosten
- erhöhtes Skandal- und Erpressungsrisiko
- besonders gefährlich bei verheirateten Figuren
### Geliebte oder Liebhaber
- wiederkehrende, bekannte außereheliche Beziehung
- im engeren Umfeld teilweise bekannt
- mittlere Unterhaltskosten
- spürbarer Einfluss auf Ehe, Hausstand und Ansehen
### Mätresse oder Favorit
- gesellschaftlich wahrnehmbare, dauerhaft unterhaltene Beziehung
- vor allem für gehobene Stände denkbar
- hohe regelmäßige Kosten
- kann Status, Gerüchte, Neid und politische Verbindungen erzeugen
Hinweis für die Spielwelt: Für männliche und weibliche Spielfiguren soll das System symmetrisch funktionieren. Die gesellschaftliche Reaktion kann jedoch je nach Geschlecht und Stand unterschiedlich stark ausfallen.
## Standeslogik
Die Behandlung soll nicht nur von „gut oder schlecht“ abhängen, sondern vom Stand.
### Unfreie, Freie, einfache Bürger
- außereheliche Beziehungen werden schnell als Verschwendung oder Unsittlichkeit gewertet
- schon geringe Zusatzkosten können den Haushalt destabilisieren
- offenkundige Affären senken Ansehen deutlich
- Heimlichkeit ist wichtiger als Repräsentation
Typische Wirkung:
- stärkerer Ansehensverlust
- erhöhtes Risiko von Streit im Haus
- kaum gesellschaftlicher Nutzen
### Wohlhabende Bürger, Patrizier, städtische Oberschicht
- diskrete Beziehungen können geduldet werden, wenn Haushalt und Ehe nach außen stabil bleiben
- auffällige Affären schaden dem Ruf in Zünften, Rat und Nachbarschaft
- die finanzielle Belastung ist tragbar, wird aber sichtbar
Typische Wirkung:
- bei Diskretion nur mäßiger Ansehensverlust
- bei öffentlichem Bekanntwerden deutlicher Malus
- gelegentlich soziale Vorteile über Kontakte der Geliebten möglich
### Niederer Adel
- Geliebte oder Mätressen sind nicht unvorstellbar, aber müssen „standesgemäß“ geführt werden
- eine vernachlässigte Ehe oder ein niedriger sozialer Rang der Geliebten kann das Haus kompromittieren
- uneheliche Kinder oder öffentliche Kränkungen des Ehepartners schaden besonders
Typische Wirkung:
- moderate bis starke Ansehensschwankungen je nach Öffentlichkeit
- Frömmigkeit und Hausfrieden werden wichtiger
- politische Nebeneffekte möglich
### Hoher Adel
- eine diskret und kostspielig unterhaltene Mätresse kann als Ausdruck von Macht und Überfluss toleriert werden
- dieselbe Situation wird zum Skandal, wenn Haus, Kirche oder Erbfolge bedroht sind
- das Problem ist weniger die bloße Existenz als die öffentliche Unordnung
Typische Wirkung:
- geringe oder neutrale Wirkung bei geordneter Diskretion
- starker Malus bei Skandal, Erpressung, Streit mit Ehepartner oder unehelichen Erbansprüchen
- hohe Unterhaltskosten sind Pflicht, nicht Kür
## Kernwerte pro Beziehung
Jede Liebhaber-Beziehung sollte mindestens diese Werte tragen:
- `type`: heimlich, geliebt, Mätresse/Favorit
- `affection`: Zuneigung und Bindung
- `visibility`: wie bekannt die Beziehung ist
- `discretion`: wie gut sie verborgen oder kontrolliert wird
- `maintenanceLevel`: wie aufwendig die Beziehung unterhalten wird
- `monthlyCost`: laufende Kosten
- `statusFit`: passt die Beziehung zum Stand der Spielfigur
- `householdTension`: Spannungen im eigenen Haus
- `scandalRisk`: Risiko für Gerüchte, Erpressung oder Entdeckung
- `fertilityRisk`
- `politicalValue`
- `churchOffense`
- `favoredByCourt`
## Laufende Kosten
Eine außereheliche Beziehung muss regelmäßig Geld kosten. Sonst wird sie spielerisch zu billig.
### Basiskosten
- Geschenke
- Unterkunft oder Versorgung
- Kleidung und Schmuck
- Reisen, Botengänge, Treffen
### Zusätzliche Kosten bei gehobenen Formen
- eigenes Haus oder eigene Zimmer
- Dienerschaft
- Bewachung oder Diskretionsgeld
- Kleidung auf Standesniveau
- gesellschaftliche Geschenke
### Kostenlogik
Die Kosten sollen aus zwei Faktoren entstehen:
- Beziehungsform
- Stand der Spielfigur
Beispielhaft:
- Heimliche Liebschaft: niedrige Grundkosten, aber höheres Risiko
- Geliebte: mittlere planbare Kosten
- Mätresse/Favorit: hohe planbare Kosten plus mögliche Sonderausgaben
Wichtig:
- Ein hoher Adeliger darf eine Mätresse nicht billig führen.
- Wer zu wenig investiert, verliert Diskretion, Zuneigung und Ansehen.
## Wirkung auf Ansehen
Ansehen soll nicht nur einmalig sinken, sondern über Zustände beeinflusst werden.
### Positive oder neutrale Fälle
- hohe Stände
- gute Diskretion
- Ehe und Haushalt wirken stabil
- keine Erbfolgen oder offenen Kränkungen
- Geliebte steht sozial nicht völlig außerhalb des Hauses
Mögliche Wirkung:
- kein Malus
- geringer passiver Malus
- in Ausnahmefällen leichter Statusbonus als Zeichen von Überfluss und Einfluss
### Negative Fälle
- Beziehung ist öffentlich
- Spielfigur ist verheiratet
- die Ehefrau oder der Ehemann wird sichtbar gedemütigt
- die Geliebte passt nicht zum Stand
- die Kosten ruinieren den Haushalt
- die Kirche oder lokale Autoritäten greifen das Thema auf
Mögliche Wirkung:
- täglicher oder wöchentlicher Ansehensverlust
- einmalige Skandalereignisse
- höhere Kosten für Reputationspflege
- Nachteile bei Standesaufstieg
## Wirkung auf Familienleben
Das System muss spürbar mit Ehe und Haushalt verbunden sein.
### Auf die Ehe
- Ehezufriedenheit sinkt
- Streitwahrscheinlichkeit steigt
- Geschenke oder Feste für den Ehepartner können Konflikte mildern
- bei sehr hoher Spannung drohen Trennung, Rückzug oder öffentliche Kränkung
### Auf Kinder und Erbfolge
- uneheliche Kinder können später Ereignisse auslösen
- anerkannte uneheliche Kinder können Hausfrieden und Stand belasten
- je höher der Stand, desto wichtiger wird die Frage nach legitimer Erbfolge
### Auf den Familienbereich
In `FamilyView` sollte eine Liebhaber-Person nicht nur mit Name und Zuneigung erscheinen, sondern auch mit:
- Form der Beziehung
- monatlichen Kosten
- Bekanntheitsgrad
- aktuellem Einfluss auf Hausfrieden
- aktuellem Einfluss auf Ansehen
## Wirkung auf Kirche und Frömmigkeit
Für die Epoche ist die religiöse Dimension wichtig.
- Hohe Frömmigkeit plus öffentliche Affäre erzeugt stärkere Heuchelei-Strafe.
- Niedrige Frömmigkeit macht Affären sozial nicht folgenlos, kann aber kirchliche Reaktionen weniger überraschend wirken lassen.
- Kirchenspenden oder Bußhandlungen könnten später Skandale abmildern, aber nicht kostenlos neutralisieren.
## Ereignisse
Das System braucht nicht nur passive Werte, sondern Ereignisse.
### Alltägliche Ereignisse
- Wunsch nach Geschenk
- Wunsch nach besserer Unterkunft
- Streit mit Ehepartner
- Bitte um öffentliche Anerkennung
### Risikoereignisse
- Gerücht am Hof oder in der Stadt
- Erpressung durch Diener, Rivalen oder Geistliche
- Schwangerschaft oder uneheliches Kind
- Duell- oder Ehrenkonflikt
- Forderung nach Versorgung eines Kindes
### Standesereignisse
- niedrige Stände: Nachbarschaftsgerede, wirtschaftliche Belastung, häuslicher Streit
- Bürgerliche: Ratshausgerüchte, Zunftschaden, moralischer Druck
- Adel: Hofklatsch, Machtfraktionen, Belastung der Erbfolge, kirchliche Einmischung
## Spielregeln zur Balance
Damit das System interessant bleibt und nicht zur reinen Strafe oder zum Gratisbonus wird:
- maximal eine aktiv unterhaltene Mätresse/Favorit gleichzeitig
- mehrere heimliche Liebschaften sind möglich, aber das Skandalrisiko steigt stark
- hohe Kosten müssen echte Opportunitätskosten erzeugen
- Ansehen darf nicht einfach mit Geld zurückgekauft werden
- zu geringe Versorgung verschlechtert Diskretion und Beziehung
- eine Beziehung darf keinen simplen Gratisbonus auf Werte geben
## UI- und UX-Konzept
Der bestehende Bereich in [FamilyView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/falukant/FamilyView.vue) kann direkt ausgebaut werden.
### Anzeige pro Person
- Name und Titel
- Rolle: heimliche Liebschaft, Geliebte, Mätresse/Favorit
- Zuneigung
- Bekanntheitsgrad
- monatliche Kosten
- Standespassung
- aktueller Effekt auf Ansehen
- aktueller Effekt auf Hausfrieden
### Aktionen
- beschenken
- besser unterbringen
- diskret halten
- öffentlich anerkennen
- Beziehung beenden
- Versorgung reduzieren
### Hinweise
- Warnung bei drohendem Skandal
- Warnung bei unpassender Standeswahl
- Warnung bei zu geringer Versorgung
- Hinweis, wenn die Beziehung die Ehe oder den Aufstieg belastet
## Umsetzungsphasen
### Phase 1: Grundsystem
- Beziehungen vom Typ `lover` im Familienbereich sauber anzeigen
- Beziehungstypen unterscheiden
- monatliche Kosten berechnen
- passiven Einfluss auf Ansehen und Hausfrieden einführen
### Phase 2: Reibung und Entscheidungen
- Sichtbarkeit und Diskretion einführen
- Ereignisse zu Streit, Geschenkforderungen und Gerüchten
- Wechselwirkungen mit Ehe und Ansehen
### Phase 3: Tiefe Systeme
- uneheliche Kinder
- Erpressung und kirchliche Reaktionen
- politische oder hofbezogene Nebeneffekte
- Standes- und Erbfolgekonflikte
## Konkrete Empfehlungsregel für Falukant
Als Startregel für die erste spielbare Version:
- jede Liebhaber-Beziehung hat laufende Monatskosten
- jede Beziehung erzeugt je nach Stand einen passiven Ansehensmodifikator
- verheiratete Figuren erhalten zusätzlich Hausfriedensverlust
- hohe Stände können eine diskrete, gut unterhaltene Mätresse mit geringem oder neutralem Ansehensmalus führen
- niedrige und mittlere Stände tragen bei öffentlicher Affäre deutlich stärkere Nachteile
- unzureichende Versorgung erhöht pro Tick Sichtbarkeit, Streit und Skandalrisiko
Damit entsteht genau das gewünschte Spannungsfeld:
- romantisch oder politisch nützlich
- aber nie kostenlos
- gesellschaftlich nie neutral
- je nach Stand anders lesbar und anders gefährlich
## Offene Designentscheidungen
Vor der technischen Umsetzung sollten noch drei Punkte festgelegt werden:
1. Soll es einen festen Wert `householdTension` geben oder soll das über bestehende Ehe-/Familienwerte laufen?
2. Soll Frömmigkeit direkt mit dem Liebhaber-System gekoppelt werden oder erst in einer späteren Kirchenphase?
3. Sollen uneheliche Kinder bereits in Phase 1 möglich sein oder erst ab Phase 3?

View File

@@ -0,0 +1,263 @@
# Falukant: Übergabedokument für den externen Daemon
## Zweck
Dieses Dokument ist die technische Übergabe an den externen Daemon, der nicht Teil dieses Projekts ist.
Es beschreibt:
- welche Daten der Daemon lesen muss
- welche Regeln er anwenden soll
- welche Felder er zurückschreiben muss
- welche Ereignisse und Nebenwirkungen erwartet werden
Die fachlichen Regeln selbst stehen in:
- [FALUKANT_LOVERS_DAEMON_SPEC.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_SPEC.md)
Die lokale technische Datenbasis dieses Projekts steht in:
- [FALUKANT_LOVERS_TECHNICAL_CONCEPT.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_TECHNICAL_CONCEPT.md)
## Architekturgrenze
Wichtig:
- dieses Backend hält die Datenstruktur und liefert Family-/UI-Daten
- der eigentliche Tick-Lauf für Kosten, Ansehen, Ehezufriedenheit und Kinder passiert im externen Daemon
- der externe Daemon ist damit zuständig für die periodische Spiellogik
Dieses Projekt ist nicht zuständig für:
- die Scheduler-Ausführung
- Tick-Zeitpunkte
- operative Daemon-Laufzeit
## Datenquelle
Der externe Daemon arbeitet auf folgenden Tabellen:
- `falukant_data.relationship`
- `falukant_data.relationship_state`
- `falukant_data.character`
- `falukant_data.child_relation`
- `falukant_data.falukant_user`
- `falukant_type.relationship`
- `falukant_type.title`
Optional später:
- Notification-Tabellen
- Frömmigkeits- oder Kirchen-bezogene Tabellen
## Mindestdatensatz pro Tick
Für jede aktive Liebschaft muss der Daemon laden:
- `relationship.id`
- `relationship.character1_id`
- `relationship.character2_id`
- `relationship_type.tr`
- `relationship_state.lover_role`
- `relationship_state.affection`
- `relationship_state.visibility`
- `relationship_state.discretion`
- `relationship_state.maintenance_level`
- `relationship_state.status_fit`
- `relationship_state.monthly_base_cost`
- `relationship_state.months_underfunded`
- `relationship_state.active`
- `relationship_state.acknowledged`
- `relationship_state.last_daily_processed_at`
- `relationship_state.last_monthly_processed_at`
Zusätzlich pro beteiligter Figur:
- `character.id`
- `character.user_id`
- `character.gender`
- `character.birthdate`
- `character.reputation`
- `character.title_of_nobility`
Zusätzlich für Geld:
- `falukant_user.id`
- `falukant_user.money`
Zusätzlich für Ehekontext:
- aktive Beziehung vom Typ `married`, `engaged` oder `wooing`
- `relationship_state.marriage_satisfaction`
Zusätzlich für Kinderprüfung:
- bestehende `child_relation` für dieselben Eltern
## Pflichtlogik Daily Tick
Der externe Daemon muss täglich:
1. Sichtbarkeit anpassen
2. Diskretion anpassen
3. Ehezufriedenheit anpassen
4. Ansehen anpassen
5. Skandalchance prüfen
6. Zustände speichern
7. optionale Benachrichtigung oder Log-Einträge erzeugen
### Daily Input
- alle aktiven `lover`-Beziehungen
- zugehörige Ehebeziehung, falls vorhanden
- Standesgruppe
- das jüngere Alter der beiden Beteiligten `minAge`
### Daily Output
Rückzuschreiben:
- `relationship_state.visibility`
- `relationship_state.discretion`
- `relationship_state.marriage_satisfaction` der Ehebeziehung
- `character.reputation`
- `relationship_state.last_daily_processed_at`
Optional:
- Notification
- Ereignislog
## Pflichtlogik Monthly Tick
Der externe Daemon muss monatlich:
1. Monatskosten berechnen
2. Geld abbuchen
3. Unterversorgung behandeln
4. Kinderchance prüfen
5. ggf. Kind anlegen
6. Folgen auf Ansehen und Ehe anwenden
7. Zustände speichern
### Monthly Output
Rückzuschreiben:
- `falukant_user.money`
- Geldfluss-Log
- `relationship_state.months_underfunded`
- `relationship_state.affection`
- `relationship_state.discretion`
- `relationship_state.visibility`
- `relationship_state.last_monthly_processed_at`
- ggf. `child_relation`
- ggf. neuer Kind-Charakter
## Formeln
Die verbindlichen Regeln und Formeln kommen aus:
- [FALUKANT_LOVERS_DAEMON_SPEC.md](/mnt/share/torsten/Programs/YourPart3/docs/FALUKANT_LOVERS_DAEMON_SPEC.md)
Der externe Daemon soll insbesondere exakt übernehmen:
- Standesgruppen
- Monatskostenformel
- Unterversorgungsfolgen
- Ehezufriedenheitslogik
- Reputationslogik
- Altersmalus bei zu jungen Liebschaften
- Sichtbarkeits- und Diskretionslogik
- Skandalchance
- Kinderwahrscheinlichkeit
## Idempotenz
Der externe Daemon muss idempotent arbeiten.
Pflicht:
- Daily Tick nie zweimal für denselben Ingame-Tag auf dieselbe Beziehung anwenden
- Monthly Tick nie zweimal für denselben Ingame-Monat auf dieselbe Beziehung anwenden
Pflichtfelder dafür:
- `last_daily_processed_at`
- `last_monthly_processed_at`
## Transaktionsanforderungen
Folgende Monthly-Vorgänge müssen atomar laufen:
- Geldabbuchung
- Statusänderung der Liebschaft
- Kind-Erzeugung
- Folgeänderung an Ansehen oder Ehe
Empfehlung:
- pro verarbeiteter Beziehung eine DB-Transaktion
## Kind-Erzeugung
Bei erfolgreicher Monatsprüfung auf Kind:
1. neues Kind in `falukant_data.character` anlegen
2. neue `child_relation` anlegen
3. Felder setzen:
- `birth_context = lover`
- `legitimacy = hidden_bastard`
- `public_known = false`
Wenn der Daemon Kinder nicht selbst anlegen soll, muss er stattdessen ein klar definiertes Create-Event an dieses Backend oder an ein anderes Backend-Modul senden. Standardempfehlung ist aber direkte DB-Erzeugung im Daemon.
## Gleichbehandlung der Geschlechter
Der externe Daemon muss dieselben Regeln für männliche und weibliche Spielfiguren anwenden.
Das betrifft:
- Kosten
- Reputationswirkung
- Ehezufriedenheit
- Skandalrisiko
- Status- und Sichtbarkeitslogik
Unterschiedlich ist nur die biologische Kinderentstehung im aktuellen Modell.
## Was dieses Backend dafür bereitstellt
Dieses Projekt stellt aktuell bereit:
- Datenstruktur für `relationship_state`
- Datenstruktur für `child_relation`-Erweiterungen
- Family-API mit lesbaren Zuständen
Später kann dieses Backend zusätzlich bereitstellen:
- Komfort-Endpunkte für Lover-Aktionen
- Admin-/Debug-Ansichten
- eventuelle Helper-Endpoints für den Daemon
## Erwartete externe Deliverables
Damit die externe Daemon-Umsetzung vollständig ist, werden dort mindestens benötigt:
1. Daily-Tick-Job
2. Monthly-Tick-Job
3. SQL- oder ORM-Zugriff auf die Falukant-Tabellen
4. saubere Transaktionslogik
5. Schutz gegen doppelte Verarbeitung
6. Logging oder Monitoring für Tick-Fehler
## Definition of Done für die Übergabe
Die Übergabe an den externen Daemon gilt als vollständig, wenn:
1. Datenfelder und Tabellen eindeutig definiert sind
2. Daily- und Monthly-Inputs beschrieben sind
3. Daily- und Monthly-Outputs beschrieben sind
4. die verbindliche Fachlogik referenziert ist
5. Idempotenz- und Transaktionsanforderungen klar sind
6. Kinder aus Liebschaften technisch beschrieben sind

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