const I18nPreferencesRepository = require('../../repositories/settings/I18nPreferencesRepository'); const { logger } = require('../../middleware/logger'); const repo = new I18nPreferencesRepository(); class I18nPreferencesController { static _normalizeLanguageCode(value) { const raw = String(value == null ? '' : value).trim(); if (!raw) return ''; const normalized = raw.replace('_', '-'); const pattern = /^[a-z]{2,3}(-[a-z0-9]{2,8})?$/i; if (!pattern.test(normalized)) return null; return normalized.toLowerCase(); } static _normalizeStringArray(values) { if (!Array.isArray(values)) return []; const normalized = values .map((v) => (v == null ? '' : String(v).trim())) .filter(Boolean); return [...new Set(normalized)]; } static _normalizeCategories(categories) { if (!Array.isArray(categories)) return []; return categories .map((item, idx) => { const id = String(item?.id ?? '').trim() || `category_${idx + 1}`; const label = String(item?.label ?? id).trim() || id; const namespaces = I18nPreferencesController._normalizeStringArray(item?.namespaces); const isCustom = Boolean(item?.isCustom); return { id, label, namespaces, isCustom }; }); } static _buildResponse(preferences) { return { ok: true, preferences, categories: preferences.categories, globalKeys: preferences.globalKeys, }; } static _normalizeLanguagePayload(body) { const source = (body?.language && typeof body.language === 'object') ? body.language : body; const rawLanguageCode = source?.languageCode ?? source?.code ?? source?.lang; const hasAnyLanguageField = rawLanguageCode !== undefined || source?.label !== undefined || source?.name !== undefined || source?.isEnabled !== undefined || source?.isCustom !== undefined; if (!hasAnyLanguageField) return null; const languageCode = I18nPreferencesController._normalizeLanguageCode(rawLanguageCode); if (languageCode === null || !languageCode) { throw new Error('Invalid language payload: languageCode is required'); } const label = String(source?.label ?? source?.name ?? languageCode.toUpperCase()).trim(); const toBool = (v, fallback) => { if (v === undefined || v === null) return fallback; if (typeof v === 'boolean') return v; const n = String(v).trim().toLowerCase(); if (['1', 'true', 'yes', 'on'].includes(n)) return true; if (['0', 'false', 'no', 'off'].includes(n)) return false; return fallback; }; return { languageCode, label, isEnabled: toBool(source?.isEnabled, true), isCustom: toBool(source?.isCustom, true), }; } static _normalizeTranslationsPayload(body, fallbackLanguageCode) { const raw = body?.translations ?? body?.translationOverrides ?? body?.entries; if (raw == null) return []; const normalized = []; if (Array.isArray(raw)) { for (const item of raw) { const languageCode = I18nPreferencesController._normalizeLanguageCode( item?.languageCode ?? item?.lang ?? fallbackLanguageCode ); const namespace = String(item?.namespace ?? '').trim(); const key = String(item?.key ?? item?.t_key ?? '').trim(); const value = item?.value ?? item?.t_value; if (!languageCode || !namespace || !key || value === undefined) continue; normalized.push({ languageCode, namespace, key, value: String(value), isCustom: item?.isCustom, }); } return normalized; } if (typeof raw === 'object') { for (const [namespace, kv] of Object.entries(raw)) { if (!kv || typeof kv !== 'object') continue; for (const [key, value] of Object.entries(kv)) { const languageCode = I18nPreferencesController._normalizeLanguageCode(fallbackLanguageCode); if (!languageCode || !namespace || !key || value === undefined) continue; normalized.push({ languageCode, namespace: String(namespace).trim(), key: String(key).trim(), value: String(value), isCustom: true, }); } } } return normalized; } static _extractPreferencesPayload(body) { const source = (body?.preferences && typeof body.preferences === 'object') ? body.preferences : body; const hasCategories = Object.prototype.hasOwnProperty.call(source || {}, 'categories'); const hasGlobalKeys = Object.prototype.hasOwnProperty.call(source || {}, 'globalKeys'); return { categories: hasCategories ? I18nPreferencesController._normalizeCategories(source?.categories) : undefined, globalKeys: hasGlobalKeys ? I18nPreferencesController._normalizeStringArray(source?.globalKeys) : undefined, }; } static async _upsertBundle(req, res) { try { const userId = req.user?.userId ?? req.user?.id ?? null; const language = I18nPreferencesController._normalizeLanguagePayload(req.body || {}); const fallbackLanguageCode = language?.languageCode || I18nPreferencesController._normalizeLanguageCode(req.body?.languageCode ?? req.body?.lang); const translations = I18nPreferencesController._normalizeTranslationsPayload(req.body || {}, fallbackLanguageCode); const extracted = I18nPreferencesController._extractPreferencesPayload(req.body || {}); const result = await repo.upsertBundle({ categories: extracted.categories, globalKeys: extracted.globalKeys, language, translations, updatedByUserId: userId, }); return res.status(200).json({ ...I18nPreferencesController._buildResponse(result.preferences), language: result.language || null, translationsUpserted: result.translationsUpserted || 0, }); } catch (error) { if (String(error?.message || '').includes('Invalid language payload')) { return res.status(400).json({ ok: false, message: error.message }); } logger.error('i18nPreferences:upsertBundle:failed', { error: error?.message }); return res.status(500).json({ ok: false, message: 'Failed to upsert i18n language data' }); } } static async get(req, res) { try { const preferences = await repo.get(); return res.status(200).json(I18nPreferencesController._buildResponse(preferences)); } catch (error) { logger.error('i18nPreferences:get:failed', { error: error?.message }); return res.status(500).json({ ok: false, message: 'Failed to load i18n preferences' }); } } static async post(req, res) { return I18nPreferencesController._upsertBundle(req, res); } static async put(req, res) { return I18nPreferencesController._upsertBundle(req, res); } static async delete(req, res) { try { const requestedLanguageCode = I18nPreferencesController._normalizeLanguageCode(req.query?.languageCode); if (requestedLanguageCode === null) { return res.status(400).json({ ok: false, message: 'Invalid languageCode query parameter' }); } if (requestedLanguageCode) { const result = await repo.deleteLanguageEntries( requestedLanguageCode, req.user?.userId ?? req.user?.id ?? null ); return res.status(200).json({ ok: true, languageCode: requestedLanguageCode, deletedRows: result.deletedRows, touchedTables: result.touchedTables, }); } const hasBodyReplacement = req.body && ( Object.prototype.hasOwnProperty.call(req.body, 'categories') || Object.prototype.hasOwnProperty.call(req.body, 'globalKeys') ); const preferences = hasBodyReplacement ? await repo.upsert({ categories: I18nPreferencesController._normalizeCategories(req.body?.categories), globalKeys: I18nPreferencesController._normalizeStringArray(req.body?.globalKeys), updatedByUserId: req.user?.userId ?? req.user?.id ?? null, }) : await repo.clear(req.user?.userId ?? req.user?.id ?? null); return res.status(200).json(I18nPreferencesController._buildResponse(preferences)); } catch (error) { logger.error('i18nPreferences:delete:failed', { error: error?.message }); return res.status(500).json({ ok: false, message: 'Failed to delete i18n preferences' }); } } static async getTranslations(req, res) { try { const languageCodeRaw = req.query?.languageCode ?? req.query?.lang; const normalizedLanguageCode = languageCodeRaw ? I18nPreferencesController._normalizeLanguageCode(languageCodeRaw) : ''; if (normalizedLanguageCode === null) { return res.status(400).json({ ok: false, message: 'Invalid languageCode query parameter' }); } const namespace = String(req.query?.namespace ?? '').trim() || undefined; const translations = await repo.listTranslations({ languageCode: normalizedLanguageCode || undefined, namespace, }); return res.status(200).json({ ok: true, translations }); } catch (error) { logger.error('i18nTranslations:get:failed', { error: error?.message }); return res.status(500).json({ ok: false, message: 'Failed to load translations' }); } } static async upsertTranslations(req, res) { try { const language = I18nPreferencesController._normalizeLanguagePayload(req.body || {}); const fallbackLanguageCode = language?.languageCode || I18nPreferencesController._normalizeLanguageCode(req.body?.languageCode ?? req.body?.lang); const translations = I18nPreferencesController._normalizeTranslationsPayload(req.body || {}, fallbackLanguageCode); if (!translations.length) { return res.status(400).json({ ok: false, message: 'No valid translations provided' }); } const userId = req.user?.userId ?? req.user?.id ?? null; const result = await repo.upsertTranslations({ translations, updatedByUserId: userId }); if (language) { await repo.upsertBundle({ language, updatedByUserId: userId }); } return res.status(200).json({ ok: true, translationsUpserted: result.upsertedCount || 0, }); } catch (error) { if (String(error?.message || '').includes('Invalid language payload')) { return res.status(400).json({ ok: false, message: error.message }); } logger.error('i18nTranslations:upsert:failed', { error: error?.message }); return res.status(500).json({ ok: false, message: 'Failed to upsert translations' }); } } static async scan(req, res) { try { const languageCodeRaw = req.query?.languageCode ?? req.query?.lang ?? req.body?.languageCode ?? req.body?.lang; const normalizedLanguageCode = languageCodeRaw ? I18nPreferencesController._normalizeLanguageCode(languageCodeRaw) : ''; if (normalizedLanguageCode === null) { return res.status(400).json({ ok: false, message: 'Invalid languageCode parameter' }); } const summary = await repo.getScanSummary({ languageCode: normalizedLanguageCode || undefined }); return res.status(200).json({ ok: true, ...summary, }); } catch (error) { logger.error('i18nScan:failed', { error: error?.message }); return res.status(500).json({ ok: false, message: 'Failed to scan i18n data' }); } } } module.exports = I18nPreferencesController;