feat(falukant): enhance notification handling and localization updates
All checks were successful
Deploy to production / deploy (push) Successful in 2m52s

- Updated the `enrichNotificationsWithCharacterNames` method in FalukantService to include region name enrichment and handle additional character IDs.
- Introduced a new `serializeNotificationForClient` function to format notifications for the client, ensuring all relevant data is included.
- Enhanced the MessagesDialog component to merge notification payloads and extract parameters more effectively, improving the clarity of displayed messages.
- Added new localization entries for director resignation risk and regional festival effects in multiple languages, ensuring comprehensive user notifications.
This commit is contained in:
Torsten Schulz (local)
2026-04-14 08:06:56 +02:00
parent 9deda3147e
commit 26daf5fed5
7 changed files with 230 additions and 43 deletions

View File

@@ -7328,7 +7328,7 @@ ORDER BY r.id`,
// Enrich notifications: parse JSON payloads and resolve character names
await enrichNotificationsWithCharacterNames(notifications);
return notifications;
return notifications.map(serializeNotificationForClient);
}
async getAllNotifications(hashedUserId, page = 1, size = 10) {
@@ -7344,7 +7344,12 @@ ORDER BY r.id`,
await enrichNotificationsWithCharacterNames(rows);
return { items: rows, total: count, page: Number(page) || 1, size: limit };
return {
items: rows.map(serializeNotificationForClient),
total: count,
page: Number(page) || 1,
size: limit,
};
}
async markNotificationsShown(hashedUserId) {
@@ -8536,6 +8541,14 @@ ORDER BY r.id`,
export default new FalukantService();
/** Stellt sicher, dass Anreicherungen (z. B. region_name ohne DB-Spalte) im API-JSON landen. */
function serializeNotificationForClient(row) {
const j = typeof row.toJSON === 'function' ? row.toJSON() : { ...row };
const dv = row.dataValues || {};
if (dv.region_name != null && j.region_name == null) j.region_name = dv.region_name;
return j;
}
// Helper: parse notifications for character references and attach characterName
async function enrichNotificationsWithCharacterNames(notifications) {
if (!Array.isArray(notifications) || notifications.length === 0) return;
@@ -8552,7 +8565,7 @@ async function enrichNotificationsWithCharacterNames(notifications) {
if (typeof obj !== 'object') return;
for (const [k, v] of Object.entries(obj)) {
if (!v) continue;
if (k === 'character_id' || k === 'characterId') {
if (k === 'character_id' || k === 'characterId' || k === 'director_character_id') {
charIds.add(Number(v));
continue;
}
@@ -8567,15 +8580,25 @@ async function enrichNotificationsWithCharacterNames(notifications) {
}
}
// First pass: collect all referenced character ids from notifications
const regionIds = new Set();
// First pass: collect all referenced character ids and region ids from notifications
for (const n of notifications) {
// parse n.tr if it's JSON
let parsed = null;
try {
if (typeof n.tr === 'string' && n.tr.trim().startsWith('{')) {
const parsed = JSON.parse(n.tr);
parsed = JSON.parse(n.tr);
collectIds(parsed);
} else if (n.tr && typeof n.tr === 'object') {
parsed = n.tr;
collectIds(parsed);
}
} catch (err) { /* ignore */ }
} catch (err) {
parsed = null;
}
if (parsed?.region_id != null) {
regionIds.add(Number(parsed.region_id));
}
// parse n.effects if present
try {
@@ -8591,24 +8614,34 @@ async function enrichNotificationsWithCharacterNames(notifications) {
}
const ids = Array.from(charIds).filter(Boolean);
if (!ids.length) return;
// Batch load characters and their display names
const characters = await FalukantCharacter.findAll({
where: { id: { [Op.in]: ids } },
include: [
{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] },
{ model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] }
],
attributes: ['id']
});
const regionIdList = Array.from(regionIds).filter((id) => Number.isFinite(id));
if (!ids.length && !regionIdList.length) return;
const nameMap = new Map();
for (const c of characters) {
const first = c.definedFirstName?.name || '';
const last = c.definedLastName?.name || '';
const display = `${first} ${last}`.trim() || null;
nameMap.set(Number(c.id), display || `#${c.id}`);
if (ids.length) {
const characters = await FalukantCharacter.findAll({
where: { id: { [Op.in]: ids } },
include: [
{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] },
{ model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] }
],
attributes: ['id']
});
for (const c of characters) {
const first = c.definedFirstName?.name || '';
const last = c.definedLastName?.name || '';
const display = `${first} ${last}`.trim() || null;
nameMap.set(Number(c.id), display || `#${c.id}`);
}
}
let regionNameById = new Map();
if (regionIdList.length) {
const regions = await RegionData.findAll({
where: { id: { [Op.in]: regionIdList } },
attributes: ['id', 'name'],
});
regionNameById = new Map(regions.map((r) => [Number(r.id), r.name]));
}
// helper to find first character id in an object
@@ -8625,6 +8658,7 @@ async function enrichNotificationsWithCharacterNames(notifications) {
for (const [k, v] of Object.entries(obj)) {
if (!v) continue;
if (k === 'character_id' || k === 'characterId') return Number(v);
if (k === 'director_character_id') return Number(v);
if (k === 'character' && typeof v === 'object') {
if (v.id) return Number(v.id);
const r = findFirstId(v);
@@ -8638,10 +8672,23 @@ async function enrichNotificationsWithCharacterNames(notifications) {
// Attach resolved name to notifications (set character_name; characterName is a getter that reads from it)
for (const n of notifications) {
let foundId = null;
let parsed = null;
try {
if (typeof n.tr === 'string' && n.tr.trim().startsWith('{')) {
const parsed = JSON.parse(n.tr);
parsed = JSON.parse(n.tr);
} else if (n.tr && typeof n.tr === 'object') {
parsed = n.tr;
}
} catch (err) {
parsed = null;
}
let foundId = null;
if (parsed?.director_character_id != null) {
foundId = Number(parsed.director_character_id);
}
try {
if (!foundId && parsed) {
foundId = findFirstId(parsed) || foundId;
}
} catch (err) { /* ignore */ }
@@ -8661,5 +8708,11 @@ async function enrichNotificationsWithCharacterNames(notifications) {
// Set character_name directly (characterName is a getter that reads from character_name)
n.character_name = resolved;
}
if (parsed?.region_id != null && regionNameById.has(Number(parsed.region_id))) {
const rn = regionNameById.get(Number(parsed.region_id));
// Kein DB-Feld: explizit in dataValues setzen, damit toJSON() es mitschickt
if (n.dataValues) n.dataValues.region_name = rn;
}
}
}

View File

@@ -138,6 +138,9 @@ export default {
if (!n || typeof n !== 'object') return n || {};
let merged = { ...n };
const raw = n.tr;
if (raw && typeof raw === 'object') {
merged = { ...merged, ...raw };
}
if (typeof raw === 'string') {
const trimmed = raw.trim();
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
@@ -151,6 +154,9 @@ export default {
}
}
}
if (merged.character_name && merged.characterName == null) {
merged.characterName = merged.character_name;
}
return merged;
},
@@ -228,10 +234,8 @@ export default {
try {
const titleKey = `${eventKey}.title`;
const descKey = `${eventKey}.description`;
// If no params were parsed from JSON, try to extract them from the notification (effects, character_id, etc.)
if ((!params || Object.keys(params).length === 0) && payload) {
params = this.extractParams(payload) || {};
}
const extracted = this.extractParams(payload) || {};
params = { ...extracted, ...params };
if (this.$te(titleKey) && this.$te(descKey)) {
const title = this.$t(titleKey, params);
@@ -243,6 +247,10 @@ export default {
}
}
// Immer Parameter aus dem vollständigen Payload (nach merge), z. B. director.resignation_risk_high
const extractedAll = this.extractParams(payload) || {};
params = { ...extractedAll, ...params };
// Fallback: Alte Methode für andere Notification-Typen
return this.$t(key, params);
},
@@ -312,6 +320,14 @@ export default {
extractParams(n) {
const base = this.mergeNotificationPayload(n);
const params = {};
const locale = this.$i18n?.locale || 'de';
const isGerman = String(locale).startsWith('de');
if (base.characterName) {
params.characterName = base.characterName;
} else if (base.character_name) {
params.characterName = base.character_name;
}
// Parameter aus effects extrahieren (Daemon: effects oft nur im JSON in tr)
if (base.effects && Array.isArray(base.effects)) {
@@ -326,11 +342,27 @@ export default {
} else if (effect.percent !== undefined) {
params.percent = effect.percent;
}
} else if (effect.type === 'price_change') {
if (effect.percent !== undefined && effect.percent !== null) {
const p = Number(effect.percent);
if (Number.isFinite(p)) {
params.priceChangePercent = `${p > 0 ? '+' : ''}${p.toFixed(1)}`;
}
}
} else if (effect.type === 'production_quality_change') {
if (effect.change !== undefined && effect.change !== null) {
const v = Number(effect.change);
if (Number.isFinite(v)) {
params.productionQualityChange = (v > 0 ? '+' : '') + (Number.isInteger(v) ? String(v) : v.toFixed(1));
}
}
} else if (effect.type === 'weather_change') {
params.hasWeatherChange = true;
} else if (effect.type === 'character_health_change') {
if (effect.character_id) {
// Prefer explicit characterName from notification, otherwise fall back to provided name or use id placeholder
params.character_id = effect.character_id;
params.characterName = params.characterName || base.characterName || `#${effect.character_id}`;
params.characterName = params.characterName || base.characterName || base.character_name || `#${effect.character_id}`;
}
if (effect.change !== undefined) {
params.change = effect.change;
@@ -352,9 +384,33 @@ export default {
}
// Weitere Parameter aus der Notification selbst
if (base.region_id && base.regionName) {
params.regionName = base.regionName;
if (base.region_id != null) {
if (base.regionName) {
params.regionName = base.regionName;
} else if (base.region_name) {
params.regionName = base.region_name;
}
}
// Daemon: Meta-Objekte (auch wenn effects[] schon Werte liefert — fehlende Werte ergänzen)
if (params.priceChangePercent == null && base.price_change && typeof base.price_change === 'object') {
const applied = base.price_change.applied !== false;
const pct = base.price_change.percent;
if (applied && pct != null && Number.isFinite(Number(pct))) {
const p = Number(pct);
params.priceChangePercent = `${p > 0 ? '+' : ''}${p.toFixed(1)}`;
}
}
if (params.productionQualityChange == null && base.production_quality && typeof base.production_quality === 'object') {
const applied = base.production_quality.applied !== false;
const ch = base.production_quality.change;
if (applied && ch != null && Number.isFinite(Number(ch))) {
const v = Number(ch);
params.productionQualityChange = (v > 0 ? '+' : '') + (Number.isInteger(v) ? String(v) : v.toFixed(1));
}
}
params.priceEffectLine = this.buildRegionalFestivalEffectsLine(params, isGerman);
if (base.character_id && base.characterName) {
params.characterName = base.characterName;
}
@@ -370,6 +426,13 @@ export default {
if (base.threshold_percent !== undefined) {
params.threshold_percent = base.threshold_percent;
}
const isDirectorResign = base.event === 'director_resignation_risk_high'
|| (typeof base.tr === 'string' && base.tr.includes('director.resignation'));
if (isDirectorResign) {
params.directorName = params.characterName
|| base.character_name
|| (base.director_character_id != null ? `#${base.director_character_id}` : '');
}
if (base.director_id !== undefined) {
params.director_id = base.director_id;
}
@@ -440,7 +503,48 @@ export default {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(amount);
}
},
/**
* Zeilen für regionale Events (Fest o. ä.): Wetter, Preis, Produktionsqualität — aus effects[] und/oder price_change / production_quality.
*/
buildRegionalFestivalEffectsLine(params, isGerman) {
const parts = [];
const kw = 'falukant.notifications.random_event.effects.festival_weather_line';
const kp = 'falukant.notifications.random_event.effects.festival_price_line';
const kq = 'falukant.notifications.random_event.effects.festival_quality_line';
if (params.hasWeatherChange) {
if (this.$te(kw)) parts.push(String(this.$t(kw)));
else parts.push(isGerman ? 'Wetter in der Region schlägt um.' : 'The regional weather shifts.');
}
if (params.priceChangePercent != null) {
if (this.$te(kp)) {
parts.push(String(this.$t(kp, { percent: params.priceChangePercent })));
} else {
const legacy = 'falukant.notifications.random_event.effects.price_effect_suffix';
parts.push(
this.$te(legacy)
? String(this.$t(legacy, { percent: params.priceChangePercent }))
: (isGerman
? `Warenpreise etwa ${params.priceChangePercent} %.`
: `Goods prices about ${params.priceChangePercent}%.`)
);
}
}
if (params.productionQualityChange != null) {
if (this.$te(kq)) {
parts.push(String(this.$t(kq, { change: params.productionQualityChange })));
} else {
parts.push(
isGerman
? `Produktionsqualität etwa ${params.productionQualityChange}.`
: `Production quality about ${params.productionQualityChange}.`
);
}
}
if (!parts.length) return '';
return ` ${parts.join(' ')}`;
},
},
computed: {
totalPages() {

View File

@@ -35,7 +35,7 @@
"notify_election_created": "Giskedyul ang usa ka bag-ong eleksiyon.",
"notify_office_filled": "Na puno ang usa ka politikal nga opisina.",
"director": {
"resignation_risk_high": "Taas ang risgo nga mubiyaa ang direktor: risgo {risk_percent}% (threshold {threshold_percent}%). Karon nga satisfaction {satisfaction}."
"resignation_risk_high": "Taas ang risgo nga mobiya ang direktor nga si {directorName}: risgo {risk_percent}% (threshold {threshold_percent}%). Karon nga satisfaction {satisfaction}."
},
"director_death": "Namatay si {characterName} sa edad nga {ageYears}. Isip amo, kinahanglan kang magtudlo og bag-ong direktor.{regionLabel}{spouses}{children}{lovers}",
"relationship_death": "Namatay si {characterName} sa edad nga {ageYears}.{regionLabel}{spouses}{children}{lovers}",
@@ -93,7 +93,13 @@
},
"regional_festival": {
"title": "Pista sa rehiyon",
"description": "Adunay dakong pista sa rehiyon nga {regionName}."
"description": "Adunay dakong pista sa rehiyon nga {regionName}.{priceEffectLine}"
},
"effects": {
"price_effect_suffix": " Epekto: gipaabot nga pagbag-o sa presyo sa mga paliton nga ~{percent}%.",
"festival_weather_line": "Ang panahon sa rehiyon nagbag-o.",
"festival_price_line": "Presyo sa mga paliton ~{percent}%.",
"festival_quality_line": "Kalidad sa produksyon ~{change}."
},
"regional_epidemic": {
"title": "Epidemya",

View File

@@ -49,7 +49,7 @@
"notify_election_created": "Es wurde eine neue Wahl ausgeschrieben.",
"notify_office_filled": "Ein politisches Amt wurde neu besetzt.",
"director": {
"resignation_risk_high": "Hohe Kündigungsgefahr bei einem Direktor: Risiko {risk_percent}% (Schwelle {threshold_percent}%). Zufriedenheit aktuell {satisfaction}."
"resignation_risk_high": "Hohe Kündigungsgefahr für den Direktor {directorName}: Risiko {risk_percent} % (Schwelle {threshold_percent} %). Zufriedenheit aktuell {satisfaction}."
},
"director_death": "{characterName} ist im Alter von {ageYears} Jahren verstorben. Als Arbeitgeber musst du die Direktion neu besetzen.{regionLabel}{spouses}{children}{lovers}",
"relationship_death": "{characterName} ist im Alter von {ageYears} Jahren verstorben.{regionLabel}{spouses}{children}{lovers}",
@@ -95,7 +95,13 @@
},
"regional_festival": {
"title": "Regionales Fest",
"description": "Ein großes Fest findet in der Region {regionName} statt."
"description": "Ein großes Fest findet in der Region {regionName} statt.{priceEffectLine}"
},
"effects": {
"price_effect_suffix": " Auswirkung: erwartete Warenpreisänderung ca. {percent} %.",
"festival_weather_line": "Wetter in der Region schlägt um.",
"festival_price_line": "Warenpreise etwa {percent} %.",
"festival_quality_line": "Produktionsqualität etwa {change}."
},
"regional_epidemic": {
"title": "Epidemie",

View File

@@ -35,7 +35,7 @@
"notify_election_created": "A new election has been scheduled.",
"notify_office_filled": "A political office has been filled.",
"director": {
"resignation_risk_high": "High resignation risk for a director: risk {risk_percent}% (threshold {threshold_percent}%). Current satisfaction {satisfaction}."
"resignation_risk_high": "High resignation risk for director {directorName}: risk {risk_percent}% (threshold {threshold_percent}%). Current satisfaction {satisfaction}."
},
"director_death": "{characterName} died at the age of {ageYears}. As employer you need to appoint a new director.{regionLabel}{spouses}{children}{lovers}",
"relationship_death": "{characterName} died at the age of {ageYears}.{regionLabel}{spouses}{children}{lovers}",
@@ -93,7 +93,13 @@
},
"regional_festival": {
"title": "Regional Festival",
"description": "A large festival is taking place in the region {regionName}."
"description": "A large festival is taking place in the region {regionName}.{priceEffectLine}"
},
"effects": {
"price_effect_suffix": " Effect: expected goods price change ~{percent}%.",
"festival_weather_line": "The regional weather shifts.",
"festival_price_line": "Goods prices about {percent}%.",
"festival_quality_line": "Production quality about {change}."
},
"regional_epidemic": {
"title": "Epidemic",

View File

@@ -49,7 +49,7 @@
"notify_election_created": "Se ha convocado una nueva elección.",
"notify_office_filled": "Se ha cubierto un cargo político.",
"director": {
"resignation_risk_high": "Alto riesgo de renuncia de un director: riesgo {risk_percent}% (umbral {threshold_percent}%). Satisfacción actual {satisfaction}."
"resignation_risk_high": "Alto riesgo de renuncia del director {directorName}: riesgo {risk_percent}% (umbral {threshold_percent}%). Satisfacción actual {satisfaction}."
},
"director_death": "{characterName} ha fallecido a la edad de {ageYears} años. Como empleador debes nombrar un nuevo director.{regionLabel}{spouses}{children}{lovers}",
"relationship_death": "{characterName} ha fallecido a la edad de {ageYears} años.{regionLabel}{spouses}{children}{lovers}",
@@ -95,7 +95,13 @@
},
"regional_festival": {
"title": "Fiesta regional",
"description": "Se celebra una gran fiesta en la región {regionName}."
"description": "Se celebra una gran fiesta en la región {regionName}.{priceEffectLine}"
},
"effects": {
"price_effect_suffix": " Efecto: variación esperada del precio de las mercancías ~{percent}%.",
"festival_weather_line": "El tiempo en la región cambia.",
"festival_price_line": "Precios de mercancías ~{percent} %.",
"festival_quality_line": "Calidad de producción ~{change}."
},
"regional_epidemic": {
"title": "Epidemia",

View File

@@ -49,7 +49,7 @@
"notify_election_created": "Une nouvelle élection a été déclenchée.",
"notify_office_filled": "Une fonction politique a été pourvue.",
"director": {
"resignation_risk_high": "Risque élevé de démission dun directeur : risque {risk_percent}% (seuil {threshold_percent}%). Satisfaction actuelle {satisfaction}."
"resignation_risk_high": "Risque élevé de démission du directeur {directorName} : risque {risk_percent}% (seuil {threshold_percent}%). Satisfaction actuelle {satisfaction}."
},
"director_death": "{characterName} est décédé à l'âge de {ageYears}. En tant qu'employeur, vous devez remplir le conseil d'administration.{regionLabel}{spouses}{children}{lovers}",
"relationship_death": "{characterName} est décédé à l'âge de {ageYears}.{regionLabel}{spouses}{children}{lovers}",
@@ -95,7 +95,13 @@
},
"regional_festival": {
"title": "Fête régionale",
"description": "Un grand festival a lieu dans la région {regionName}."
"description": "Un grand festival a lieu dans la région {regionName}.{priceEffectLine}"
},
"effects": {
"price_effect_suffix": " Effet : variation attendue des prix des marchandises ~{percent} %.",
"festival_weather_line": "Le temps dans la région change.",
"festival_price_line": "Prix des marchandises ~{percent} %.",
"festival_quality_line": "Qualité de production ~{change}."
},
"regional_epidemic": {
"title": "Épidémie",