Compare commits
40 Commits
Redesign
...
3e6c09ab29
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e6c09ab29 | ||
|
|
f93687c753 | ||
|
|
e0c3b472db | ||
|
|
01849c8ffe | ||
|
|
2e6eb53918 | ||
|
|
09141d9e55 | ||
|
|
95c9e7c036 | ||
|
|
850a59a0b5 | ||
|
|
90385f2ee0 | ||
|
|
eb8f9c1d19 | ||
|
|
3ac4ea04d5 | ||
|
|
6be816fe48 | ||
|
|
d50d3c4016 | ||
|
|
8af726c65a | ||
|
|
44991743d2 | ||
|
|
b61a533eac | ||
|
|
de52b6f26d | ||
|
|
43dd1a3b7f | ||
|
|
22f1803e7d | ||
|
|
42e894d4e4 | ||
|
|
9b88a98a20 | ||
|
|
f2343098d2 | ||
|
|
57ab85fe10 | ||
|
|
ce36315b58 | ||
|
|
80d8caee88 | ||
|
|
b3607849d2 | ||
|
|
d901257be1 | ||
|
|
d7c59df225 | ||
|
|
f7e0d97174 | ||
|
|
2055c11fd9 | ||
|
|
f98352088e | ||
|
|
63d9aab66a | ||
|
|
5f9e0a5a49 | ||
|
|
9af974d2f2 | ||
|
|
c0f9fc8970 | ||
|
|
876ee2ab49 | ||
|
|
2977b152a2 | ||
|
|
c7d33525ff | ||
|
|
1774d7df88 | ||
|
|
2c58ef37c4 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,6 +7,7 @@ node_modules
|
||||
node_modules/*
|
||||
**/package-lock.json
|
||||
backend/.env
|
||||
backend/.env.local
|
||||
backend/images
|
||||
backend/images/*
|
||||
backend/node_modules
|
||||
|
||||
@@ -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
|
||||
// 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 {};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
21
backend/env.example
Normal 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
|
||||
5
backend/env.local.example
Normal file
5
backend/env.local.example
Normal 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
|
||||
@@ -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;
|
||||
`);
|
||||
},
|
||||
};
|
||||
@@ -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');
|
||||
`);
|
||||
},
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
};
|
||||
83
backend/migrations/20260323010000-expand-debtors-prism.cjs
Normal file
83
backend/migrations/20260323010000-expand-debtors-prism.cjs
Normal 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');
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
`);
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
63
backend/migrations/20260326001000-create-erotic-video.cjs
Normal file
63
backend/migrations/20260326001000-create-erotic-video.cjs
Normal 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' });
|
||||
},
|
||||
};
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
@@ -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(() => {});
|
||||
}
|
||||
};
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
|
||||
@@ -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},
|
||||
|
||||
64
backend/models/community/erotic_content_report.js
Normal file
64
backend/models/community/erotic_content_report.js
Normal 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;
|
||||
55
backend/models/community/erotic_video.js
Normal file
55
backend/models/community/erotic_video.js
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
141
backend/models/falukant/data/relationship_state.js
Normal file
141
backend/models/falukant/data/relationship_state.js
Normal 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;
|
||||
@@ -22,8 +22,9 @@ TownProductWorth.init({
|
||||
timestamps: false,
|
||||
underscored: true,
|
||||
hooks: {
|
||||
// Neu: 55–85 %; ältere Einträge können 40–60 % 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Underground.init({
|
||||
allowNull: false},
|
||||
victimId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false},
|
||||
allowNull: true},
|
||||
parameters: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
207
backend/scripts/apply-bisaya-course-refresh.js
Normal file
207
backend/scripts/apply-bisaya-course-refresh.js
Normal 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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
142
backend/scripts/falukant-moneyflow-report.mjs
Normal file
142
backend/scripts/falukant-moneyflow-report.mjs
Normal 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 (1–3650, 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);
|
||||
}
|
||||
103
backend/scripts/falukant-town-product-worth-stats.mjs
Normal file
103
backend/scripts/falukant-town-product-worth-stats.mjs
Normal 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);
|
||||
}
|
||||
157
backend/scripts/update-bisaya-didactics.js
Normal file
157
backend/scripts/update-bisaya-didactics.js
Normal 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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
5
backend/sql/add_adult_area_to_gallery.sql
Normal file
5
backend/sql/add_adult_area_to_gallery.sql
Normal 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;
|
||||
36
backend/sql/add_adult_content_moderation.sql
Normal file
36
backend/sql/add_adult_content_moderation.sql
Normal 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'
|
||||
);
|
||||
21
backend/sql/add_adult_verification_user_param.sql
Normal file
21
backend/sql/add_adult_verification_user_param.sql
Normal 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'
|
||||
);
|
||||
5
backend/sql/add_household_tension_to_user_house.sql
Normal file
5
backend/sql/add_household_tension_to_user_house.sql
Normal 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;
|
||||
2
backend/sql/add_is_adult_only_to_chat_room.sql
Normal file
2
backend/sql/add_is_adult_only_to_chat_room.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE chat.room
|
||||
ADD COLUMN IF NOT EXISTS is_adult_only BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
24
backend/sql/add_language_assistant_user_params.sql
Normal file
24
backend/sql/add_language_assistant_user_params.sql
Normal 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');
|
||||
88
backend/sql/add_relationship_state_and_child_legitimacy.sql
Normal file
88
backend/sql/add_relationship_state_and_child_legitimacy.sql
Normal 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;
|
||||
7
backend/sql/add_servants_to_user_house.sql
Normal file
7
backend/sql/add_servants_to_user_house.sql
Normal 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;
|
||||
@@ -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;
|
||||
5
backend/sql/add_underground_investigate_affair_type.sql
Normal file
5
backend/sql/add_underground_investigate_affair_type.sql
Normal 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;
|
||||
5
backend/sql/add_underground_raid_transport_type.sql
Normal file
5
backend/sql/add_underground_raid_transport_type.sql
Normal 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;
|
||||
14
backend/sql/add_vocab_lesson_didactics.sql
Normal file
14
backend/sql/add_vocab_lesson_didactics.sql
Normal 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;
|
||||
50
backend/sql/backfill_relationship_state.sql
Normal file
50
backend/sql/backfill_relationship_state.sql
Normal 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;
|
||||
20
backend/sql/balance_carrot_product.sql
Normal file
20
backend/sql/balance_carrot_product.sql
Normal 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';
|
||||
@@ -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
|
||||
|
||||
11
backend/sql/create_erotic_video.sql
Normal file
11
backend/sql/create_erotic_video.sql
Normal 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
|
||||
);
|
||||
31
backend/sql/diagnostics/falukant_moneyflow_by_activity.sql
Normal file
31
backend/sql/diagnostics/falukant_moneyflow_by_activity.sql
Normal 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;
|
||||
14
backend/sql/diagnostics/falukant_moneyflow_window_totals.sql
Normal file
14
backend/sql/diagnostics/falukant_moneyflow_window_totals.sql
Normal 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__;
|
||||
@@ -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;
|
||||
32
backend/sql/expand_debtors_prism.sql
Normal file
32
backend/sql/expand_debtors_prism.sql
Normal 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;
|
||||
2
backend/sql/expand_user_param_value_to_text.sql
Normal file
2
backend/sql/expand_user_param_value_to_text.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE community.user_param
|
||||
ALTER COLUMN value TYPE TEXT;
|
||||
51
backend/sql/rebalance_product_certificates.sql
Normal file
51
backend/sql/rebalance_product_certificates.sql
Normal 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;
|
||||
@@ -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
|
||||
|
||||
132
backend/sql/update_nobility_requirements_extended.sql
Normal file
132
backend/sql/update_nobility_requirements_extended.sql
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
96
backend/utils/falukant/falukantProductEconomy.js
Normal file
96
backend/utils/falukant/falukantProductEconomy.js
Normal 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 (75–100). */
|
||||
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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -659,6 +659,14 @@ const undergroundTypes = [
|
||||
"tr": "rob",
|
||||
"cost": 500
|
||||
},
|
||||
{
|
||||
"tr": "investigate_affair",
|
||||
"cost": 7000
|
||||
},
|
||||
{
|
||||
"tr": "raid_transport",
|
||||
"cost": 9000
|
||||
},
|
||||
];
|
||||
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
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: [
|
||||
|
||||
406
docs/ADULT_SOCIAL_EROTIC_CONCEPT.md
Normal file
406
docs/ADULT_SOCIAL_EROTIC_CONCEPT.md
Normal 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.
|
||||
328
docs/FALUKANT_CHURCH_ADVANCEMENT_DAEMON_SPEC.md
Normal file
328
docs/FALUKANT_CHURCH_ADVANCEMENT_DAEMON_SPEC.md
Normal 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
|
||||
|
||||
403
docs/FALUKANT_DEBTORS_PRISON_CONCEPT.md
Normal file
403
docs/FALUKANT_DEBTORS_PRISON_CONCEPT.md
Normal 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.
|
||||
|
||||
447
docs/FALUKANT_DEBTORS_PRISON_DAEMON_SPEC.md
Normal file
447
docs/FALUKANT_DEBTORS_PRISON_DAEMON_SPEC.md
Normal 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.
|
||||
342
docs/FALUKANT_LOVERS_CONCEPT.md
Normal file
342
docs/FALUKANT_LOVERS_CONCEPT.md
Normal 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?
|
||||
263
docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md
Normal file
263
docs/FALUKANT_LOVERS_DAEMON_HANDOFF.md
Normal 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
Reference in New Issue
Block a user