CentralBackend/repositories/settings/I18nPreferencesRepository.js
seaznCode 427c12be3c feat: add language normalization and user settings updates
- Introduced language normalization utility functions to standardize language codes across the application.
- Updated ContractUploadController to resolve requested language from contract data and user settings.
- Enhanced authMiddleware to set preferred language in user object based on user settings.
- Added preferred_language column to user_settings table in the database.
- Implemented UserSettingsRepository to manage user settings, including preferred language updates.
- Updated DocumentTemplateService and AboContractService to support language-specific templates.
- Enhanced InvoiceService to select invoice templates based on normalized language codes.
- Added new script to compare versions of ABO contract documents.
- Refactored various services and repositories to utilize the new language normalization logic.
2026-06-07 21:13:41 +02:00

442 lines
14 KiB
JavaScript

const db = require('../../database/database');
const { mergeLanguageDescriptors } = require('../../utils/languageUtils');
class I18nPreferencesRepository {
_safeJsonArray(value) {
if (Array.isArray(value)) return value;
if (value == null) return [];
try {
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
return Array.isArray(parsed) ? parsed : [];
} catch (_) {
return [];
}
}
_normalizeRow(row) {
const categories = this._safeJsonArray(row?.categories_json);
const globalKeys = this._safeJsonArray(row?.global_keys_json);
return {
categories,
globalKeys,
};
}
_safeBoolean(value, fallback) {
if (value === undefined || value === null) return fallback;
if (typeof value === 'boolean') return value;
const normalized = String(value).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
return fallback;
}
async get() {
const [rows] = await db.query('SELECT * FROM i18n_preferences WHERE id = 1 LIMIT 1');
if (!rows.length) {
return { categories: [], globalKeys: [] };
}
return this._normalizeRow(rows[0]);
}
async listLanguages({ enabledOnly = true } = {}) {
try {
const [rows] = await db.query(
`SELECT language_code AS languageCode,
label,
is_enabled AS isEnabled,
is_custom AS isCustom
FROM i18n_languages
${enabledOnly ? 'WHERE is_enabled = 1' : ''}
ORDER BY language_code`
);
const languages = mergeLanguageDescriptors(rows || []);
return enabledOnly ? languages.filter((entry) => entry.isEnabled !== false) : languages;
} catch (_) {
return mergeLanguageDescriptors([]);
}
}
async upsert({ categories, globalKeys, updatedByUserId } = {}) {
const current = await this.get();
const nextCategories = categories !== undefined ? categories : current.categories;
const nextGlobalKeys = globalKeys !== undefined ? globalKeys : current.globalKeys;
await db.query(
`INSERT INTO i18n_preferences (id, categories_json, global_keys_json, updated_by_user_id)
VALUES (1, ?, ?, ?)
ON DUPLICATE KEY UPDATE
categories_json = VALUES(categories_json),
global_keys_json = VALUES(global_keys_json),
updated_by_user_id = VALUES(updated_by_user_id)`,
[JSON.stringify(nextCategories || []), JSON.stringify(nextGlobalKeys || []), updatedByUserId || null]
);
return this.get();
}
async clear(updatedByUserId) {
await db.query(
`INSERT INTO i18n_preferences (id, categories_json, global_keys_json, updated_by_user_id)
VALUES (1, ?, ?, ?)
ON DUPLICATE KEY UPDATE
categories_json = VALUES(categories_json),
global_keys_json = VALUES(global_keys_json),
updated_by_user_id = VALUES(updated_by_user_id)`,
[JSON.stringify([]), JSON.stringify([]), updatedByUserId || null]
);
return this.get();
}
async _tableExists(conn, tableName) {
const [rows] = await conn.query(
`SELECT 1
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
LIMIT 1`,
[tableName]
);
return rows.length > 0;
}
async _columnExists(conn, tableName, columnName) {
const [rows] = await conn.query(
`SELECT 1
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND COLUMN_NAME = ?
LIMIT 1`,
[tableName, columnName]
);
return rows.length > 0;
}
async deleteLanguageEntries(languageCode, updatedByUserId) {
const conn = await db.getConnection();
const safeLanguageCode = String(languageCode || '').trim();
const targets = [
// Language metadata
{ table: 'i18n_languages', possibleColumns: ['language_code', 'lang', 'code'] },
{ table: 'i18n_language_metadata', possibleColumns: ['language_code', 'lang', 'code'] },
// Translation/custom-value stores
{ table: 'i18n_translation_overrides', possibleColumns: ['language_code', 'lang'] },
{ table: 'i18n_translations', possibleColumns: ['language_code', 'lang'] },
// Potential language-scoped preference/link tables
{ table: 'i18n_preferences_languages', possibleColumns: ['language_code', 'lang'] },
{ table: 'i18n_preference_categories', possibleColumns: ['language_code', 'lang'] },
{ table: 'i18n_preference_global_keys', possibleColumns: ['language_code', 'lang'] },
];
let deletedRows = 0;
const touchedTables = [];
try {
await conn.beginTransaction();
for (const target of targets) {
const exists = await this._tableExists(conn, target.table);
if (!exists) continue;
let deleteColumn = null;
for (const col of target.possibleColumns) {
if (await this._columnExists(conn, target.table, col)) {
deleteColumn = col;
break;
}
}
if (!deleteColumn) continue;
const [result] = await conn.query(
`DELETE FROM \`${target.table}\` WHERE \`${deleteColumn}\` = ?`,
[safeLanguageCode]
);
const affected = Number(result?.affectedRows || 0);
if (affected > 0) {
deletedRows += affected;
touchedTables.push(target.table);
}
}
if (await this._tableExists(conn, 'i18n_preferences')) {
await conn.query(
`UPDATE i18n_preferences
SET updated_by_user_id = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = 1`,
[updatedByUserId || null]
);
}
await conn.commit();
return { deletedRows, touchedTables };
} catch (error) {
await conn.rollback();
throw error;
} finally {
conn.release();
}
}
async upsertLanguage(conn, language, updatedByUserId) {
if (!language || !language.languageCode) return null;
const languageCode = String(language.languageCode).trim().toLowerCase();
const label = String(language.label || languageCode).trim();
const isEnabled = this._safeBoolean(language.isEnabled, true);
const isCustom = this._safeBoolean(language.isCustom, true);
await conn.query(
`INSERT INTO i18n_languages (language_code, label, is_enabled, is_custom, created_by_user_id, updated_by_user_id)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
label = VALUES(label),
is_enabled = VALUES(is_enabled),
is_custom = VALUES(is_custom),
updated_by_user_id = VALUES(updated_by_user_id)`,
[languageCode, label, isEnabled ? 1 : 0, isCustom ? 1 : 0, updatedByUserId || null, updatedByUserId || null]
);
const [rows] = await conn.query(
`SELECT language_code AS languageCode,
label,
is_enabled AS isEnabled,
is_custom AS isCustom,
created_at AS createdAt,
updated_at AS updatedAt
FROM i18n_languages
WHERE language_code = ?
LIMIT 1`,
[languageCode]
);
return rows[0] || null;
}
async upsertTranslationOverrides(conn, translationEntries, updatedByUserId) {
if (!Array.isArray(translationEntries) || !translationEntries.length) {
return { upsertedCount: 0 };
}
let upsertedCount = 0;
for (const entry of translationEntries) {
const languageCode = String(entry.languageCode || '').trim().toLowerCase();
const namespace = String(entry.namespace || '').trim();
const key = String(entry.key || '').trim();
const value = entry.value == null ? '' : String(entry.value);
const isCustom = this._safeBoolean(entry.isCustom, true);
if (!languageCode || !namespace || !key) continue;
await conn.query(
`INSERT INTO i18n_translation_overrides
(language_code, namespace, t_key, t_value, is_custom, created_by_user_id, updated_by_user_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
t_value = VALUES(t_value),
is_custom = VALUES(is_custom),
updated_by_user_id = VALUES(updated_by_user_id)`,
[
languageCode,
namespace,
key,
value,
isCustom ? 1 : 0,
updatedByUserId || null,
updatedByUserId || null,
]
);
upsertedCount += 1;
}
return { upsertedCount };
}
async upsertBundle({ categories, globalKeys, language, translations, updatedByUserId } = {}) {
const conn = await db.getConnection();
try {
await conn.beginTransaction();
const [prefRows] = await conn.query('SELECT * FROM i18n_preferences WHERE id = 1 LIMIT 1');
const current = prefRows.length
? this._normalizeRow(prefRows[0])
: { categories: [], globalKeys: [] };
const nextCategories = categories !== undefined ? categories : current.categories;
const nextGlobalKeys = globalKeys !== undefined ? globalKeys : current.globalKeys;
await conn.query(
`INSERT INTO i18n_preferences (id, categories_json, global_keys_json, updated_by_user_id)
VALUES (1, ?, ?, ?)
ON DUPLICATE KEY UPDATE
categories_json = VALUES(categories_json),
global_keys_json = VALUES(global_keys_json),
updated_by_user_id = VALUES(updated_by_user_id)`,
[JSON.stringify(nextCategories || []), JSON.stringify(nextGlobalKeys || []), updatedByUserId || null]
);
const languagesToEnsure = new Map();
if (language && language.languageCode) {
languagesToEnsure.set(String(language.languageCode).toLowerCase(), language);
}
for (const t of (Array.isArray(translations) ? translations : [])) {
const code = String(t.languageCode || '').trim().toLowerCase();
if (!code) continue;
if (!languagesToEnsure.has(code)) {
languagesToEnsure.set(code, {
languageCode: code,
label: code.toUpperCase(),
isEnabled: true,
isCustom: true,
});
}
}
let upsertedLanguage = null;
for (const [, langPayload] of languagesToEnsure) {
const row = await this.upsertLanguage(conn, langPayload, updatedByUserId);
if (language && row && row.languageCode === String(language.languageCode).toLowerCase()) {
upsertedLanguage = row;
}
}
const translationResult = await this.upsertTranslationOverrides(conn, translations, updatedByUserId);
const [savedRows] = await conn.query('SELECT * FROM i18n_preferences WHERE id = 1 LIMIT 1');
const preferences = savedRows.length
? this._normalizeRow(savedRows[0])
: { categories: [], globalKeys: [] };
await conn.commit();
return {
preferences,
language: upsertedLanguage,
translationsUpserted: translationResult.upsertedCount,
};
} catch (error) {
await conn.rollback();
throw error;
} finally {
conn.release();
}
}
async listTranslations({ languageCode, namespace } = {}) {
const filters = [];
const params = [];
if (languageCode) {
filters.push('language_code = ?');
params.push(String(languageCode).trim().toLowerCase());
}
if (namespace) {
filters.push('namespace = ?');
params.push(String(namespace).trim());
}
const whereClause = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
const [rows] = await db.query(
`SELECT language_code AS languageCode,
namespace,
t_key AS \`key\`,
t_value AS value,
is_custom AS isCustom,
updated_at AS updatedAt
FROM i18n_translation_overrides
${whereClause}
ORDER BY language_code, namespace, t_key`,
params
);
return rows || [];
}
async upsertTranslations({ translations, updatedByUserId } = {}) {
const conn = await db.getConnection();
try {
await conn.beginTransaction();
// Make sure every translation language exists in metadata table.
const ensured = new Set();
for (const t of (translations || [])) {
const code = String(t.languageCode || '').trim().toLowerCase();
if (!code || ensured.has(code)) continue;
await this.upsertLanguage(
conn,
{
languageCode: code,
label: code.toUpperCase(),
isEnabled: true,
isCustom: true,
},
updatedByUserId
);
ensured.add(code);
}
const result = await this.upsertTranslationOverrides(conn, translations || [], updatedByUserId);
await conn.commit();
return result;
} catch (error) {
await conn.rollback();
throw error;
} finally {
conn.release();
}
}
async getScanSummary({ languageCode } = {}) {
const code = languageCode ? String(languageCode).trim().toLowerCase() : null;
const [languages] = await db.query(
`SELECT language_code AS languageCode,
label,
is_enabled AS isEnabled,
is_custom AS isCustom
FROM i18n_languages
ORDER BY language_code`
);
const [namespaces] = await db.query(
`SELECT DISTINCT namespace
FROM i18n_translation_overrides
${code ? 'WHERE language_code = ?' : ''}
ORDER BY namespace`,
code ? [code] : []
);
const [countsByLanguage] = await db.query(
`SELECT language_code AS languageCode, COUNT(*) AS entryCount
FROM i18n_translation_overrides
${code ? 'WHERE language_code = ?' : ''}
GROUP BY language_code
ORDER BY language_code`,
code ? [code] : []
);
const prefs = await this.get();
const categoryNamespaces = Array.isArray(prefs.categories)
? [...new Set(prefs.categories.flatMap((c) => (Array.isArray(c?.namespaces) ? c.namespaces : [])))]
: [];
return {
languages: languages || [],
namespaces: (namespaces || []).map((r) => r.namespace),
countsByLanguage: countsByLanguage || [],
categories: prefs.categories || [],
globalKeys: prefs.globalKeys || [],
categoryNamespaces,
};
}
}
module.exports = I18nPreferencesRepository;