Compare commits
68 Commits
9d44a265ca
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71e120bf20 | ||
|
|
39032570e3 | ||
|
|
9e8f8e8077 | ||
|
|
e76be33743 | ||
|
|
6cbcf9d95f | ||
|
|
31a96aaf60 | ||
|
|
8a1ff52a61 | ||
|
|
84c598bf52 | ||
|
|
291e79c41f | ||
|
|
3b823420e6 | ||
|
|
674c4d0b69 | ||
|
|
9f3facbb3f | ||
|
|
07604cc9fa | ||
|
|
82223676a6 | ||
|
|
207ef6266a | ||
|
|
02837c7b73 | ||
|
|
25b658acce | ||
|
|
0f0c102ded | ||
|
|
26eb7b8ce7 | ||
|
|
0dd2bce5d1 | ||
|
|
cf6d72385e | ||
|
|
1a86061680 | ||
|
|
e13deb0720 | ||
|
|
21072139f7 | ||
|
|
1878b2a8c7 | ||
|
|
6563ca23c7 | ||
|
|
085333db29 | ||
|
|
17325a5263 | ||
|
|
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 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,8 +5,9 @@
|
||||
.depbe.sh
|
||||
node_modules
|
||||
node_modules/*
|
||||
**/package-lock.json
|
||||
# package-lock.json wird versioniert (npm ci im Deploy braucht konsistente Locks zu package.json)
|
||||
backend/.env
|
||||
backend/.env.local
|
||||
backend/images
|
||||
backend/images/*
|
||||
backend/node_modules
|
||||
|
||||
@@ -83,7 +83,12 @@ const corsOptions = {
|
||||
};
|
||||
|
||||
app.use(cors(corsOptions));
|
||||
app.options('*', cors(corsOptions));
|
||||
app.use((req, res, next) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return cors(corsOptions)(req, res, next);
|
||||
}
|
||||
return next();
|
||||
});
|
||||
app.use(express.json()); // To handle JSON request bodies
|
||||
|
||||
app.use('/api/chat', chatRouter);
|
||||
@@ -124,6 +129,11 @@ app.get(/^\/(?!api\/).*/, (req, res) => {
|
||||
});
|
||||
|
||||
// Fallback 404 for unknown API routes
|
||||
app.use('/api/*', (req, res) => res.status(404).send('404 Not Found'));
|
||||
app.use((req, res, next) => {
|
||||
if (req.path.startsWith('/api/')) {
|
||||
return res.status(404).send('404 Not Found');
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -7,6 +7,16 @@ import fs from 'fs';
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const quietEnv = process.env.QUIET_ENV_LOGS === '1';
|
||||
const dotenvQuiet = quietEnv || process.env.DOTENV_CONFIG_QUIET === '1';
|
||||
|
||||
function log(...args) {
|
||||
if (!quietEnv) console.log(...args);
|
||||
}
|
||||
function warn(...args) {
|
||||
console.warn(...args);
|
||||
}
|
||||
|
||||
// Versuche zuerst Produktions-.env, dann lokale .env
|
||||
const productionEnvPath = '/opt/yourpart/backend/.env';
|
||||
const localEnvPath = path.resolve(__dirname, '../.env');
|
||||
@@ -19,54 +29,68 @@ if (fs.existsSync(productionEnvPath)) {
|
||||
fs.accessSync(productionEnvPath, fs.constants.R_OK);
|
||||
envPath = productionEnvPath;
|
||||
usingProduction = true;
|
||||
console.log('[env] Produktions-.env gefunden und lesbar:', productionEnvPath);
|
||||
log('[env] Produktions-.env gefunden und lesbar:', productionEnvPath);
|
||||
} catch (err) {
|
||||
console.warn('[env] Produktions-.env vorhanden, aber nicht lesbar - verwende lokale .env stattdessen:', productionEnvPath);
|
||||
console.warn('[env] Fehler:', err && err.message);
|
||||
if (!quietEnv) {
|
||||
warn('[env] Produktions-.env vorhanden, aber nicht lesbar - verwende lokale .env stattdessen:', productionEnvPath);
|
||||
warn('[env] Fehler:', err && err.message);
|
||||
}
|
||||
envPath = localEnvPath;
|
||||
}
|
||||
} else {
|
||||
console.log('[env] Produktions-.env nicht gefunden, lade lokale .env:', localEnvPath);
|
||||
log('[env] Produktions-.env nicht gefunden, lade lokale .env:', localEnvPath);
|
||||
}
|
||||
|
||||
// Lade .env-Datei (robust gegen Fehler)
|
||||
console.log('[env] Versuche .env zu laden von:', envPath);
|
||||
console.log('[env] Datei existiert:', fs.existsSync(envPath));
|
||||
log('[env] Versuche .env zu laden von:', envPath);
|
||||
log('[env] Datei existiert:', fs.existsSync(envPath));
|
||||
let result;
|
||||
try {
|
||||
result = dotenv.config({ path: envPath });
|
||||
result = dotenv.config({ path: envPath, quiet: dotenvQuiet });
|
||||
if (result.error) {
|
||||
console.warn('[env] Konnte .env nicht laden:', result.error.message);
|
||||
console.warn('[env] Fehler-Details:', result.error);
|
||||
warn('[env] Konnte .env nicht laden:', result.error.message);
|
||||
warn('[env] Fehler-Details:', result.error);
|
||||
} else {
|
||||
console.log('[env] .env erfolgreich geladen von:', envPath, usingProduction ? '(production)' : '(local)');
|
||||
console.log('[env] Geladene Variablen:', Object.keys(result.parsed || {}));
|
||||
log('[env] .env erfolgreich geladen von:', envPath, usingProduction ? '(production)' : '(local)');
|
||||
log('[env] Geladene Variablen:', Object.keys(result.parsed || {}));
|
||||
}
|
||||
} catch (err) {
|
||||
// Sollte nicht passieren, aber falls dotenv intern eine Exception wirft (z.B. EACCES), fange sie ab
|
||||
console.warn('[env] Unerwarteter Fehler beim Laden der .env:', err && err.message);
|
||||
console.warn('[env] Stack:', err && err.stack);
|
||||
warn('[env] Unerwarteter Fehler beim Laden der .env:', err && err.message);
|
||||
warn('[env] Stack:', err && err.stack);
|
||||
if (envPath !== localEnvPath && fs.existsSync(localEnvPath)) {
|
||||
console.log('[env] Versuche stattdessen lokale .env:', localEnvPath);
|
||||
log('[env] Versuche stattdessen lokale .env:', localEnvPath);
|
||||
try {
|
||||
result = dotenv.config({ path: localEnvPath });
|
||||
result = dotenv.config({ path: localEnvPath, quiet: dotenvQuiet });
|
||||
if (!result.error) {
|
||||
console.log('[env] Lokale .env erfolgreich geladen von:', localEnvPath);
|
||||
log('[env] Lokale .env erfolgreich geladen von:', localEnvPath);
|
||||
}
|
||||
} catch (err2) {
|
||||
console.warn('[env] Konnte lokale .env auch nicht laden:', err2 && err2.message);
|
||||
warn('[env] Konnte lokale .env auch nicht laden:', err2 && err2.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Debug: Zeige Redis-Konfiguration
|
||||
console.log('[env] Redis-Konfiguration:');
|
||||
console.log('[env] REDIS_HOST:', process.env.REDIS_HOST);
|
||||
console.log('[env] REDIS_PORT:', process.env.REDIS_PORT);
|
||||
console.log('[env] REDIS_PASSWORD:', process.env.REDIS_PASSWORD ? '***gesetzt***' : 'NICHT GESETZT');
|
||||
console.log('[env] REDIS_URL:', process.env.REDIS_URL);
|
||||
// Lokale Überschreibungen (nicht committen): z. B. SSH-Tunnel DB_HOST=127.0.0.1 DB_PORT=60000
|
||||
const localOverridePath = path.resolve(__dirname, '../.env.local');
|
||||
if (fs.existsSync(localOverridePath)) {
|
||||
const overrideResult = dotenv.config({ path: localOverridePath, override: true, quiet: dotenvQuiet });
|
||||
if (!overrideResult.error) {
|
||||
log('[env] .env.local geladen (überschreibt Werte, z. B. SSH-Tunnel)');
|
||||
} else {
|
||||
warn('[env] .env.local vorhanden, aber Laden fehlgeschlagen:', overrideResult.error?.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (!quietEnv) {
|
||||
console.log('[env] Redis-Konfiguration:');
|
||||
console.log('[env] REDIS_HOST:', process.env.REDIS_HOST);
|
||||
console.log('[env] REDIS_PORT:', process.env.REDIS_PORT);
|
||||
console.log('[env] REDIS_PASSWORD:', process.env.REDIS_PASSWORD ? '***gesetzt***' : 'NICHT GESETZT');
|
||||
console.log('[env] REDIS_URL:', process.env.REDIS_URL);
|
||||
}
|
||||
|
||||
if (!process.env.SECRET_KEY) {
|
||||
console.warn('[env] SECRET_KEY nicht gesetzt in .env');
|
||||
warn('[env] SECRET_KEY nicht gesetzt in .env');
|
||||
}
|
||||
export {};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -96,9 +96,7 @@ const menuStructure = {
|
||||
},
|
||||
eroticChat: {
|
||||
visible: ["over18"],
|
||||
action: "openEroticChat",
|
||||
view: "window",
|
||||
class: "eroticChatWindow"
|
||||
action: "openEroticChat"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -214,6 +212,10 @@ const menuStructure = {
|
||||
visible: ["all"],
|
||||
path: "/settings/account"
|
||||
},
|
||||
languageAssistant: {
|
||||
visible: ["all"],
|
||||
path: "/settings/language-assistant"
|
||||
},
|
||||
personal: {
|
||||
visible: ["all"],
|
||||
path: "/settings/personal"
|
||||
@@ -254,6 +256,14 @@ const menuStructure = {
|
||||
visible: ["mainadmin", "useradministration"],
|
||||
path: "/admin/users"
|
||||
},
|
||||
adultverification: {
|
||||
visible: ["mainadmin", "useradministration"],
|
||||
path: "/admin/users/adult-verification"
|
||||
},
|
||||
eroticmoderation: {
|
||||
visible: ["mainadmin", "useradministration"],
|
||||
path: "/admin/users/erotic-moderation"
|
||||
},
|
||||
userstatistics: {
|
||||
visible: ["mainadmin"],
|
||||
path: "/admin/users/statistics"
|
||||
@@ -339,7 +349,14 @@ class NavigationController {
|
||||
return age;
|
||||
}
|
||||
|
||||
async filterMenu(menu, rights, age, userId) {
|
||||
normalizeAdultVerificationStatus(value) {
|
||||
if (['pending', 'approved', 'rejected'].includes(value)) {
|
||||
return value;
|
||||
}
|
||||
return 'none';
|
||||
}
|
||||
|
||||
async filterMenu(menu, rights, age, userId, adultVerificationStatus = 'none') {
|
||||
const filteredMenu = {};
|
||||
try {
|
||||
const hasFalukantAccount = await this.hasFalukantAccount(userId);
|
||||
@@ -353,8 +370,17 @@ class NavigationController {
|
||||
|| (value.visible.includes('hasfalukantaccount') && hasFalukantAccount)) {
|
||||
const { visible, ...itemWithoutVisible } = value;
|
||||
filteredMenu[key] = { ...itemWithoutVisible };
|
||||
if (
|
||||
value.visible.includes("over18")
|
||||
&& age >= 18
|
||||
&& adultVerificationStatus !== 'approved'
|
||||
&& (value.path || value.action || value.view)
|
||||
) {
|
||||
filteredMenu[key].disabled = true;
|
||||
filteredMenu[key].disabledReasonKey = 'socialnetwork.erotic.lockedShort';
|
||||
}
|
||||
if (value.children) {
|
||||
filteredMenu[key].children = await this.filterMenu(value.children, rights, age, userId);
|
||||
filteredMenu[key].children = await this.filterMenu(value.children, rights, age, userId, adultVerificationStatus);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -381,20 +407,29 @@ class NavigationController {
|
||||
required: false
|
||||
}]
|
||||
});
|
||||
const userBirthdateParams = await UserParam.findAll({
|
||||
const userParams = await UserParam.findAll({
|
||||
where: { userId: user.id },
|
||||
include: [
|
||||
{
|
||||
model: UserParamType,
|
||||
as: 'paramType',
|
||||
where: { description: 'birthdate' }
|
||||
where: { description: ['birthdate', 'adult_verification_status'] }
|
||||
}
|
||||
]
|
||||
});
|
||||
const birthDate = userBirthdateParams.length > 0 ? userBirthdateParams[0].value : (new Date()).toDateString();
|
||||
let birthDate = (new Date()).toDateString();
|
||||
let adultVerificationStatus = 'none';
|
||||
for (const param of userParams) {
|
||||
if (param.paramType?.description === 'birthdate' && param.value) {
|
||||
birthDate = param.value;
|
||||
}
|
||||
if (param.paramType?.description === 'adult_verification_status') {
|
||||
adultVerificationStatus = this.normalizeAdultVerificationStatus(param.value);
|
||||
}
|
||||
}
|
||||
const age = this.calculateAge(birthDate);
|
||||
const rights = userRights.map(ur => ur.rightType?.title).filter(Boolean);
|
||||
const filteredMenu = await this.filterMenu(menuStructure, rights, age, user.id);
|
||||
const filteredMenu = await this.filterMenu(menuStructure, rights, age, user.id, adultVerificationStatus);
|
||||
|
||||
// Vokabeltrainer: Sprachen werden im Frontend dynamisch geladen (wie Forum)
|
||||
// Keine children mehr, da das Menü nur 2 Ebenen unterstützt
|
||||
|
||||
@@ -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,19 @@ class SocialNetworkController {
|
||||
this.changeImage = this.changeImage.bind(this);
|
||||
this.getFoldersByUsername = this.getFoldersByUsername.bind(this);
|
||||
this.deleteFolder = this.deleteFolder.bind(this);
|
||||
this.getAdultFolders = this.getAdultFolders.bind(this);
|
||||
this.getAdultFoldersByUsername = this.getAdultFoldersByUsername.bind(this);
|
||||
this.createAdultFolder = this.createAdultFolder.bind(this);
|
||||
this.getAdultFolderImageList = this.getAdultFolderImageList.bind(this);
|
||||
this.uploadAdultImage = this.uploadAdultImage.bind(this);
|
||||
this.getAdultImageByHash = this.getAdultImageByHash.bind(this);
|
||||
this.changeAdultImage = this.changeAdultImage.bind(this);
|
||||
this.listEroticVideos = this.listEroticVideos.bind(this);
|
||||
this.getEroticVideosByUsername = this.getEroticVideosByUsername.bind(this);
|
||||
this.uploadEroticVideo = this.uploadEroticVideo.bind(this);
|
||||
this.changeEroticVideo = this.changeEroticVideo.bind(this);
|
||||
this.getEroticVideoByHash = this.getEroticVideoByHash.bind(this);
|
||||
this.reportEroticContent = this.reportEroticContent.bind(this);
|
||||
this.createGuestbookEntry = this.createGuestbookEntry.bind(this);
|
||||
this.getGuestbookEntries = this.getGuestbookEntries.bind(this);
|
||||
this.deleteGuestbookEntry = this.deleteGuestbookEntry.bind(this);
|
||||
@@ -147,8 +160,8 @@ class SocialNetworkController {
|
||||
try {
|
||||
const userId = req.headers.userid;
|
||||
const { imageId } = req.params;
|
||||
const { title, visibilities } = req.body;
|
||||
const folderId = await this.socialNetworkService.changeImage(userId, imageId, title, visibilities);
|
||||
const { title, visibilities, selectedUsers } = req.body;
|
||||
const folderId = await this.socialNetworkService.changeImage(userId, imageId, title, visibilities, selectedUsers);
|
||||
console.log('--->', folderId);
|
||||
res.status(201).json(await this.socialNetworkService.getFolderImageList(userId, folderId));
|
||||
} catch (error) {
|
||||
@@ -187,6 +200,177 @@ class SocialNetworkController {
|
||||
}
|
||||
}
|
||||
|
||||
async getAdultFolders(req, res) {
|
||||
try {
|
||||
const userId = req.headers.userid;
|
||||
const folders = await this.socialNetworkService.getAdultFolders(userId);
|
||||
res.status(200).json(folders);
|
||||
} catch (error) {
|
||||
console.error('Error in getAdultFolders:', error);
|
||||
res.status(error.status || 500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async getAdultFoldersByUsername(req, res) {
|
||||
try {
|
||||
const requestingUserId = req.headers.userid;
|
||||
const { username } = req.params;
|
||||
const folders = await this.socialNetworkService.getAdultFoldersByUsername(username, requestingUserId);
|
||||
if (!folders) {
|
||||
return res.status(404).json({ error: 'No folders found or access denied.' });
|
||||
}
|
||||
res.status(200).json(folders);
|
||||
} catch (error) {
|
||||
console.error('Error in getAdultFoldersByUsername:', error);
|
||||
res.status(error.status || 500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async createAdultFolder(req, res) {
|
||||
try {
|
||||
const userId = req.headers.userid;
|
||||
const folderData = req.body;
|
||||
const { folderId } = req.params;
|
||||
const folder = await this.socialNetworkService.createAdultFolder(userId, folderData, folderId);
|
||||
res.status(201).json(folder);
|
||||
} catch (error) {
|
||||
console.error('Error in createAdultFolder:', error);
|
||||
res.status(error.status || 500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async getAdultFolderImageList(req, res) {
|
||||
try {
|
||||
const userId = req.headers.userid;
|
||||
const { folderId } = req.params;
|
||||
const images = await this.socialNetworkService.getAdultFolderImageList(userId, folderId);
|
||||
res.status(200).json(images);
|
||||
} catch (error) {
|
||||
console.error('Error in getAdultFolderImageList:', error);
|
||||
res.status(error.status || 500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async uploadAdultImage(req, res) {
|
||||
try {
|
||||
const userId = req.headers.userid;
|
||||
const file = req.file;
|
||||
const formData = req.body;
|
||||
const image = await this.socialNetworkService.uploadAdultImage(userId, file, formData);
|
||||
res.status(201).json(image);
|
||||
} catch (error) {
|
||||
console.error('Error in uploadAdultImage:', error);
|
||||
res.status(error.status || 500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async getAdultImageByHash(req, res) {
|
||||
try {
|
||||
const userId = req.headers.userid;
|
||||
const { hash } = req.params;
|
||||
const filePath = await this.socialNetworkService.getAdultImageFilePath(userId, hash);
|
||||
res.sendFile(filePath, err => {
|
||||
if (err) {
|
||||
console.error('Error sending adult file:', err);
|
||||
res.status(500).json({ error: 'Error sending file' });
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in getAdultImageByHash:', error);
|
||||
res.status(error.status || 403).json({ error: error.message || 'Access denied or image not found' });
|
||||
}
|
||||
}
|
||||
|
||||
async changeAdultImage(req, res) {
|
||||
try {
|
||||
const userId = req.headers.userid;
|
||||
const { imageId } = req.params;
|
||||
const { title, visibilities, selectedUsers } = req.body;
|
||||
const folderId = await this.socialNetworkService.changeAdultImage(userId, imageId, title, visibilities, selectedUsers);
|
||||
res.status(201).json(await this.socialNetworkService.getAdultFolderImageList(userId, folderId));
|
||||
} catch (error) {
|
||||
console.error('Error in changeAdultImage:', error);
|
||||
res.status(error.status || 403).json({ error: error.message || 'Access denied or image not found' });
|
||||
}
|
||||
}
|
||||
|
||||
async listEroticVideos(req, res) {
|
||||
try {
|
||||
const userId = req.headers.userid;
|
||||
const videos = await this.socialNetworkService.listEroticVideos(userId);
|
||||
res.status(200).json(videos);
|
||||
} catch (error) {
|
||||
console.error('Error in listEroticVideos:', error);
|
||||
res.status(error.status || 500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async getEroticVideosByUsername(req, res) {
|
||||
try {
|
||||
const userId = req.headers.userid;
|
||||
const { username } = req.params;
|
||||
const videos = await this.socialNetworkService.getEroticVideosByUsername(username, userId);
|
||||
res.status(200).json(videos);
|
||||
} catch (error) {
|
||||
console.error('Error in getEroticVideosByUsername:', error);
|
||||
res.status(error.status || 500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async uploadEroticVideo(req, res) {
|
||||
try {
|
||||
const userId = req.headers.userid;
|
||||
const file = req.file;
|
||||
const formData = req.body;
|
||||
const video = await this.socialNetworkService.uploadEroticVideo(userId, file, formData);
|
||||
res.status(201).json(video);
|
||||
} catch (error) {
|
||||
console.error('Error in uploadEroticVideo:', error);
|
||||
res.status(error.status || 500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async changeEroticVideo(req, res) {
|
||||
try {
|
||||
const userId = req.headers.userid;
|
||||
const { videoId } = req.params;
|
||||
const updatedVideo = await this.socialNetworkService.changeEroticVideo(userId, videoId, req.body);
|
||||
res.status(200).json(updatedVideo);
|
||||
} catch (error) {
|
||||
console.error('Error in changeEroticVideo:', error);
|
||||
res.status(error.status || 500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async getEroticVideoByHash(req, res) {
|
||||
try {
|
||||
const userId = req.headers.userid;
|
||||
const { hash } = req.params;
|
||||
const { filePath, mimeType } = await this.socialNetworkService.getEroticVideoFilePath(userId, hash);
|
||||
res.type(mimeType);
|
||||
res.sendFile(filePath, err => {
|
||||
if (err) {
|
||||
console.error('Error sending adult video:', err);
|
||||
res.status(500).json({ error: 'Error sending file' });
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in getEroticVideoByHash:', error);
|
||||
res.status(error.status || 403).json({ error: error.message || 'Access denied or video not found' });
|
||||
}
|
||||
}
|
||||
|
||||
async reportEroticContent(req, res) {
|
||||
try {
|
||||
const userId = req.headers.userid;
|
||||
const result = await this.socialNetworkService.createEroticContentReport(userId, req.body || {});
|
||||
res.status(201).json(result);
|
||||
} catch (error) {
|
||||
console.error('Error in reportEroticContent:', error);
|
||||
res.status(error.status || 400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async createGuestbookEntry(req, res) {
|
||||
try {
|
||||
const { htmlContent, recipientName } = req.body;
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
'use strict';
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.createTable(
|
||||
{ schema: 'community', tableName: 'erotic_video_image_visibility' },
|
||||
{
|
||||
id: {
|
||||
type: Sequelize.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
erotic_video_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: { schema: 'community', tableName: 'erotic_video' },
|
||||
key: 'id',
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
visibility_type_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: { schema: 'type', tableName: 'image_visibility' },
|
||||
key: 'id',
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await queryInterface.createTable(
|
||||
{ schema: 'community', tableName: 'erotic_video_visibility_user' },
|
||||
{
|
||||
id: {
|
||||
type: Sequelize.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
erotic_video_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: { schema: 'community', tableName: 'erotic_video' },
|
||||
key: 'id',
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
user_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: { schema: 'community', tableName: 'user' },
|
||||
key: 'id',
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await queryInterface.sequelize.query(`
|
||||
INSERT INTO community.erotic_video_image_visibility (erotic_video_id, visibility_type_id)
|
||||
SELECT ev.id, iv.id
|
||||
FROM community.erotic_video ev
|
||||
CROSS JOIN type.image_visibility iv
|
||||
WHERE iv.description = 'adults'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM community.erotic_video_image_visibility eviv
|
||||
WHERE eviv.erotic_video_id = ev.id
|
||||
AND eviv.visibility_type_id = iv.id
|
||||
)
|
||||
`);
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
await queryInterface.dropTable({ schema: 'community', tableName: 'erotic_video_visibility_user' });
|
||||
await queryInterface.dropTable({ schema: 'community', tableName: 'erotic_video_image_visibility' });
|
||||
},
|
||||
};
|
||||
@@ -18,11 +18,15 @@ import UserParamVisibilityType from './type/user_param_visibility.js';
|
||||
import UserParamVisibility from './community/user_param_visibility.js';
|
||||
import Folder from './community/folder.js';
|
||||
import Image from './community/image.js';
|
||||
import EroticVideo from './community/erotic_video.js';
|
||||
import EroticContentReport from './community/erotic_content_report.js';
|
||||
import ImageVisibilityType from './type/image_visibility.js';
|
||||
import ImageVisibilityUser from './community/image_visibility_user.js';
|
||||
import FolderImageVisibility from './community/folder_image_visibility.js';
|
||||
import ImageImageVisibility from './community/image_image_visibility.js';
|
||||
import FolderVisibilityUser from './community/folder_visibility_user.js';
|
||||
import EroticVideoImageVisibility from './community/erotic_video_image_visibility.js';
|
||||
import EroticVideoVisibilityUser from './community/erotic_video_visibility_user.js';
|
||||
import GuestbookEntry from './community/guestbook.js';
|
||||
import Forum from './forum/forum.js';
|
||||
import Title from './forum/title.js';
|
||||
@@ -68,6 +72,7 @@ import PromotionalGiftCharacterTrait from './falukant/predefine/promotional_gift
|
||||
import PromotionalGiftMood from './falukant/predefine/promotional_gift_mood.js';
|
||||
import RelationshipType from './falukant/type/relationship.js';
|
||||
import Relationship from './falukant/data/relationship.js';
|
||||
import RelationshipState from './falukant/data/relationship_state.js';
|
||||
import PromotionalGiftLog from './falukant/log/promotional_gift.js';
|
||||
import HouseType from './falukant/type/house.js';
|
||||
import BuyableHouse from './falukant/data/buyable_house.js';
|
||||
@@ -208,6 +213,14 @@ export default function setupAssociations() {
|
||||
Image.belongsTo(User, { foreignKey: 'userId' });
|
||||
User.hasMany(Image, { foreignKey: 'userId' });
|
||||
|
||||
EroticVideo.belongsTo(User, { foreignKey: 'userId', as: 'owner' });
|
||||
User.hasMany(EroticVideo, { foreignKey: 'userId', as: 'eroticVideos' });
|
||||
|
||||
EroticContentReport.belongsTo(User, { foreignKey: 'reporterId', as: 'reporter' });
|
||||
User.hasMany(EroticContentReport, { foreignKey: 'reporterId', as: 'eroticContentReports' });
|
||||
EroticContentReport.belongsTo(User, { foreignKey: 'handledBy', as: 'moderator' });
|
||||
User.hasMany(EroticContentReport, { foreignKey: 'handledBy', as: 'handledEroticContentReports' });
|
||||
|
||||
// Image visibility associations
|
||||
Folder.belongsToMany(ImageVisibilityType, {
|
||||
through: FolderImageVisibility,
|
||||
@@ -231,6 +244,17 @@ export default function setupAssociations() {
|
||||
otherKey: 'imageId'
|
||||
});
|
||||
|
||||
EroticVideo.belongsToMany(ImageVisibilityType, {
|
||||
through: EroticVideoImageVisibility,
|
||||
foreignKey: 'eroticVideoId',
|
||||
otherKey: 'visibilityTypeId'
|
||||
});
|
||||
ImageVisibilityType.belongsToMany(EroticVideo, {
|
||||
through: EroticVideoImageVisibility,
|
||||
foreignKey: 'visibilityTypeId',
|
||||
otherKey: 'eroticVideoId'
|
||||
});
|
||||
|
||||
Folder.belongsToMany(ImageVisibilityUser, {
|
||||
through: FolderVisibilityUser,
|
||||
foreignKey: 'folderId',
|
||||
@@ -242,6 +266,19 @@ export default function setupAssociations() {
|
||||
otherKey: 'folderId'
|
||||
});
|
||||
|
||||
EroticVideo.belongsToMany(User, {
|
||||
through: EroticVideoVisibilityUser,
|
||||
foreignKey: 'eroticVideoId',
|
||||
otherKey: 'userId',
|
||||
as: 'selectedVisibilityUsers'
|
||||
});
|
||||
User.belongsToMany(EroticVideo, {
|
||||
through: EroticVideoVisibilityUser,
|
||||
foreignKey: 'userId',
|
||||
otherKey: 'eroticVideoId',
|
||||
as: 'visibleEroticVideos'
|
||||
});
|
||||
|
||||
// Guestbook related associations
|
||||
User.hasMany(GuestbookEntry, { foreignKey: 'recipientId', as: 'receivedEntries' });
|
||||
User.hasMany(GuestbookEntry, { foreignKey: 'senderId', as: 'sentEntries' });
|
||||
@@ -460,6 +497,8 @@ export default function setupAssociations() {
|
||||
Relationship.belongsTo(FalukantCharacter, { foreignKey: 'character2Id', as: 'character2', });
|
||||
FalukantCharacter.hasMany(Relationship, { foreignKey: 'character1Id', as: 'relationshipsAsCharacter1', });
|
||||
FalukantCharacter.hasMany(Relationship, { foreignKey: 'character2Id', as: 'relationshipsAsCharacter2', });
|
||||
Relationship.hasOne(RelationshipState, { foreignKey: 'relationshipId', as: 'state' });
|
||||
RelationshipState.belongsTo(Relationship, { foreignKey: 'relationshipId', as: 'relationship' });
|
||||
|
||||
PromotionalGiftLog.belongsTo(PromotionalGift, { foreignKey: 'giftId', as: 'gift' });
|
||||
PromotionalGift.hasMany(PromotionalGiftLog, { foreignKey: 'giftId', as: 'logs' });
|
||||
@@ -1095,4 +1134,3 @@ export default function setupAssociations() {
|
||||
CalendarEvent.belongsTo(User, { foreignKey: 'userId', as: 'user' });
|
||||
User.hasMany(CalendarEvent, { foreignKey: 'userId', as: 'calendarEvents' });
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
26
backend/models/community/erotic_video_image_visibility.js
Normal file
26
backend/models/community/erotic_video_image_visibility.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { sequelize } from '../../utils/sequelize.js';
|
||||
import { DataTypes } from 'sequelize';
|
||||
|
||||
const EroticVideoImageVisibility = sequelize.define('erotic_video_image_visibility', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
allowNull: false
|
||||
},
|
||||
eroticVideoId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false
|
||||
},
|
||||
visibilityTypeId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false
|
||||
}
|
||||
}, {
|
||||
tableName: 'erotic_video_image_visibility',
|
||||
timestamps: false,
|
||||
underscored: true,
|
||||
schema: 'community'
|
||||
});
|
||||
|
||||
export default EroticVideoImageVisibility;
|
||||
26
backend/models/community/erotic_video_visibility_user.js
Normal file
26
backend/models/community/erotic_video_visibility_user.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { sequelize } from '../../utils/sequelize.js';
|
||||
import { DataTypes } from 'sequelize';
|
||||
|
||||
const EroticVideoVisibilityUser = sequelize.define('erotic_video_visibility_user', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
allowNull: false
|
||||
},
|
||||
eroticVideoId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false
|
||||
}
|
||||
}, {
|
||||
tableName: 'erotic_video_visibility_user',
|
||||
timestamps: false,
|
||||
underscored: true,
|
||||
schema: 'community'
|
||||
});
|
||||
|
||||
export default EroticVideoVisibilityUser;
|
||||
@@ -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,11 +18,15 @@ import UserParamVisibilityType from './type/user_param_visibility.js';
|
||||
import UserParamVisibility from './community/user_param_visibility.js';
|
||||
import Folder from './community/folder.js';
|
||||
import Image from './community/image.js';
|
||||
import EroticVideo from './community/erotic_video.js';
|
||||
import EroticContentReport from './community/erotic_content_report.js';
|
||||
import ImageVisibilityType from './type/image_visibility.js';
|
||||
import ImageVisibilityUser from './community/image_visibility_user.js';
|
||||
import FolderImageVisibility from './community/folder_image_visibility.js';
|
||||
import ImageImageVisibility from './community/image_image_visibility.js';
|
||||
import FolderVisibilityUser from './community/folder_visibility_user.js';
|
||||
import EroticVideoImageVisibility from './community/erotic_video_image_visibility.js';
|
||||
import EroticVideoVisibilityUser from './community/erotic_video_visibility_user.js';
|
||||
import GuestbookEntry from './community/guestbook.js';
|
||||
import DiaryHistory from './community/diary_history.js';
|
||||
import Diary from './community/diary.js';
|
||||
@@ -67,6 +71,7 @@ import Notification from './falukant/log/notification.js';
|
||||
import MarriageProposal from './falukant/data/marriage_proposal.js';
|
||||
import RelationshipType from './falukant/type/relationship.js';
|
||||
import Relationship from './falukant/data/relationship.js';
|
||||
import RelationshipState from './falukant/data/relationship_state.js';
|
||||
import CharacterTrait from './falukant/type/character_trait.js';
|
||||
import FalukantCharacterTrait from './falukant/data/falukant_character_trait.js';
|
||||
import Mood from './falukant/type/mood.js';
|
||||
@@ -169,11 +174,15 @@ const models = {
|
||||
UserParamVisibility,
|
||||
Folder,
|
||||
Image,
|
||||
EroticVideo,
|
||||
EroticContentReport,
|
||||
ImageVisibilityType,
|
||||
ImageVisibilityUser,
|
||||
FolderImageVisibility,
|
||||
ImageImageVisibility,
|
||||
FolderVisibilityUser,
|
||||
EroticVideoImageVisibility,
|
||||
EroticVideoVisibilityUser,
|
||||
GuestbookEntry,
|
||||
DiaryHistory,
|
||||
Diary,
|
||||
@@ -219,6 +228,7 @@ const models = {
|
||||
MarriageProposal,
|
||||
RelationshipType,
|
||||
Relationship,
|
||||
RelationshipState,
|
||||
CharacterTrait,
|
||||
FalukantCharacterTrait,
|
||||
Mood,
|
||||
|
||||
@@ -9,13 +9,13 @@ const ContactMessage = sequelize.define('contact_message', {
|
||||
set(value) {
|
||||
if (value) {
|
||||
const encryptedValue = encrypt(value);
|
||||
this.setDataValue('email', encryptedValue.toString('hex'));
|
||||
this.setDataValue('email', encryptedValue);
|
||||
}
|
||||
},
|
||||
get() {
|
||||
const value = this.getDataValue('email');
|
||||
if (value) {
|
||||
return decrypt(Buffer.from(value, 'hex'));
|
||||
return decrypt(value);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -25,13 +25,13 @@ const ContactMessage = sequelize.define('contact_message', {
|
||||
set(value) {
|
||||
if (value) {
|
||||
const encryptedValue = encrypt(value);
|
||||
this.setDataValue('message', encryptedValue.toString('hex'));
|
||||
this.setDataValue('message', encryptedValue);
|
||||
}
|
||||
},
|
||||
get() {
|
||||
const value = this.getDataValue('message');
|
||||
if (value) {
|
||||
return decrypt(Buffer.from(value, 'hex'));
|
||||
return decrypt(value);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -41,13 +41,13 @@ const ContactMessage = sequelize.define('contact_message', {
|
||||
set(value) {
|
||||
if (value) {
|
||||
const encryptedValue = encrypt(value);
|
||||
this.setDataValue('name', encryptedValue.toString('hex'));
|
||||
this.setDataValue('name', encryptedValue);
|
||||
}
|
||||
},
|
||||
get() {
|
||||
const value = this.getDataValue('name');
|
||||
if (value) {
|
||||
return decrypt(Buffer.from(value, 'hex'));
|
||||
return decrypt(value);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -67,13 +67,13 @@ const ContactMessage = sequelize.define('contact_message', {
|
||||
set(value) {
|
||||
if (value) {
|
||||
const encryptedValue = encrypt(value);
|
||||
this.setDataValue('answer', encryptedValue.toString('hex'));
|
||||
this.setDataValue('answer', encryptedValue);
|
||||
}
|
||||
},
|
||||
get() {
|
||||
const value = this.getDataValue('answer');
|
||||
if (value) {
|
||||
return decrypt(Buffer.from(value, 'hex'));
|
||||
return decrypt(value);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
5965
backend/package-lock.json
generated
Normal file
5965
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"description": "Nach Änderungen an dependencies: npm install ausführen und package-lock.json committen (npm ci im Deploy).",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -12,38 +12,44 @@
|
||||
"sync-tables": "node sync-tables-only.js",
|
||||
"check-connections": "node check-connections.js",
|
||||
"cleanup-connections": "node cleanup-connections.js",
|
||||
"diag:town-worth": "QUIET_ENV_LOGS=1 DOTENV_CONFIG_QUIET=1 node scripts/falukant-town-product-worth-stats.mjs",
|
||||
"diag:moneyflow": "QUIET_ENV_LOGS=1 DOTENV_CONFIG_QUIET=1 node scripts/falukant-moneyflow-report.mjs",
|
||||
"lockfile:sync": "npm install --package-lock-only",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"amqplib": "^0.10.4",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"connect-redis": "^7.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"@gltf-transform/cli": "^4.3.0",
|
||||
"amqplib": "^0.10.9",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"connect-redis": "^9.0.0",
|
||||
"cors": "^2.8.6",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.1.7",
|
||||
"dotenv": "^17.2.1",
|
||||
"express": "^4.19.2",
|
||||
"express-session": "^1.18.1",
|
||||
"i18n": "^0.15.1",
|
||||
"joi": "^17.13.3",
|
||||
"jsdom": "^26.1.0",
|
||||
"multer": "^2.0.0",
|
||||
"mysql2": "^3.10.3",
|
||||
"nodemailer": "^7.0.11",
|
||||
"pg": "^8.12.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"express-session": "^1.19.0",
|
||||
"i18n": "^0.15.3",
|
||||
"joi": "^18.0.2",
|
||||
"jsdom": "^29.0.1",
|
||||
"multer": "^2.1.1",
|
||||
"mysql2": "^3.20.0",
|
||||
"nodemailer": "^8.0.3",
|
||||
"pg": "^8.20.0",
|
||||
"pg-hstore": "^2.3.4",
|
||||
"redis": "^4.7.0",
|
||||
"sequelize": "^6.37.3",
|
||||
"sharp": "^0.34.3",
|
||||
"socket.io": "^4.7.5",
|
||||
"uuid": "^11.1.0",
|
||||
"ws": "^8.18.0",
|
||||
"@gltf-transform/cli": "^4.3.0"
|
||||
"redis": "^5.11.0",
|
||||
"sequelize": "^6.37.8",
|
||||
"sharp": "^0.34.5",
|
||||
"socket.io": "^4.8.3",
|
||||
"uuid": "^13.0.0",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sequelize-cli": "^6.6.2"
|
||||
"sequelize-cli": "^6.6.5"
|
||||
},
|
||||
"overrides": {
|
||||
"minimatch": "10.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -22,10 +22,12 @@ router.post('/production', falukantController.createProduction);
|
||||
router.get('/production/:branchId', falukantController.getProduction);
|
||||
router.get('/stocktypes', falukantController.getStockTypes);
|
||||
router.get('/stockoverview', falukantController.getStockOverview);
|
||||
router.get('/stock/?:branchId', falukantController.getStock);
|
||||
router.get('/stock', falukantController.getStock);
|
||||
router.get('/stock/:branchId', falukantController.getStock);
|
||||
router.post('/stock', falukantController.createStock);
|
||||
router.get('/products', falukantController.getProducts);
|
||||
router.get('/inventory/?:branchId', falukantController.getInventory);
|
||||
router.get('/inventory', falukantController.getInventory);
|
||||
router.get('/inventory/:branchId', falukantController.getInventory);
|
||||
router.post('/sell/all', falukantController.sellAllProducts);
|
||||
router.post('/sell', falukantController.sellProduct);
|
||||
router.post('/moneyhistory', falukantController.moneyHistory);
|
||||
@@ -47,6 +49,13 @@ router.get('/dashboard-widget', falukantController.getDashboardWidget);
|
||||
router.post('/family/acceptmarriageproposal', falukantController.acceptMarriageProposal);
|
||||
router.post('/family/cancel-wooing', falukantController.cancelWooing);
|
||||
router.post('/family/set-heir', falukantController.setHeir);
|
||||
router.post('/family/lover', falukantController.createLoverRelationship);
|
||||
router.post('/family/marriage/spend-time', falukantController.spendTimeWithSpouse);
|
||||
router.post('/family/marriage/gift', falukantController.giftToSpouse);
|
||||
router.post('/family/marriage/reconcile', falukantController.reconcileMarriage);
|
||||
router.post('/family/lover/:relationshipId/maintenance', falukantController.setLoverMaintenance);
|
||||
router.post('/family/lover/:relationshipId/acknowledge', falukantController.acknowledgeLover);
|
||||
router.post('/family/lover/:relationshipId/end', falukantController.endLoverRelationship);
|
||||
router.get('/heirs/potential', falukantController.getPotentialHeirs);
|
||||
router.post('/heirs/select', falukantController.selectHeir);
|
||||
router.get('/family/gifts', falukantController.getGifts);
|
||||
@@ -61,6 +70,10 @@ router.get('/houses/buyable', falukantController.getBuyableHouses);
|
||||
router.get('/houses', falukantController.getUserHouse);
|
||||
router.post('/houses/renovate-all', falukantController.renovateAll);
|
||||
router.post('/houses/renovate', falukantController.renovate);
|
||||
router.post('/houses/servants/hire', falukantController.hireServants);
|
||||
router.post('/houses/servants/dismiss', falukantController.dismissServants);
|
||||
router.post('/houses/servants/pay-level', falukantController.setServantPayLevel);
|
||||
router.post('/houses/order', falukantController.tidyHousehold);
|
||||
router.post('/houses', falukantController.buyUserHouse);
|
||||
router.get('/party/types', falukantController.getPartyTypes);
|
||||
router.post('/party', falukantController.createParty);
|
||||
@@ -101,6 +114,8 @@ router.post('/transports', falukantController.createTransport);
|
||||
router.get('/transports/route', falukantController.getTransportRoute);
|
||||
router.get('/transports/branch/:branchId', falukantController.getBranchTransports);
|
||||
router.get('/underground/types', falukantController.getUndergroundTypes);
|
||||
router.get('/underground/raid-regions', falukantController.getRaidTransportRegions);
|
||||
router.get('/underground/activities', falukantController.getUndergroundActivities);
|
||||
router.get('/notifications', falukantController.getNotifications);
|
||||
router.get('/notifications/all', falukantController.getAllNotifications);
|
||||
router.post('/notifications/mark-shown', falukantController.markNotificationsShown);
|
||||
|
||||
@@ -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,19 @@ router.post('/folders/:folderId', socialNetworkController.createFolder);
|
||||
router.get('/folders', socialNetworkController.getFolders);
|
||||
router.get('/folder/:folderId', socialNetworkController.getFolderImageList);
|
||||
router.post('/images', upload.single('image'), socialNetworkController.uploadImage);
|
||||
router.post('/erotic/folders/:folderId', socialNetworkController.createAdultFolder);
|
||||
router.get('/erotic/folders', socialNetworkController.getAdultFolders);
|
||||
router.get('/profile/erotic/folders/:username', socialNetworkController.getAdultFoldersByUsername);
|
||||
router.get('/profile/erotic/videos/:username', socialNetworkController.getEroticVideosByUsername);
|
||||
router.get('/erotic/folder/:folderId', socialNetworkController.getAdultFolderImageList);
|
||||
router.post('/erotic/images', upload.single('image'), socialNetworkController.uploadAdultImage);
|
||||
router.put('/erotic/images/:imageId', socialNetworkController.changeAdultImage);
|
||||
router.get('/erotic/image/:hash', socialNetworkController.getAdultImageByHash);
|
||||
router.get('/erotic/videos', socialNetworkController.listEroticVideos);
|
||||
router.post('/erotic/videos', upload.single('video'), socialNetworkController.uploadEroticVideo);
|
||||
router.put('/erotic/videos/:videoId', socialNetworkController.changeEroticVideo);
|
||||
router.get('/erotic/video/:hash', socialNetworkController.getEroticVideoByHash);
|
||||
router.post('/erotic/report', socialNetworkController.reportEroticContent);
|
||||
router.get('/images/:imageId', socialNetworkController.getImage);
|
||||
router.put('/images/:imageId', socialNetworkController.changeImage);
|
||||
router.get('/imagevisibilities', socialNetworkController.getImageVisibilityTypes);
|
||||
|
||||
@@ -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,69 @@ import BranchType from "../models/falukant/type/branch.js";
|
||||
import RegionDistance from "../models/falukant/data/region_distance.js";
|
||||
import Room from '../models/chat/room.js';
|
||||
import UserParam from '../models/community/user_param.js';
|
||||
import Image from '../models/community/image.js';
|
||||
import EroticVideo from '../models/community/erotic_video.js';
|
||||
import EroticContentReport from '../models/community/erotic_content_report.js';
|
||||
import TitleOfNobility from "../models/falukant/type/title_of_nobility.js";
|
||||
import { sequelize } from '../utils/sequelize.js';
|
||||
import npcCreationJobService from './npcCreationJobService.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { getAdultVerificationBaseDir, getLegacyAdultVerificationBaseDir } from '../utils/storagePaths.js';
|
||||
import { notifyUser } from '../utils/socket.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
class AdminService {
|
||||
resolveAdultVerificationFile(requestData) {
|
||||
if (!requestData || typeof requestData !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = [];
|
||||
if (requestData.fileName) {
|
||||
candidates.push(path.join(getAdultVerificationBaseDir(), requestData.fileName));
|
||||
candidates.push(path.join(getLegacyAdultVerificationBaseDir(), requestData.fileName));
|
||||
}
|
||||
if (requestData.storedFileName && requestData.storedFileName !== requestData.fileName) {
|
||||
candidates.push(path.join(getAdultVerificationBaseDir(), requestData.storedFileName));
|
||||
candidates.push(path.join(getLegacyAdultVerificationBaseDir(), requestData.storedFileName));
|
||||
}
|
||||
if (requestData.filePath) {
|
||||
candidates.push(path.isAbsolute(requestData.filePath)
|
||||
? requestData.filePath
|
||||
: path.join(__dirname, '..', requestData.filePath));
|
||||
}
|
||||
|
||||
return candidates.find((candidate) => candidate && fs.existsSync(candidate)) || null;
|
||||
}
|
||||
|
||||
removeEroticStorageFile(type, hash) {
|
||||
if (!hash) {
|
||||
return;
|
||||
}
|
||||
const storageFolder = type === 'image' ? 'erotic' : 'erotic-video';
|
||||
const filePath = path.join(__dirname, '..', 'images', storageFolder, hash);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
calculateAgeFromBirthdate(birthdate) {
|
||||
if (!birthdate) return null;
|
||||
const today = new Date();
|
||||
const birthDateObj = new Date(birthdate);
|
||||
let age = today.getFullYear() - birthDateObj.getFullYear();
|
||||
const monthDiff = today.getMonth() - birthDateObj.getMonth();
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDateObj.getDate())) {
|
||||
age--;
|
||||
}
|
||||
return age;
|
||||
}
|
||||
|
||||
async hasUserAccess(userId, section) {
|
||||
const userRights = await UserRight.findAll({
|
||||
include: [{
|
||||
@@ -232,6 +289,369 @@ class AdminService {
|
||||
});
|
||||
}
|
||||
|
||||
async getAdultVerificationRequests(userId, status = 'pending') {
|
||||
if (!(await this.hasUserAccess(userId, 'useradministration'))) {
|
||||
throw new Error('noaccess');
|
||||
}
|
||||
|
||||
const users = await User.findAll({
|
||||
attributes: ['id', 'hashedId', 'username', 'active'],
|
||||
include: [
|
||||
{
|
||||
model: UserParam,
|
||||
as: 'user_params',
|
||||
required: false,
|
||||
include: [
|
||||
{
|
||||
model: UserParamType,
|
||||
as: 'paramType',
|
||||
where: { description: ['birthdate', 'adult_verification_status', 'adult_verification_request'] }
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
order: [['username', 'ASC']]
|
||||
});
|
||||
|
||||
const rows = users.map((user) => {
|
||||
const birthdateParam = user.user_params.find((param) => param.paramType?.description === 'birthdate');
|
||||
const statusParam = user.user_params.find((param) => param.paramType?.description === 'adult_verification_status');
|
||||
const requestParam = user.user_params.find((param) => param.paramType?.description === 'adult_verification_request');
|
||||
const age = this.calculateAgeFromBirthdate(birthdateParam?.value);
|
||||
const verificationStatus = ['pending', 'approved', 'rejected'].includes(statusParam?.value)
|
||||
? statusParam.value
|
||||
: 'none';
|
||||
let verificationRequest = null;
|
||||
try {
|
||||
verificationRequest = requestParam?.value ? JSON.parse(requestParam.value) : null;
|
||||
} catch {
|
||||
verificationRequest = null;
|
||||
}
|
||||
const resolvedDocumentPath = this.resolveAdultVerificationFile(verificationRequest);
|
||||
return {
|
||||
id: user.hashedId,
|
||||
username: user.username,
|
||||
active: !!user.active,
|
||||
age,
|
||||
adultVerificationStatus: verificationStatus,
|
||||
adultVerificationRequest: verificationRequest,
|
||||
adultVerificationDocumentAvailable: !!resolvedDocumentPath
|
||||
};
|
||||
}).filter((row) => row.age !== null && row.age >= 18);
|
||||
|
||||
if (status === 'all') {
|
||||
return rows;
|
||||
}
|
||||
|
||||
return rows.filter((row) => row.adultVerificationStatus === status);
|
||||
}
|
||||
|
||||
async setAdultVerificationStatus(requesterId, targetHashedId, status) {
|
||||
if (!(await this.hasUserAccess(requesterId, 'useradministration'))) {
|
||||
throw new Error('noaccess');
|
||||
}
|
||||
if (!['approved', 'rejected', 'pending'].includes(status)) {
|
||||
throw new Error('wrongstatus');
|
||||
}
|
||||
|
||||
const user = await User.findOne({
|
||||
where: { hashedId: targetHashedId },
|
||||
attributes: ['id']
|
||||
});
|
||||
if (!user) {
|
||||
throw new Error('notfound');
|
||||
}
|
||||
|
||||
const birthdateParam = await UserParam.findOne({
|
||||
where: { userId: user.id },
|
||||
include: [{
|
||||
model: UserParamType,
|
||||
as: 'paramType',
|
||||
where: { description: 'birthdate' }
|
||||
}]
|
||||
});
|
||||
const age = this.calculateAgeFromBirthdate(birthdateParam?.value);
|
||||
if (age === null || age < 18) {
|
||||
throw new Error('notadult');
|
||||
}
|
||||
|
||||
const paramType = await UserParamType.findOne({
|
||||
where: { description: 'adult_verification_status' }
|
||||
});
|
||||
if (!paramType) {
|
||||
throw new Error('missingparamtype');
|
||||
}
|
||||
|
||||
const existing = await UserParam.findOne({
|
||||
where: { userId: user.id, paramTypeId: paramType.id }
|
||||
});
|
||||
|
||||
const previousStatus = existing?.value || 'none';
|
||||
|
||||
if (existing) {
|
||||
await existing.update({ value: status });
|
||||
} else {
|
||||
await UserParam.create({
|
||||
userId: user.id,
|
||||
paramTypeId: paramType.id,
|
||||
value: status
|
||||
});
|
||||
}
|
||||
|
||||
await notifyUser(targetHashedId, 'reloadmenu', {});
|
||||
await notifyUser(targetHashedId, 'adultVerificationChanged', {
|
||||
status,
|
||||
previousStatus
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async getAdultVerificationDocument(requesterId, targetHashedId) {
|
||||
if (!(await this.hasUserAccess(requesterId, 'useradministration'))) {
|
||||
throw new Error('noaccess');
|
||||
}
|
||||
|
||||
const user = await User.findOne({
|
||||
where: { hashedId: targetHashedId },
|
||||
attributes: ['id', 'username']
|
||||
});
|
||||
if (!user) {
|
||||
throw new Error('notfound');
|
||||
}
|
||||
|
||||
const requestParam = await UserParam.findOne({
|
||||
where: { userId: user.id },
|
||||
include: [{
|
||||
model: UserParamType,
|
||||
as: 'paramType',
|
||||
where: { description: 'adult_verification_request' }
|
||||
}]
|
||||
});
|
||||
if (!requestParam?.value) {
|
||||
throw new Error('norequest');
|
||||
}
|
||||
|
||||
let requestData;
|
||||
try {
|
||||
requestData = JSON.parse(requestParam.value);
|
||||
} catch {
|
||||
throw new Error('norequest');
|
||||
}
|
||||
|
||||
const filePath = this.resolveAdultVerificationFile(requestData);
|
||||
if (!filePath) {
|
||||
throw new Error('nofile');
|
||||
}
|
||||
|
||||
return {
|
||||
filePath,
|
||||
mimeType: requestData.mimeType || 'application/octet-stream',
|
||||
originalName: requestData.originalName || `${user.username}-verification`
|
||||
};
|
||||
}
|
||||
|
||||
async ensureUserParam(userId, description, value) {
|
||||
const paramType = await UserParamType.findOne({
|
||||
where: { description }
|
||||
});
|
||||
if (!paramType) {
|
||||
throw new Error('missingparamtype');
|
||||
}
|
||||
const existing = await UserParam.findOne({
|
||||
where: { userId, paramTypeId: paramType.id }
|
||||
});
|
||||
if (existing) {
|
||||
await existing.update({ value });
|
||||
return existing;
|
||||
}
|
||||
return await UserParam.create({
|
||||
userId,
|
||||
paramTypeId: paramType.id,
|
||||
value
|
||||
});
|
||||
}
|
||||
|
||||
async getEroticModerationReports(userId, status = 'open') {
|
||||
if (!(await this.hasUserAccess(userId, 'useradministration'))) {
|
||||
throw new Error('noaccess');
|
||||
}
|
||||
|
||||
const where = status === 'all' ? {} : { status };
|
||||
const reports = await EroticContentReport.findAll({
|
||||
where,
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: 'reporter',
|
||||
attributes: ['id', 'hashedId', 'username']
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
as: 'moderator',
|
||||
attributes: ['id', 'hashedId', 'username'],
|
||||
required: false
|
||||
}
|
||||
],
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
|
||||
const rows = [];
|
||||
for (const report of reports) {
|
||||
let target = null;
|
||||
if (report.targetType === 'image') {
|
||||
target = await Image.findByPk(report.targetId, {
|
||||
attributes: ['id', 'title', 'hash', 'userId', 'isModeratedHidden']
|
||||
});
|
||||
} else if (report.targetType === 'video') {
|
||||
target = await EroticVideo.findByPk(report.targetId, {
|
||||
attributes: ['id', 'title', 'hash', 'userId', 'isModeratedHidden']
|
||||
});
|
||||
}
|
||||
const owner = target ? await User.findByPk(target.userId, {
|
||||
attributes: ['hashedId', 'username']
|
||||
}) : null;
|
||||
rows.push({
|
||||
id: report.id,
|
||||
targetType: report.targetType,
|
||||
targetId: report.targetId,
|
||||
reason: report.reason,
|
||||
note: report.note,
|
||||
status: report.status,
|
||||
actionTaken: report.actionTaken,
|
||||
handledAt: report.handledAt,
|
||||
createdAt: report.createdAt,
|
||||
reporter: report.reporter,
|
||||
moderator: report.moderator,
|
||||
target: target ? {
|
||||
id: target.id,
|
||||
title: target.title,
|
||||
hash: target.hash,
|
||||
isModeratedHidden: !!target.isModeratedHidden
|
||||
} : null,
|
||||
owner
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
async applyEroticModerationAction(requesterId, reportId, action, note = null) {
|
||||
if (!(await this.hasUserAccess(requesterId, 'useradministration'))) {
|
||||
throw new Error('noaccess');
|
||||
}
|
||||
if (!['dismiss', 'hide_content', 'restore_content', 'delete_content', 'block_uploads', 'revoke_access'].includes(action)) {
|
||||
throw new Error('wrongaction');
|
||||
}
|
||||
|
||||
const moderator = await User.findOne({
|
||||
where: { hashedId: requesterId },
|
||||
attributes: ['id']
|
||||
});
|
||||
const report = await EroticContentReport.findByPk(reportId);
|
||||
if (!report) {
|
||||
throw new Error('notfound');
|
||||
}
|
||||
|
||||
let target = null;
|
||||
if (report.targetType === 'image') {
|
||||
target = await Image.findByPk(report.targetId);
|
||||
} else if (report.targetType === 'video') {
|
||||
target = await EroticVideo.findByPk(report.targetId);
|
||||
}
|
||||
|
||||
if (action === 'dismiss') {
|
||||
await report.update({
|
||||
status: 'dismissed',
|
||||
actionTaken: 'dismiss',
|
||||
handledBy: moderator?.id || null,
|
||||
handledAt: new Date(),
|
||||
note: note ?? report.note
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
throw new Error('targetnotfound');
|
||||
}
|
||||
|
||||
if (action === 'hide_content') {
|
||||
await target.update({ isModeratedHidden: true });
|
||||
} else if (action === 'restore_content') {
|
||||
await target.update({ isModeratedHidden: false });
|
||||
} else if (action === 'delete_content') {
|
||||
this.removeEroticStorageFile(report.targetType, target.hash);
|
||||
await target.destroy();
|
||||
} else if (action === 'block_uploads') {
|
||||
await this.ensureUserParam(target.userId, 'adult_upload_blocked', 'true');
|
||||
} else if (action === 'revoke_access') {
|
||||
await this.ensureUserParam(target.userId, 'adult_verification_status', 'rejected');
|
||||
}
|
||||
|
||||
await report.update({
|
||||
status: 'actioned',
|
||||
actionTaken: action,
|
||||
handledBy: moderator?.id || null,
|
||||
handledAt: new Date(),
|
||||
note: note ?? report.note
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async getEroticModerationPreview(requesterId, targetType, targetId) {
|
||||
if (!(await this.hasUserAccess(requesterId, 'useradministration'))) {
|
||||
throw new Error('noaccess');
|
||||
}
|
||||
|
||||
if (targetType === 'image') {
|
||||
const target = await Image.findByPk(targetId, {
|
||||
attributes: ['hash', 'originalFileName']
|
||||
});
|
||||
if (!target) {
|
||||
throw new Error('notfound');
|
||||
}
|
||||
const filePath = path.join(__dirname, '..', 'images', 'erotic', target.hash);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error('nofile');
|
||||
}
|
||||
return {
|
||||
filePath,
|
||||
mimeType: this.getMimeTypeFromName(target.originalFileName) || 'image/jpeg',
|
||||
originalName: target.originalFileName || target.hash
|
||||
};
|
||||
}
|
||||
|
||||
if (targetType === 'video') {
|
||||
const target = await EroticVideo.findByPk(targetId, {
|
||||
attributes: ['hash', 'mimeType', 'originalFileName']
|
||||
});
|
||||
if (!target) {
|
||||
throw new Error('notfound');
|
||||
}
|
||||
const filePath = path.join(__dirname, '..', 'images', 'erotic-video', target.hash);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error('nofile');
|
||||
}
|
||||
return {
|
||||
filePath,
|
||||
mimeType: target.mimeType || this.getMimeTypeFromName(target.originalFileName) || 'application/octet-stream',
|
||||
originalName: target.originalFileName || target.hash
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('wrongtype');
|
||||
}
|
||||
|
||||
getMimeTypeFromName(fileName) {
|
||||
const lower = String(fileName || '').toLowerCase();
|
||||
if (lower.endsWith('.png')) return 'image/png';
|
||||
if (lower.endsWith('.webp')) return 'image/webp';
|
||||
if (lower.endsWith('.gif')) return 'image/gif';
|
||||
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
|
||||
return null;
|
||||
}
|
||||
|
||||
async getFalukantUserById(userId, hashedId) {
|
||||
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||
throw new Error('noaccess');
|
||||
@@ -682,6 +1102,7 @@ class AdminService {
|
||||
'title',
|
||||
'roomTypeId',
|
||||
'isPublic',
|
||||
'isAdultOnly',
|
||||
'genderRestrictionId',
|
||||
'minAge',
|
||||
'maxAge',
|
||||
|
||||
@@ -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,145 @@ import InterestTranslation from '../models/type/interest_translation.js';
|
||||
import { Op } from 'sequelize';
|
||||
import UserParamVisibilityType from '../models/type/user_param_visibility.js';
|
||||
import UserParamVisibility from '../models/community/user_param_visibility.js';
|
||||
import { generateIv } from '../utils/encryption.js';
|
||||
import { encrypt } from '../utils/encryption.js';
|
||||
import { sequelize } from '../utils/sequelize.js';
|
||||
import fsPromises from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { getAdultVerificationBaseDir } from '../utils/storagePaths.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
/** Wie UserParam.value-Setter: bei Verschlüsselungsfehler leeren String speichern, nicht crashen. */
|
||||
function encryptUserParamValue(plain) {
|
||||
try {
|
||||
return encrypt(plain);
|
||||
} catch (error) {
|
||||
console.error('Error encrypting user_param value:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
class SettingsService extends BaseService{
|
||||
async ensureSpecialUserParamType(description) {
|
||||
const specialTypes = {
|
||||
adult_verification_status: { datatype: 'string', setting: 'account', orderId: 910, minAge: 18 },
|
||||
adult_verification_request: { datatype: 'string', setting: 'account', orderId: 911, minAge: 18 },
|
||||
adult_upload_blocked: { datatype: 'bool', setting: 'account', orderId: 912, minAge: 18 },
|
||||
};
|
||||
const definition = specialTypes[description];
|
||||
if (!definition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [settingsType] = await SettingsType.findOrCreate({
|
||||
where: { name: definition.setting },
|
||||
defaults: { name: definition.setting }
|
||||
});
|
||||
|
||||
const [paramType] = await UserParamType.findOrCreate({
|
||||
where: { description },
|
||||
defaults: {
|
||||
description,
|
||||
datatype: definition.datatype,
|
||||
settingsId: settingsType.id,
|
||||
orderId: definition.orderId,
|
||||
minAge: definition.minAge,
|
||||
immutable: false
|
||||
}
|
||||
});
|
||||
return paramType;
|
||||
}
|
||||
|
||||
parseAdultVerificationRequest(value) {
|
||||
if (!value) return null;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
normalizeAdultVerificationStatus(value) {
|
||||
if (['pending', 'approved', 'rejected'].includes(value)) {
|
||||
return value;
|
||||
}
|
||||
return 'none';
|
||||
}
|
||||
|
||||
async getAdultAccessStateByUserId(userId) {
|
||||
const userParams = await this.getUserParams(userId, ['birthdate', 'adult_verification_status', 'adult_verification_request']);
|
||||
let birthdate = null;
|
||||
let adultVerificationStatus = 'none';
|
||||
let adultVerificationRequest = null;
|
||||
for (const param of userParams) {
|
||||
if (param.paramType.description === 'birthdate') {
|
||||
birthdate = param.value;
|
||||
}
|
||||
if (param.paramType.description === 'adult_verification_status') {
|
||||
adultVerificationStatus = this.normalizeAdultVerificationStatus(param.value);
|
||||
}
|
||||
if (param.paramType.description === 'adult_verification_request') {
|
||||
adultVerificationRequest = this.parseAdultVerificationRequest(param.value);
|
||||
}
|
||||
}
|
||||
const age = birthdate ? this.calculateAge(birthdate) : null;
|
||||
const isAdult = age !== null && age >= 18;
|
||||
return {
|
||||
age,
|
||||
isAdult,
|
||||
adultVerificationStatus: isAdult ? adultVerificationStatus : 'none',
|
||||
adultVerificationRequest: isAdult ? adultVerificationRequest : null,
|
||||
adultAccessEnabled: isAdult && adultVerificationStatus === 'approved'
|
||||
};
|
||||
}
|
||||
|
||||
buildAdultVerificationFilePath(fileName) {
|
||||
return path.join(getAdultVerificationBaseDir(), fileName);
|
||||
}
|
||||
|
||||
async saveAdultVerificationDocument(file) {
|
||||
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
|
||||
if (!file || !file.buffer) {
|
||||
throw new Error('No verification document provided');
|
||||
}
|
||||
if (!allowedMimeTypes.includes(file.mimetype)) {
|
||||
throw new Error('Unsupported verification document type');
|
||||
}
|
||||
|
||||
const ext = path.extname(file.originalname || '').toLowerCase();
|
||||
const safeExt = ext && ext.length <= 8 ? ext : (file.mimetype === 'application/pdf' ? '.pdf' : '.bin');
|
||||
const fileName = `${uuidv4()}${safeExt}`;
|
||||
const filePath = this.buildAdultVerificationFilePath(fileName);
|
||||
await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fsPromises.writeFile(filePath, file.buffer);
|
||||
return { fileName, filePath };
|
||||
}
|
||||
|
||||
async upsertUserParam(userId, description, value) {
|
||||
let paramType = await UserParamType.findOne({ where: { description } });
|
||||
if (!paramType) {
|
||||
paramType = await this.ensureSpecialUserParamType(description);
|
||||
}
|
||||
if (!paramType) {
|
||||
throw new Error(`Missing user param type: ${description}`);
|
||||
}
|
||||
const existingParam = await UserParam.findOne({
|
||||
where: { userId, paramTypeId: paramType.id }
|
||||
});
|
||||
if (existingParam) {
|
||||
await existingParam.update({ value });
|
||||
return existingParam;
|
||||
}
|
||||
return UserParam.create({
|
||||
userId,
|
||||
paramTypeId: paramType.id,
|
||||
value
|
||||
});
|
||||
}
|
||||
|
||||
async getUserParams(userId, paramDescriptions) {
|
||||
return await UserParam.findAll({
|
||||
where: { userId },
|
||||
@@ -288,10 +424,13 @@ class SettingsService extends BaseService{
|
||||
email = null;
|
||||
}
|
||||
|
||||
const adultAccess = await this.getAdultAccessStateByUserId(user.id);
|
||||
|
||||
return {
|
||||
username: user.username,
|
||||
email: email,
|
||||
showinsearch: user.searchable
|
||||
showinsearch: user.searchable,
|
||||
...adultAccess
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting account settings:', error);
|
||||
@@ -306,6 +445,8 @@ class SettingsService extends BaseService{
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const adultAccess = await this.getAdultAccessStateByUserId(user.id);
|
||||
|
||||
// Update username if provided
|
||||
if (settings.username !== undefined) {
|
||||
await user.update({ username: settings.username });
|
||||
@@ -321,6 +462,17 @@ class SettingsService extends BaseService{
|
||||
await user.update({ searchable: settings.showinsearch });
|
||||
}
|
||||
|
||||
if (settings.requestAdultVerification) {
|
||||
if (!adultAccess.isAdult) {
|
||||
throw new Error('Adult verification can only be requested by adult users');
|
||||
}
|
||||
|
||||
const normalizedValue = adultAccess.adultVerificationStatus === 'approved'
|
||||
? 'approved'
|
||||
: 'pending';
|
||||
await this.upsertUserParam(user.id, 'adult_verification_status', normalizedValue);
|
||||
}
|
||||
|
||||
// Update password if provided and not empty
|
||||
if (settings.newpassword && settings.newpassword.trim() !== '') {
|
||||
if (!settings.oldpassword || settings.oldpassword.trim() === '') {
|
||||
@@ -346,6 +498,36 @@ class SettingsService extends BaseService{
|
||||
}
|
||||
}
|
||||
|
||||
async submitAdultVerificationRequest(hashedUserId, { note }, file) {
|
||||
const user = await this.getUserByHashedId(hashedUserId);
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
const adultAccess = await this.getAdultAccessStateByUserId(user.id);
|
||||
if (!adultAccess.isAdult) {
|
||||
throw new Error('Adult verification can only be requested by adult users');
|
||||
}
|
||||
if (!file) {
|
||||
throw new Error('No verification document provided');
|
||||
}
|
||||
|
||||
const savedFile = await this.saveAdultVerificationDocument(file);
|
||||
const requestPayload = {
|
||||
fileName: savedFile.fileName,
|
||||
storedFileName: savedFile.fileName,
|
||||
filePath: savedFile.filePath,
|
||||
originalName: file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
note: note || '',
|
||||
submittedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
await this.upsertUserParam(user.id, 'adult_verification_request', JSON.stringify(requestPayload));
|
||||
await this.upsertUserParam(user.id, 'adult_verification_status', adultAccess.adultVerificationStatus === 'approved' ? 'approved' : 'pending');
|
||||
|
||||
return requestPayload;
|
||||
}
|
||||
|
||||
async getVisibilities() {
|
||||
return UserParamVisibilityType.findAll();
|
||||
}
|
||||
@@ -381,6 +563,137 @@ class SettingsService extends BaseService{
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM-/Sprachassistent: Werte in community.user_param, Typen in type.user_param,
|
||||
* Gruppe type.settings.name = languageAssistant. API-Key separat (llm_api_key), Metadaten als JSON in llm_settings.
|
||||
* Kein Klartext-Key an den Client.
|
||||
*/
|
||||
async getLlmSettings(hashedUserId) {
|
||||
const user = await this.getUserByHashedId(hashedUserId);
|
||||
const settingsType = await UserParamType.findOne({ where: { description: 'llm_settings' } });
|
||||
const apiKeyType = await UserParamType.findOne({ where: { description: 'llm_api_key' } });
|
||||
if (!settingsType || !apiKeyType) {
|
||||
return {
|
||||
enabled: true,
|
||||
baseUrl: '',
|
||||
model: 'gpt-4o-mini',
|
||||
hasKey: false,
|
||||
keyLast4: null
|
||||
};
|
||||
}
|
||||
|
||||
const settingsRow = await UserParam.findOne({
|
||||
where: { userId: user.id, paramTypeId: settingsType.id }
|
||||
});
|
||||
const keyRow = await UserParam.findOne({
|
||||
where: { userId: user.id, paramTypeId: apiKeyType.id }
|
||||
});
|
||||
|
||||
let parsed = {};
|
||||
if (settingsRow?.value) {
|
||||
try {
|
||||
parsed = JSON.parse(settingsRow.value);
|
||||
} catch {
|
||||
parsed = {};
|
||||
}
|
||||
}
|
||||
|
||||
const hasStoredKey = Boolean(keyRow && keyRow.getDataValue('value') && String(keyRow.getDataValue('value')).trim());
|
||||
const hasReadableKey = Boolean(keyRow && keyRow.value && String(keyRow.value).trim());
|
||||
|
||||
return {
|
||||
enabled: parsed.enabled !== false,
|
||||
baseUrl: parsed.baseUrl || '',
|
||||
model: parsed.model || 'gpt-4o-mini',
|
||||
hasKey: hasStoredKey,
|
||||
keyLast4: parsed.keyLast4 || null,
|
||||
keyStatus: hasStoredKey ? (hasReadableKey ? 'stored' : 'invalid') : 'missing'
|
||||
};
|
||||
}
|
||||
|
||||
async saveLlmSettings(hashedUserId, payload) {
|
||||
const user = await this.getUserByHashedId(hashedUserId);
|
||||
const settingsType = await UserParamType.findOne({ where: { description: 'llm_settings' } });
|
||||
const apiKeyType = await UserParamType.findOne({ where: { description: 'llm_api_key' } });
|
||||
if (!settingsType || !apiKeyType) {
|
||||
throw new Error(
|
||||
'LLM-Einstellungstypen fehlen (languageAssistant / llm_settings / llm_api_key). initializeSettings & initializeTypes ausführen.'
|
||||
);
|
||||
}
|
||||
|
||||
const settingsRow = await UserParam.findOne({
|
||||
where: { userId: user.id, paramTypeId: settingsType.id }
|
||||
});
|
||||
let parsed = {};
|
||||
if (settingsRow?.value) {
|
||||
try {
|
||||
parsed = JSON.parse(settingsRow.value);
|
||||
} catch {
|
||||
parsed = {};
|
||||
}
|
||||
}
|
||||
|
||||
const { apiKey, clearKey, baseUrl, model, enabled } = payload;
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
if (clearKey) {
|
||||
const keyRow = await UserParam.findOne({
|
||||
where: { userId: user.id, paramTypeId: apiKeyType.id },
|
||||
transaction
|
||||
});
|
||||
if (keyRow) {
|
||||
await keyRow.destroy({ transaction });
|
||||
}
|
||||
delete parsed.keyLast4;
|
||||
} else if (apiKey !== undefined && String(apiKey).trim() !== '') {
|
||||
const plain = String(apiKey).trim();
|
||||
parsed.keyLast4 = plain.length >= 4 ? plain.slice(-4) : plain;
|
||||
const encKey = encryptUserParamValue(plain);
|
||||
const [keyRow] = await UserParam.findOrCreate({
|
||||
where: { userId: user.id, paramTypeId: apiKeyType.id },
|
||||
defaults: {
|
||||
userId: user.id,
|
||||
paramTypeId: apiKeyType.id,
|
||||
// Platzhalter: Setter verschlüsselt; wird sofort durch encKey überschrieben.
|
||||
value: ' '
|
||||
},
|
||||
transaction
|
||||
});
|
||||
keyRow.setDataValue('value', encKey);
|
||||
await keyRow.save({ fields: ['value'], transaction });
|
||||
}
|
||||
|
||||
if (baseUrl !== undefined) {
|
||||
parsed.baseUrl = String(baseUrl).trim();
|
||||
}
|
||||
if (model !== undefined) {
|
||||
parsed.model = String(model).trim() || 'gpt-4o-mini';
|
||||
}
|
||||
if (enabled !== undefined) {
|
||||
parsed.enabled = Boolean(enabled);
|
||||
}
|
||||
if (!parsed.model) {
|
||||
parsed.model = 'gpt-4o-mini';
|
||||
}
|
||||
|
||||
const jsonStr = JSON.stringify(parsed);
|
||||
const encMeta = encryptUserParamValue(jsonStr);
|
||||
const [metaRow] = await UserParam.findOrCreate({
|
||||
where: { userId: user.id, paramTypeId: settingsType.id },
|
||||
defaults: {
|
||||
userId: user.id,
|
||||
paramTypeId: settingsType.id,
|
||||
value: ' '
|
||||
},
|
||||
transaction
|
||||
});
|
||||
metaRow.setDataValue('value', encMeta);
|
||||
await metaRow.save({ fields: ['value'], transaction });
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
export default new SettingsService();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,14 @@ const initializeSettings = async () => {
|
||||
where: { name: 'flirt' },
|
||||
defaults: { name: 'flirt' }
|
||||
});
|
||||
await SettingsType.findOrCreate({
|
||||
where: { name: 'account' },
|
||||
defaults: { name: 'account' }
|
||||
});
|
||||
await SettingsType.findOrCreate({
|
||||
where: { name: 'languageAssistant' },
|
||||
defaults: { name: 'languageAssistant' }
|
||||
});
|
||||
};
|
||||
|
||||
export default initializeSettings;
|
||||
@@ -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);
|
||||
|
||||
console.log('Database configuration:');
|
||||
console.log('DB_NAME:', dbName);
|
||||
console.log('DB_USER:', dbUser);
|
||||
console.log('DB_PASS:', dbPass ? '[SET]' : '[NOT SET]');
|
||||
console.log('DB_HOST:', dbHost);
|
||||
const useSsl = process.env.DB_SSL === '1' || process.env.PGSSLMODE === 'require';
|
||||
const connectTimeoutMs = Number.parseInt(process.env.DB_CONNECT_TIMEOUT_MS || '30000', 10);
|
||||
|
||||
if (process.env.QUIET_ENV_LOGS !== '1') {
|
||||
console.log('Database configuration:');
|
||||
console.log('DB_NAME:', dbName);
|
||||
console.log('DB_USER:', dbUser);
|
||||
console.log('DB_PASS:', dbPass ? '[SET]' : '[NOT SET]');
|
||||
console.log('DB_HOST:', dbHost);
|
||||
console.log('DB_PORT:', dbPort);
|
||||
console.log('DB_SSL:', useSsl ? 'on' : 'off');
|
||||
}
|
||||
|
||||
if (!dbName || !dbUser || !dbHost) {
|
||||
throw new Error('Missing required database environment variables: DB_NAME, DB_USER, or DB_HOST');
|
||||
@@ -44,8 +63,22 @@ const poolAcquire = Number.parseInt(process.env.DB_POOL_ACQUIRE || '30000', 10);
|
||||
const poolIdle = Number.parseInt(process.env.DB_POOL_IDLE || '10000', 10);
|
||||
const poolEvict = Number.parseInt(process.env.DB_POOL_EVICT || '1000', 10);
|
||||
|
||||
const dialectOptions = {
|
||||
connectTimeout: connectTimeoutMs,
|
||||
...(useSsl
|
||||
? {
|
||||
// node-pg: true oder { rejectUnauthorized: false } bei selbstsigniertem Zertifikat
|
||||
ssl:
|
||||
process.env.DB_SSL_REJECT_UNAUTHORIZED === '0'
|
||||
? { rejectUnauthorized: false }
|
||||
: true
|
||||
}
|
||||
: {})
|
||||
};
|
||||
|
||||
const sequelize = new Sequelize(dbName, dbUser, dbPass, {
|
||||
host: dbHost,
|
||||
port: dbPort,
|
||||
dialect: 'postgres',
|
||||
define: {
|
||||
timestamps: false,
|
||||
@@ -61,9 +94,7 @@ const sequelize = new Sequelize(dbName, dbUser, dbPass, {
|
||||
evict: poolEvict, // Intervall (ms) zum Prüfen auf idle Verbindungen
|
||||
handleDisconnects: true // Automatisches Reconnect bei Verbindungsverlust
|
||||
},
|
||||
dialectOptions: {
|
||||
connectTimeout: 30000 // Timeout für Verbindungsaufbau (30 Sekunden)
|
||||
},
|
||||
dialectOptions,
|
||||
retry: {
|
||||
max: 3, // Maximale Anzahl von Wiederholungsversuchen
|
||||
match: [
|
||||
|
||||
26
backend/utils/storagePaths.js
Normal file
26
backend/utils/storagePaths.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
function getBackendRoot() {
|
||||
return path.join(__dirname, '..');
|
||||
}
|
||||
|
||||
export function getAdultVerificationBaseDir() {
|
||||
if (process.env.ADULT_VERIFICATION_DIR && process.env.ADULT_VERIFICATION_DIR.trim()) {
|
||||
return process.env.ADULT_VERIFICATION_DIR.trim();
|
||||
}
|
||||
if (process.env.YOURPART_PERSISTENT_DATA_DIR && process.env.YOURPART_PERSISTENT_DATA_DIR.trim()) {
|
||||
return path.join(process.env.YOURPART_PERSISTENT_DATA_DIR.trim(), 'adult-verification');
|
||||
}
|
||||
if (process.env.STAGE === 'production' || process.env.NODE_ENV === 'production') {
|
||||
return '/opt/yourpart-data/adult-verification';
|
||||
}
|
||||
return path.join(getBackendRoot(), 'images', 'adult-verification');
|
||||
}
|
||||
|
||||
export function getLegacyAdultVerificationBaseDir() {
|
||||
return path.join(getBackendRoot(), 'images', 'adult-verification');
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user