From 427c12be3c7022c16aab49fb66dacdd6897fadb0 Mon Sep 17 00:00:00 2001 From: seaznCode Date: Sun, 7 Jun 2026 21:13:41 +0200 Subject: [PATCH] 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. --- controller/auth/UserSettingsController.js | 56 +++++ .../DocumentTemplateController.js | 73 ++++-- .../documents/ContractUploadController.js | 20 ++ database/createDb.js | 3 + middleware/authMiddleware.js | 21 ++ .../settings/I18nPreferencesRepository.js | 20 ++ .../settings/UserSettingsRepository.js | 30 ++- .../template/DocumentTemplateRepository.js | 5 +- routes/putRoutes.js | 6 + scripts/compareAboContractDocxVersions.js | 223 ++++++++++++++++++ services/abonemments/AboContractService.js | 26 +- services/contracts/ContractUploadService.js | 35 ++- services/invoice/InvoiceService.js | 68 ++++-- services/pool/PoolInflowService.js | 82 ++++++- services/template/DocumentTemplateService.js | 30 ++- utils/languageUtils.js | 89 +++++++ 16 files changed, 718 insertions(+), 69 deletions(-) create mode 100644 scripts/compareAboContractDocxVersions.js create mode 100644 utils/languageUtils.js diff --git a/controller/auth/UserSettingsController.js b/controller/auth/UserSettingsController.js index beb7d1d..18625d9 100644 --- a/controller/auth/UserSettingsController.js +++ b/controller/auth/UserSettingsController.js @@ -1,6 +1,21 @@ const UnitOfWork = require('../../database/UnitOfWork'); const UserSettingsRepository = require('../../repositories/settings/UserSettingsRepository'); +const I18nPreferencesRepository = require('../../repositories/settings/I18nPreferencesRepository'); const { logger } = require('../../middleware/logger'); // <-- import logger +const { normalizeLanguageCode } = require('../../utils/languageUtils'); + +const i18nPreferencesRepository = new I18nPreferencesRepository(); + +async function resolvePreferredLanguage(rawValue) { + if (rawValue === undefined) return undefined; + if (rawValue === null || String(rawValue).trim() === '') return null; + + const normalized = normalizeLanguageCode(rawValue); + if (!normalized) return ''; + + const languages = await i18nPreferencesRepository.listLanguages({ enabledOnly: true }); + return languages.some((entry) => entry.languageCode === normalized) ? normalized : ''; +} class UserSettingsController { static async getSettings(req, res) { @@ -24,6 +39,47 @@ class UserSettingsController { res.status(500).json({ success: false, message: error.message }); } } + + static async updateSettings(req, res) { + const userId = req.user.userId; + logger.info(`[UserSettingsController] updateSettings called for userId: ${userId}`); + + const rawPreferredLanguage = req.body?.preferredLanguage + ?? req.body?.preferred_language + ?? req.body?.language + ?? req.body?.lang; + + if (rawPreferredLanguage === undefined) { + return res.status(400).json({ + success: false, + message: 'No supported settings payload provided', + }); + } + + const preferredLanguage = await resolvePreferredLanguage(rawPreferredLanguage); + if (preferredLanguage === '') { + return res.status(400).json({ + success: false, + message: 'Invalid preferred language', + }); + } + + const unitOfWork = new UnitOfWork(); + await unitOfWork.start(); + try { + const repo = new UserSettingsRepository(unitOfWork); + const settings = await repo.updatePreferredLanguage(userId, preferredLanguage, unitOfWork); + await unitOfWork.commit(); + logger.info(`[UserSettingsController] updateSettings success for userId: ${userId}`, { + preferredLanguage: settings?.preferred_language || null, + }); + return res.json({ success: true, settings }); + } catch (error) { + logger.error(`[UserSettingsController] updateSettings error for userId: ${userId}`, { error }); + await unitOfWork.rollback(error); + return res.status(500).json({ success: false, message: error.message }); + } + } } module.exports = UserSettingsController; diff --git a/controller/documentTemplate/DocumentTemplateController.js b/controller/documentTemplate/DocumentTemplateController.js index 4749b7e..c33e47d 100644 --- a/controller/documentTemplate/DocumentTemplateController.js +++ b/controller/documentTemplate/DocumentTemplateController.js @@ -12,7 +12,11 @@ const db = require('../../database/database'); const UnitOfWork = require('../../database/UnitOfWork'); const { logger } = require('../../middleware/logger'); const CompanyStampService = require('../../services/stamp/company/CompanyStampService'); +const I18nPreferencesRepository = require('../../repositories/settings/I18nPreferencesRepository'); const { s3: sharedExoscaleClient } = require('../../utils/exoscaleUploader'); +const { normalizeLanguageCode, resolveDocumentTemplateStorageFolder } = require('../../utils/languageUtils'); + +const i18nPreferencesRepository = new I18nPreferencesRepository(); // Ensure debug directory exists and helper to save files function ensureDebugDir() { @@ -41,6 +45,29 @@ function normalizeInvoiceTaxMode(rawValue) { return ['standard', 'reverse_charge', 'both'].includes(normalized) ? normalized : 'both'; } +async function resolveSupportedTemplateLanguage(rawValue) { + const normalized = normalizeLanguageCode(rawValue); + if (!normalized) return null; + + const languages = await i18nPreferencesRepository.listLanguages({ enabledOnly: true }); + return languages.some((entry) => entry.languageCode === normalized) ? normalized : null; +} + +function getRequestedTemplateLanguage(req) { + return normalizeLanguageCode( + req?.query?.lang + || req?.query?.language + || req?.user?.lang + || req?.user?.language + || '', + ) || null; +} + +function buildTemplateStorageKey(lang, originalName) { + const langFolder = resolveDocumentTemplateStorageFolder(lang); + return `DocumentTemplates/${langFolder}/${Date.now()}_${originalName}`; +} + // Helper to remove/empty placeholders except allow-list // Updated: match any content inside {{ ... }} (not only \w+) so placeholders like {{company.name}} are sanitized. // allowList contains exact placeholder names to preserve (e.g. 'companyStamp', 'companyLogo', 'profitplanetSignature'). @@ -119,7 +146,7 @@ async function enrichTemplate(template, s3, serverBaseUrl = null) { // optional: upload sanitized preview to S3 (keeps earlier behavior for offline debugging) try { - const langFolder = template.lang === 'en' ? 'english' : (template.lang === 'de' ? 'german' : 'other'); + const langFolder = resolveDocumentTemplateStorageFolder(template.lang); const previewKey = `DocumentTemplates/previews/${langFolder}/template_${template.id}_preview_${Date.now()}.html`; const putCmd = new PutObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, @@ -350,7 +377,8 @@ async function fetchLatestActiveContractTemplateHtml({ userType, contractType }) ? String(contractType).toLowerCase() : 'contract'; - const latest = await DocumentTemplateService.getLatestActiveForUserType(userType, 'contract', safeContractType); + const lang = arguments[0]?.lang || null; + const latest = await DocumentTemplateService.getLatestActiveForUserType(userType, 'contract', safeContractType, null, lang); if (!latest) return { latest: null, html: '' }; const s3 = new S3Client({ @@ -371,13 +399,13 @@ async function fetchLatestActiveContractTemplateHtml({ userType, contractType }) return { latest, html: ensureHtmlDocument(html) }; } -async function renderLatestActiveContractHtmlForUser({ targetUserId, userType, contractType, req = null }) { +async function renderLatestActiveContractHtmlForUser({ targetUserId, userType, contractType, req = null, lang = null }) { const allowedContractTypes = ['contract', 'gdpr', 'abo']; const safeContractType = allowedContractTypes.includes(String(contractType || '').toLowerCase()) ? String(contractType).toLowerCase() : 'contract'; - const latest = await DocumentTemplateService.getLatestActiveForUserType(userType, 'contract', safeContractType); + const latest = await DocumentTemplateService.getLatestActiveForUserType(userType, 'contract', safeContractType, null, lang); if (!latest) return { latest: null, html: '' }; const s3 = new S3Client({ @@ -480,7 +508,8 @@ exports.uploadTemplate = async (req, res) => { const user_type = allowed.includes(rawUserType) ? rawUserType : 'both'; const file = req.file; if (!file) return res.status(400).json({ error: 'No file uploaded' }); - if (!lang || !['en', 'de'].includes(lang)) return res.status(400).json({ error: 'Invalid or missing language' }); + const supportedLang = await resolveSupportedTemplateLanguage(lang); + if (!supportedLang) return res.status(400).json({ error: 'Invalid or missing language' }); const allowedContractTypes = ['contract', 'gdpr', 'abo']; const normalizedContractType = rawContractType !== undefined && rawContractType !== null @@ -491,9 +520,7 @@ exports.uploadTemplate = async (req, res) => { : (type === 'contract' ? 'contract' : null); const tax_mode = type === 'invoice' ? normalizeInvoiceTaxMode(rawTaxMode) : null; - // Use "english" for en, "german" for de - const langFolder = lang === 'en' ? 'english' : 'german'; - const key = `DocumentTemplates/${langFolder}/${Date.now()}_${file.originalname}`; + const key = buildTemplateStorageKey(supportedLang, file.originalname); const s3 = new S3Client({ region: process.env.EXOSCALE_REGION, @@ -510,7 +537,7 @@ exports.uploadTemplate = async (req, res) => { ContentType: file.mimetype })); - const template = await DocumentTemplateService.uploadTemplate({ name, type, contract_type, storageKey: key, description, lang, version: 1, user_type, tax_mode }); + const template = await DocumentTemplateService.uploadTemplate({ name, type, contract_type, storageKey: key, description, lang: supportedLang, version: 1, user_type, tax_mode }); // Enrich with previewUrl, fileUrl, html const enriched = await enrichTemplate(template, s3); res.status(201).json(enriched); @@ -550,6 +577,12 @@ exports.updateTemplate = async (req, res) => { const current = await DocumentTemplateService.getTemplate(id); if (!current) return res.status(404).json({ error: 'Template not found' }); + let nextLang = normalizeLanguageCode(current.lang) || current.lang; + if (lang !== undefined) { + nextLang = await resolveSupportedTemplateLanguage(lang); + if (!nextLang) return res.status(400).json({ error: 'Invalid or missing language' }); + } + const nextType = type !== undefined ? type : current.type; const allowedContractTypes = ['contract', 'gdpr', 'abo']; let contract_type = null; @@ -566,10 +599,8 @@ exports.updateTemplate = async (req, res) => { ? normalizeInvoiceTaxMode(rawTaxMode !== undefined ? rawTaxMode : current.tax_mode) : null; - // Use "english" for en, "german" for de - const langFolder = lang ? (lang === 'en' ? 'english' : 'german') : (current.lang === 'en' ? 'english' : 'german'); if (file) { - storageKey = `DocumentTemplates/${langFolder}/${Date.now()}_${file.originalname}`; + storageKey = buildTemplateStorageKey(nextLang, file.originalname); const s3 = new S3Client({ region: process.env.EXOSCALE_REGION, endpoint: process.env.EXOSCALE_ENDPOINT, @@ -592,7 +623,7 @@ exports.updateTemplate = async (req, res) => { contract_type, tax_mode, description: description !== undefined ? description : current.description, - lang: lang !== undefined ? lang : current.lang, + lang: nextLang, storageKey: storageKey || current.storageKey, ...(user_type !== undefined ? { user_type } : {}) }; @@ -628,8 +659,10 @@ exports.reviseTemplate = async (req, res) => { if (!previous) return res.status(404).json({ error: 'Template not found' }); const nextType = type !== undefined ? type : previous.type; - const nextLang = lang !== undefined ? lang : previous.lang; - if (!nextLang || !['en', 'de'].includes(nextLang)) return res.status(400).json({ error: 'Invalid or missing language' }); + const nextLang = lang !== undefined + ? await resolveSupportedTemplateLanguage(lang) + : (normalizeLanguageCode(previous.lang) || previous.lang); + if (!nextLang) return res.status(400).json({ error: 'Invalid or missing language' }); const allowedUserTypes = ['personal', 'company', 'both']; const user_type = allowedUserTypes.includes(rawUserType) @@ -647,9 +680,7 @@ exports.reviseTemplate = async (req, res) => { ? normalizeInvoiceTaxMode(rawTaxMode !== undefined ? rawTaxMode : previous.tax_mode) : null; - // Use "english" for en, "german" for de - const langFolder = nextLang === 'en' ? 'english' : 'german'; - const storageKey = `DocumentTemplates/${langFolder}/${Date.now()}_${file.originalname}`; + const storageKey = buildTemplateStorageKey(nextLang, file.originalname); const s3 = new S3Client({ region: process.env.EXOSCALE_REGION, @@ -2035,9 +2066,10 @@ exports.previewLatestForMe = async (req, res) => { const contractTypeParam = (req.query.contract_type || req.query.contractType || '').toString().toLowerCase(); const allowedContractTypes = ['contract', 'gdpr', 'abo']; const contractType = allowedContractTypes.includes(contractTypeParam) ? contractTypeParam : 'contract'; + const lang = getRequestedTemplateLanguage(req); try { - const { latest, html } = await fetchLatestActiveContractTemplateHtml({ userType, contractType }); + const { latest, html } = await fetchLatestActiveContractTemplateHtml({ userType, contractType, lang }); if (!latest) { logger.info('[previewLatestForMe] no active template', { userId: targetUserId, userType, contractType }); res.setHeader('Content-Type', 'text/html; charset=utf-8'); @@ -2057,10 +2089,11 @@ exports.previewLatestAboForMe = async (req, res) => { if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' }); const targetUserId = req.user.id || req.user.userId; const userType = (req.user.user_type || req.user.userType || '').toString().toLowerCase(); + const lang = getRequestedTemplateLanguage(req); if (!targetUserId || !userType) return res.status(400).json({ success: false, message: 'Invalid authenticated user' }); try { - const { latest, html } = await fetchLatestActiveContractTemplateHtml({ userType, contractType: 'abo' }); + const { latest, html } = await fetchLatestActiveContractTemplateHtml({ userType, contractType: 'abo', lang }); if (!latest) { logger.info('[previewLatestAboForMe] no active template', { userId: targetUserId, userType, contractType: 'abo' }); res.setHeader('Content-Type', 'text/html; charset=utf-8'); diff --git a/controller/documents/ContractUploadController.js b/controller/documents/ContractUploadController.js index e6533c4..47339d1 100644 --- a/controller/documents/ContractUploadController.js +++ b/controller/documents/ContractUploadController.js @@ -2,6 +2,7 @@ const UnitOfWork = require('../../database/UnitOfWork'); const ContractUploadService = require('../../services/contracts/ContractUploadService'); const UserDocumentRepository = require('../../repositories/documents/UserDocumentRepository'); const { logger } = require('../../middleware/logger'); +const { normalizeLanguageCode } = require('../../utils/languageUtils'); function normalizeSignature(signatureImage) { if (!signatureImage) return null; @@ -22,12 +23,28 @@ function normalizeSignature(signatureImage) { } } +function resolveRequestedLanguage(contractData, user) { + return normalizeLanguageCode( + contractData?.lang + ?? contractData?.language + ?? contractData?.locale + ?? contractData?.preferred_language + ?? contractData?.preferredLanguage + ?? user?.preferred_language + ?? user?.preferredLanguage + ?? user?.language + ?? user?.lang + ?? '' + ) || null; +} + class ContractUploadController { static async uploadPersonalContract(req, res) { const userId = req.user.userId; logger.info(`[ContractUploadController] uploadPersonalContract called for userId: ${userId}`); const file = req.file; // optional, we now generate from templates when absent const contractData = req.body.contractData ? JSON.parse(req.body.contractData) : undefined; + const lang = resolveRequestedLanguage(contractData, req.user); const signatureImage = req.body.signatureImage; const unitOfWork = new UnitOfWork(); @@ -63,6 +80,7 @@ class ContractUploadController { contractCategory: 'personal', unitOfWork, contractData, + lang, signatureImage: signatureMeta ? signatureMeta.base64 : null, contract_type, user_type: 'personal' @@ -110,6 +128,7 @@ class ContractUploadController { logger.info(`[ContractUploadController] uploadCompanyContract called for userId: ${userId}`); const file = req.file; const contractData = req.body.contractData ? JSON.parse(req.body.contractData) : undefined; + const lang = resolveRequestedLanguage(contractData, req.user); const signatureImage = req.body.signatureImage; const unitOfWork = new UnitOfWork(); await unitOfWork.start(); @@ -143,6 +162,7 @@ class ContractUploadController { contractCategory: 'company', unitOfWork, contractData, + lang, signatureImage: signatureMeta ? signatureMeta.base64 : null, contract_type, user_type: 'company' diff --git a/database/createDb.js b/database/createDb.js index da26650..55e0654 100644 --- a/database/createDb.js +++ b/database/createDb.js @@ -846,6 +846,7 @@ const createDatabase = async () => { high_contrast_mode BOOLEAN DEFAULT FALSE, two_factor_auth_enabled BOOLEAN DEFAULT FALSE, account_visibility ENUM('public', 'private') DEFAULT 'public', + preferred_language VARCHAR(16) NULL, show_email BOOLEAN DEFAULT TRUE, show_phone BOOLEAN DEFAULT TRUE, data_export_requested BOOLEAN DEFAULT FALSE, @@ -857,6 +858,8 @@ const createDatabase = async () => { `); console.log('✅ User settings table created/verified'); + await addColumnIfMissing(connection, 'user_settings', 'preferred_language', `VARCHAR(16) NULL AFTER account_visibility`); + // --- Company Settings (single-row, global invoice / company info) --- await connection.query(` CREATE TABLE IF NOT EXISTS company_settings ( diff --git a/middleware/authMiddleware.js b/middleware/authMiddleware.js index 3ab00fb..6c3b187 100644 --- a/middleware/authMiddleware.js +++ b/middleware/authMiddleware.js @@ -2,6 +2,8 @@ const jwt = require('jsonwebtoken'); const { logger } = require('./logger'); const UnitOfWork = require('../database/UnitOfWork'); const UserStatusRepository = require('../repositories/status/UserStatusRepository'); +const UserSettingsRepository = require('../repositories/settings/UserSettingsRepository'); +const { normalizeLanguageCode } = require('../utils/languageUtils'); async function authMiddleware(req, res, next) { const authHeader = req.headers.authorization; @@ -57,10 +59,29 @@ async function authMiddleware(req, res, next) { const unitOfWork = new UnitOfWork(); await unitOfWork.start(); unitOfWork.registerRepository('status', new UserStatusRepository(unitOfWork)); + unitOfWork.registerRepository('settings', new UserSettingsRepository(unitOfWork)); const statusRepo = unitOfWork.getRepository('status'); + const settingsRepo = unitOfWork.getRepository('settings'); const userStatus = await statusRepo.getStatusByUserId(normalizedUserId); + const userSettings = await settingsRepo.getSettingsByUserId(normalizedUserId, unitOfWork); await unitOfWork.commit(); + const preferredLanguage = normalizeLanguageCode( + userSettings?.preferred_language + || userSettings?.preferredLanguage + || req.user?.preferred_language + || req.user?.preferredLanguage + || req.user?.language + || req.user?.lang + ) || null; + + if (preferredLanguage) { + req.user.preferred_language = preferredLanguage; + req.user.preferredLanguage = preferredLanguage; + req.user.language = preferredLanguage; + req.user.lang = preferredLanguage; + } + if (userStatus && userStatus.status === 'suspended') { logger.warn('authMiddleware:user_suspended', { userId: normalizedUserId, diff --git a/repositories/settings/I18nPreferencesRepository.js b/repositories/settings/I18nPreferencesRepository.js index 4b3d6a9..7cad0e0 100644 --- a/repositories/settings/I18nPreferencesRepository.js +++ b/repositories/settings/I18nPreferencesRepository.js @@ -1,4 +1,5 @@ const db = require('../../database/database'); +const { mergeLanguageDescriptors } = require('../../utils/languageUtils'); class I18nPreferencesRepository { _safeJsonArray(value) { @@ -39,6 +40,25 @@ class I18nPreferencesRepository { 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(); diff --git a/repositories/settings/UserSettingsRepository.js b/repositories/settings/UserSettingsRepository.js index 008be83..4e16f9b 100644 --- a/repositories/settings/UserSettingsRepository.js +++ b/repositories/settings/UserSettingsRepository.js @@ -5,10 +5,10 @@ class UserSettingsRepository { this.unitOfWork = unitOfWork; } - async getSettingsByUserId(userId) { + async getSettingsByUserId(userId, unitOfWork = null) { logger.info('UserSettingsRepository.getSettingsByUserId:start', { userId }); try { - const conn = this.unitOfWork.connection; + const conn = unitOfWork ? unitOfWork.connection : this.unitOfWork.connection; const [rows] = await conn.query('SELECT * FROM user_settings WHERE user_id = ?', [userId]); logger.info('UserSettingsRepository.getSettingsByUserId:success', { userId, found: rows.length > 0 }); return rows.length > 0 ? rows[0] : null; @@ -32,6 +32,32 @@ class UserSettingsRepository { throw error; } } + + async updatePreferredLanguage(userId, preferredLanguage, unitOfWork = null) { + logger.info('UserSettingsRepository.updatePreferredLanguage:start', { userId, preferredLanguage }); + try { + const conn = unitOfWork ? unitOfWork.connection : this.unitOfWork.connection; + await conn.query( + `INSERT INTO user_settings (user_id, preferred_language) + VALUES (?, ?) + ON DUPLICATE KEY UPDATE preferred_language = VALUES(preferred_language)` , + [userId, preferredLanguage || null] + ); + const settings = await this.getSettingsByUserId(userId, unitOfWork); + logger.info('UserSettingsRepository.updatePreferredLanguage:success', { + userId, + preferredLanguage: settings?.preferred_language || null, + }); + return settings; + } catch (error) { + logger.error('UserSettingsRepository.updatePreferredLanguage:error', { + userId, + preferredLanguage, + error: error.message, + }); + throw error; + } + } } module.exports = UserSettingsRepository; diff --git a/repositories/template/DocumentTemplateRepository.js b/repositories/template/DocumentTemplateRepository.js index b8d93b9..62a7018 100644 --- a/repositories/template/DocumentTemplateRepository.js +++ b/repositories/template/DocumentTemplateRepository.js @@ -1,5 +1,6 @@ const db = require('../../database/database'); const { logger } = require('../../middleware/logger'); +const { normalizeLanguageCode } = require('../../utils/languageUtils'); const ALLOWED_USER_TYPES = new Set(['personal', 'company', 'both']); const ALLOWED_CONTRACT_TYPES = new Set(['contract', 'gdpr', 'abo']); @@ -179,7 +180,7 @@ class DocumentTemplateRepository { const safeContractType = (normalizedContractType === 'gdpr' || normalizedContractType === 'contract' || normalizedContractType === 'abo') ? normalizedContractType : 'contract'; - const safeLang = (lang === 'en' || lang === 'de') ? lang : 'en'; + const safeLang = normalizeLanguageCode(lang) || 'en'; const safeUserType = (user_type === 'personal' || user_type === 'company' || user_type === 'both') ? user_type : 'both'; // user_type overlap rules @@ -222,7 +223,7 @@ class DocumentTemplateRepository { // Deactivate other active invoice templates for the same language and user_type. async deactivateOtherActiveInvoices({ excludeId, lang, user_type, tax_mode }, conn) { logger.info('DocumentTemplateRepository.deactivateOtherActiveInvoices:start', { excludeId, lang, user_type, tax_mode }); - const safeLang = (lang === 'en' || lang === 'de') ? lang : 'en'; + const safeLang = normalizeLanguageCode(lang) || 'en'; const safeUserType = normalizeTemplateUserType(user_type); const safeTaxMode = normalizeInvoiceTaxMode(tax_mode); diff --git a/routes/putRoutes.js b/routes/putRoutes.js index ce27822..d23a79f 100644 --- a/routes/putRoutes.js +++ b/routes/putRoutes.js @@ -4,6 +4,7 @@ const router = express.Router(); const authMiddleware = require('../middleware/authMiddleware'); const adminOnly = require('../middleware/adminOnly'); const AdminUserController = require('../controller/admin/AdminUserController'); +const UserSettingsController = require('../controller/auth/UserSettingsController'); const DocumentTemplateController = require('../controller/documentTemplate/DocumentTemplateController'); const CoffeeController = require('../controller/admin/CoffeeController'); const CompanySettingsController = require('../controller/admin/CompanySettingsController'); @@ -18,6 +19,11 @@ router.put('/admin/users/:id/permissions', authMiddleware, adminOnly, AdminUserC // PUT /document-templates/:id (moved from routes/documentTemplates.js) router.put('/document-templates/:id', authMiddleware, upload.single('file'), DocumentTemplateController.updateTemplate); + +// User: update persisted UI settings +router.put('/user/settings', authMiddleware, UserSettingsController.updateSettings); +router.put('/settings', authMiddleware, UserSettingsController.updateSettings); + // Admin: update coffee product (supports picture file replacement) router.put('/admin/coffee/:id', authMiddleware, adminOnly, upload.single('picture'), CoffeeController.update); diff --git a/scripts/compareAboContractDocxVersions.js b/scripts/compareAboContractDocxVersions.js new file mode 100644 index 0000000..e3a09ae --- /dev/null +++ b/scripts/compareAboContractDocxVersions.js @@ -0,0 +1,223 @@ +const fs = require('fs'); +const path = require('path'); +const yauzl = require('yauzl'); + +const ABO_TEMPLATE_DIR = path.join(__dirname, '..', 'templates', 'abo'); + +const COMPARISONS = [ + { + label: 'DE', + previous: path.join(ABO_TEMPLATE_DIR, 'abo-contract-DE.docx'), + next: path.join(ABO_TEMPLATE_DIR, 'new', 'abo-contract-DE-NEW.docx'), + }, + { + label: 'SL', + previous: path.join(ABO_TEMPLATE_DIR, 'abo-contract-SL.docx'), + next: path.join(ABO_TEMPLATE_DIR, 'new', 'abo-contract-SL-NEW.docx'), + }, +]; + +function decodeXml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'"); +} + +function openZip(zipPath) { + return new Promise((resolve, reject) => { + yauzl.open(zipPath, { lazyEntries: true }, (error, zipFile) => { + if (error) { + reject(error); + return; + } + + resolve(zipFile); + }); + }); +} + +function readEntry(zipFile, entry) { + return new Promise((resolve, reject) => { + zipFile.openReadStream(entry, (error, stream) => { + if (error) { + reject(error); + return; + } + + const chunks = []; + stream.on('data', (chunk) => chunks.push(chunk)); + stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + stream.on('error', reject); + }); + }); +} + +async function readWordXmlEntries(zipPath) { + const zipFile = await openZip(zipPath); + const documents = []; + + return new Promise((resolve, reject) => { + zipFile.readEntry(); + + zipFile.on('entry', (entry) => { + if (!/^word\/(document|header\d+|footer\d+|footnotes|endnotes)\.xml$/i.test(entry.fileName)) { + zipFile.readEntry(); + return; + } + + readEntry(zipFile, entry) + .then((xml) => { + documents.push({ fileName: entry.fileName, xml }); + zipFile.readEntry(); + }) + .catch(reject); + }); + + zipFile.on('end', () => { + zipFile.close(); + resolve(documents); + }); + + zipFile.on('error', reject); + }); +} + +function xmlToLines(xml) { + const withStructuralBreaks = String(xml || '') + .replace(/]*\/>/gi, '\t') + .replace(/]*\/>/gi, '\n') + .replace(/<\/w:p>/gi, '\n') + .replace(/<\/w:tr>/gi, '\n') + .replace(/<\/w:tc>/gi, '\t') + .replace(/]*>([\s\S]*?)<\/w:t>/gi, (_, value) => decodeXml(value)) + .replace(/]*>([\s\S]*?)<\/w:delText>/gi, (_, value) => decodeXml(value)); + + const textOnly = decodeXml(withStructuralBreaks.replace(/<[^>]+>/g, ' ')); + + return textOnly + .split(/\r?\n/) + .map((line) => line.replace(/[ \t]+/g, ' ').trim()) + .filter((line) => line.length > 0); +} + +async function extractDocxLines(docxPath) { + const entries = await readWordXmlEntries(docxPath); + const ordered = entries.sort((left, right) => left.fileName.localeCompare(right.fileName)); + + return ordered.flatMap((entry) => xmlToLines(entry.xml)); +} + +function buildLcsMatrix(left, right) { + const matrix = Array.from({ length: left.length + 1 }, () => Array(right.length + 1).fill(0)); + + for (let leftIndex = left.length - 1; leftIndex >= 0; leftIndex -= 1) { + for (let rightIndex = right.length - 1; rightIndex >= 0; rightIndex -= 1) { + if (left[leftIndex] === right[rightIndex]) { + matrix[leftIndex][rightIndex] = matrix[leftIndex + 1][rightIndex + 1] + 1; + } else { + matrix[leftIndex][rightIndex] = Math.max( + matrix[leftIndex + 1][rightIndex], + matrix[leftIndex][rightIndex + 1], + ); + } + } + } + + return matrix; +} + +function diffLines(previousLines, nextLines) { + const matrix = buildLcsMatrix(previousLines, nextLines); + const changes = []; + + let previousIndex = 0; + let nextIndex = 0; + + while (previousIndex < previousLines.length && nextIndex < nextLines.length) { + if (previousLines[previousIndex] === nextLines[nextIndex]) { + previousIndex += 1; + nextIndex += 1; + continue; + } + + if (matrix[previousIndex + 1][nextIndex] >= matrix[previousIndex][nextIndex + 1]) { + changes.push({ type: 'removed', line: previousIndex + 1, text: previousLines[previousIndex] }); + previousIndex += 1; + } else { + changes.push({ type: 'added', line: nextIndex + 1, text: nextLines[nextIndex] }); + nextIndex += 1; + } + } + + while (previousIndex < previousLines.length) { + changes.push({ type: 'removed', line: previousIndex + 1, text: previousLines[previousIndex] }); + previousIndex += 1; + } + + while (nextIndex < nextLines.length) { + changes.push({ type: 'added', line: nextIndex + 1, text: nextLines[nextIndex] }); + nextIndex += 1; + } + + return changes; +} + +async function comparePair({ label, previous, next }) { + const previousExists = fs.existsSync(previous); + const nextExists = fs.existsSync(next); + + if (!previousExists || !nextExists) { + throw new Error(`${label}: missing input file(s)`); + } + + const [previousLines, nextLines] = await Promise.all([ + extractDocxLines(previous), + extractDocxLines(next), + ]); + + const changes = diffLines(previousLines, nextLines); + + return { + label, + previous, + next, + previousLineCount: previousLines.length, + nextLineCount: nextLines.length, + changes, + }; +} + +function printComparison(result) { + console.log(`\n=== ${result.label} ===`); + console.log(`Old: ${path.basename(result.previous)} (${result.previousLineCount} lines)`); + console.log(`New: ${path.basename(result.next)} (${result.nextLineCount} lines)`); + + if (!result.changes.length) { + console.log('No textual differences detected.'); + return; + } + + console.log(`Detected ${result.changes.length} text-level changes:`); + result.changes.forEach((change) => { + const marker = change.type === 'added' ? '+' : '-'; + console.log(`${marker} [${change.line}] ${change.text}`); + }); +} + +async function main() { + const results = []; + + for (const comparison of COMPARISONS) { + results.push(await comparePair(comparison)); + } + + results.forEach(printComparison); +} + +main().catch((error) => { + console.error('[compareAboContractDocxVersions] failed:', error?.message || error); + process.exitCode = 1; +}); \ No newline at end of file diff --git a/services/abonemments/AboContractService.js b/services/abonemments/AboContractService.js index 0968242..745f210 100644 --- a/services/abonemments/AboContractService.js +++ b/services/abonemments/AboContractService.js @@ -7,19 +7,20 @@ const DocumentTemplateService = require('../template/DocumentTemplateService'); const MailService = require('../email/MailService'); const pool = require('../../database/database'); const { logger } = require('../../middleware/logger'); +const { normalizeLanguageCode } = require('../../utils/languageUtils'); class AboContractService { constructor() { - this.templatePath = path.join(__dirname, '..', '..', 'templates', 'abo', 'abo-contract-template-new.html'); + this.templateDir = path.join(__dirname, '..', '..', 'templates', 'abo'); } /** * Load the latest active abo contract template from the contract management system (DB + S3). * Falls back to the local file if no active template is found. */ - async _loadTemplate(userType = 'both') { + async _loadTemplate(userType = 'both', lang = null) { try { - const latest = await DocumentTemplateService.getLatestActiveForUserType(userType, 'contract', 'abo'); + const latest = await DocumentTemplateService.getLatestActiveForUserType(userType, 'contract', 'abo', null, lang); if (latest?.storageKey) { const command = new GetObjectCommand({ Bucket: process.env.EXOSCALE_BUCKET, @@ -41,8 +42,21 @@ class AboContractService { } catch (e) { logger.warn('AboContractService:s3_template_load_failed', { message: e?.message }); } - // Fallback: local file - return fs.readFileSync(this.templatePath, 'utf8'); + + const normalizedLang = normalizeLanguageCode(lang); + const candidates = []; + if (normalizedLang === 'sl') candidates.push('abo-contract-SL.html'); + if (normalizedLang === 'de') candidates.push('abo-contract-DE.html'); + if (normalizedLang !== 'de') candidates.push('abo-contract-DE.html'); + + for (const fileName of candidates) { + const fallbackPath = path.join(this.templateDir, fileName); + if (!fs.existsSync(fallbackPath)) continue; + logger.warn('AboContractService:using_local_fallback_template', { fileName, lang: normalizedLang || null }); + return fs.readFileSync(fallbackPath, 'utf8'); + } + + throw new Error('ABO contract template missing'); } _escapeHtml(value) { @@ -131,7 +145,7 @@ class AboContractService { const userType = await this._resolveUserType(actorUser); let template; try { - template = await this._loadTemplate(userType); + template = await this._loadTemplate(userType, lang); } catch (e) { logger.error('AboContractService:template_missing', { message: e?.message }); throw new Error('ABO contract template missing'); diff --git a/services/contracts/ContractUploadService.js b/services/contracts/ContractUploadService.js index a8beb97..18454e9 100644 --- a/services/contracts/ContractUploadService.js +++ b/services/contracts/ContractUploadService.js @@ -9,6 +9,7 @@ const fs = require('fs'); const path = require('path'); const { logger } = require('../../middleware/logger'); const puppeteer = require('puppeteer'); +const { normalizeLanguageCode } = require('../../utils/languageUtils'); // Robust stream/Body -> Buffer reader (supports async iterable, web streams, node streams, buffers) async function streamToBuffer(body) { @@ -228,7 +229,23 @@ class ContractUploadService { contract_type = 'contract', user_type = 'personal' }) { - logger.info('ContractUploadService.uploadContract:start', { userId, documentType, contractCategory, templateId, lang }); + const resolvedLanguage = normalizeLanguageCode( + lang + ?? contractData?.lang + ?? contractData?.language + ?? contractData?.locale + ?? contractData?.preferred_language + ?? contractData?.preferredLanguage + ?? '' + ) || null; + + logger.info('ContractUploadService.uploadContract:start', { + userId, + documentType, + contractCategory, + templateId, + lang: resolvedLanguage, + }); let pdfBuffer, originalFilename, mimeType, fileSize; let contractBody; @@ -240,10 +257,12 @@ class ContractUploadService { try { // If templateId and lang are provided, fetch HTML template from object storage - if (templateId && lang) { - logger.info('ContractUploadService.uploadContract:fetching_template', { templateId, lang }); + if (templateId && resolvedLanguage) { + logger.info('ContractUploadService.uploadContract:fetching_template', { templateId, lang: resolvedLanguage }); const templateMeta = await DocumentTemplateService.getTemplate(templateId); - if (!templateMeta || templateMeta.lang !== lang) throw new Error('Template not found for specified language'); + if (!templateMeta || normalizeLanguageCode(templateMeta.lang) !== resolvedLanguage) { + throw new Error('Template not found for specified language'); + } // Fetch HTML from object storage const s3 = new S3Client({ region: process.env.EXOSCALE_REGION }); const getObj = await s3.send(new GetObjectCommand({ @@ -315,7 +334,13 @@ class ContractUploadService { fileSize = file.size; } else { // NEW: auto-generate PDF from latest active template (contract_type) when no file provided - const tmpl = await DocumentTemplateService.getLatestActiveForUserType(user_type, 'contract', contract_type); + const tmpl = await DocumentTemplateService.getLatestActiveForUserType( + user_type, + 'contract', + contract_type, + null, + resolvedLanguage + ); if (!tmpl) { throw new Error(`No active ${contract_type} template found for user type ${user_type}`); } diff --git a/services/invoice/InvoiceService.js b/services/invoice/InvoiceService.js index 5bbcf8b..bb084d6 100644 --- a/services/invoice/InvoiceService.js +++ b/services/invoice/InvoiceService.js @@ -8,6 +8,7 @@ const { s3: sharedExoscaleClient } = require('../../utils/exoscaleUploader'); const { logger } = require('../../middleware/logger'); const puppeteer = require('puppeteer'); const pool = require('../../database/database'); +const { normalizeLanguageCode } = require('../../utils/languageUtils'); const CoffeeShippingFeeService = require('../subscriptions/CoffeeShippingFeeService'); @@ -233,35 +234,52 @@ class InvoiceService { _selectInvoiceTemplate(templates, { lang = 'en', userType = 'personal', taxMode = 'standard' } = {}) { if (!Array.isArray(templates) || !templates.length) return null; - const safeLang = lang === 'de' ? 'de' : 'en'; + const requestedLanguage = normalizeLanguageCode(lang) || 'en'; + const languageCandidates = requestedLanguage === 'en' ? ['en'] : [requestedLanguage, 'en']; const safeUserType = this._normalizeInvoiceUserType(userType); const safeTaxMode = this._normalizeInvoiceTemplateTaxMode(taxMode); const matchesTaxMode = (template, mode) => this._normalizeInvoiceTemplateTaxMode(template?.tax_mode) === mode; - const priorities = [ - (template) => template?.lang === safeLang && template?.user_type === safeUserType && matchesTaxMode(template, safeTaxMode), - (template) => template?.lang === safeLang && template?.user_type === safeUserType && this._matchesLegacyReverseChargeTemplate(template, safeTaxMode), - (template) => template?.lang === safeLang && template?.user_type === safeUserType && matchesTaxMode(template, 'both'), - (template) => template?.lang === safeLang && template?.user_type === 'both' && matchesTaxMode(template, safeTaxMode), - (template) => template?.lang === safeLang && template?.user_type === 'both' && this._matchesLegacyReverseChargeTemplate(template, safeTaxMode), - (template) => template?.lang === safeLang && template?.user_type === 'both' && matchesTaxMode(template, 'both'), - (template) => template?.lang === 'en' && template?.user_type === safeUserType && matchesTaxMode(template, safeTaxMode), - (template) => template?.lang === 'en' && template?.user_type === safeUserType && this._matchesLegacyReverseChargeTemplate(template, safeTaxMode), - (template) => template?.lang === 'en' && template?.user_type === safeUserType && matchesTaxMode(template, 'both'), - (template) => template?.lang === 'en' && template?.user_type === 'both' && matchesTaxMode(template, safeTaxMode), - (template) => template?.lang === 'en' && template?.user_type === 'both' && this._matchesLegacyReverseChargeTemplate(template, safeTaxMode), - (template) => template?.lang === 'en' && template?.user_type === 'both' && matchesTaxMode(template, 'both'), - (template) => template?.user_type === safeUserType && matchesTaxMode(template, safeTaxMode), - (template) => template?.user_type === safeUserType && this._matchesLegacyReverseChargeTemplate(template, safeTaxMode), - (template) => template?.user_type === safeUserType && matchesTaxMode(template, 'both'), - (template) => template?.user_type === 'both' && matchesTaxMode(template, safeTaxMode), - (template) => template?.user_type === 'both' && this._matchesLegacyReverseChargeTemplate(template, safeTaxMode), - (template) => template?.user_type === 'both' && matchesTaxMode(template, 'both'), - () => true, - ]; - for (const matches of priorities) { - const selected = templates.find(matches); - if (selected) return selected; + const matchesLanguage = (template, candidateLanguage) => normalizeLanguageCode(template?.lang) === candidateLanguage; + const userTypeCandidates = [safeUserType, 'both']; + + for (const candidateLanguage of languageCandidates) { + for (const candidateUserType of userTypeCandidates) { + const exact = templates.find((template) => ( + matchesLanguage(template, candidateLanguage) + && template?.user_type === candidateUserType + && matchesTaxMode(template, safeTaxMode) + )); + if (exact) return exact; + + const legacyReverseCharge = templates.find((template) => ( + matchesLanguage(template, candidateLanguage) + && template?.user_type === candidateUserType + && this._matchesLegacyReverseChargeTemplate(template, safeTaxMode) + )); + if (legacyReverseCharge) return legacyReverseCharge; + + const bothTaxModes = templates.find((template) => ( + matchesLanguage(template, candidateLanguage) + && template?.user_type === candidateUserType + && matchesTaxMode(template, 'both') + )); + if (bothTaxModes) return bothTaxModes; + } + } + + for (const candidateUserType of userTypeCandidates) { + const exact = templates.find((template) => template?.user_type === candidateUserType && matchesTaxMode(template, safeTaxMode)); + if (exact) return exact; + + const legacyReverseCharge = templates.find((template) => ( + template?.user_type === candidateUserType + && this._matchesLegacyReverseChargeTemplate(template, safeTaxMode) + )); + if (legacyReverseCharge) return legacyReverseCharge; + + const bothTaxModes = templates.find((template) => template?.user_type === candidateUserType && matchesTaxMode(template, 'both')); + if (bothTaxModes) return bothTaxModes; } return templates[0]; diff --git a/services/pool/PoolInflowService.js b/services/pool/PoolInflowService.js index 7813a43..b8a98ab 100644 --- a/services/pool/PoolInflowService.js +++ b/services/pool/PoolInflowService.js @@ -1,6 +1,27 @@ const db = require('../../database/database'); const SYSTEM_POOL_NAMES = ['ABO 60', 'ABO 120', 'Business', 'Gigantea', 'Core']; +const ABO_60_POOL_NAME = 'ABO 60'; +const ABO_120_POOL_NAME = 'ABO 120'; + +function resolveAboPoolName(totalPacks) { + const safeTotalPacks = Number(totalPacks || 0); + if (!Number.isFinite(safeTotalPacks) || safeTotalPacks <= 0) return ABO_60_POOL_NAME; + return safeTotalPacks <= 11 ? ABO_60_POOL_NAME : ABO_120_POOL_NAME; +} + +function selectPoolsForAbonement(pools, totalPacks) { + if (!Array.isArray(pools) || !pools.length) return []; + + const targetAboPoolName = resolveAboPoolName(totalPacks); + return pools.filter((pool) => { + const poolName = String(pool?.pool_name || '').trim(); + if (poolName === ABO_60_POOL_NAME || poolName === ABO_120_POOL_NAME) { + return poolName === targetAboPoolName; + } + return true; + }); +} function toTwo(value) { return Number(Number(value || 0).toFixed(2)); @@ -11,6 +32,28 @@ function toFour(value) { } class PoolInflowService { + async removeStaleAboPoolInflowsForInvoice({ conn, invoiceId, selectedAboPoolName }) { + if (!conn || !invoiceId || !selectedAboPoolName) return 0; + + const [res] = await conn.query( + `DELETE pi + FROM pool_inflows pi + INNER JOIN pools p ON p.id = pi.pool_id + WHERE pi.invoice_id = ? + AND pi.event_type = 'invoice_paid' + AND p.pool_name IN (?, ?) + AND p.pool_name <> ?`, + [ + Number(invoiceId), + ABO_60_POOL_NAME, + ABO_120_POOL_NAME, + selectedAboPoolName, + ] + ); + + return Number(res?.affectedRows || 0); + } + async upsertCapsuleSalesForInvoice({ conn, invoiceId, abonementId, paidAtDate, currency, byCoffee }) { const entries = Array.from(byCoffee.entries()); for (const [coffeeId, capsulesCountRaw] of entries) { @@ -126,13 +169,23 @@ class PoolInflowService { ? breakdown .map((line) => ({ coffeeId: Number(line?.coffee_table_id), + packsCount: Number(line?.packs || 0), capsulesCount: Number(line?.packs || 0) * 10, })) - .filter((line) => Number.isFinite(line.coffeeId) && line.coffeeId > 0 && Number.isFinite(line.capsulesCount) && line.capsulesCount > 0) + .filter((line) => ( + Number.isFinite(line.coffeeId) + && line.coffeeId > 0 + && Number.isFinite(line.packsCount) + && line.packsCount > 0 + && Number.isFinite(line.capsulesCount) + && line.capsulesCount > 0 + )) : []; if (!normalizedLines.length) return { ok: false, reason: 'no_breakdown_lines', invoice, abonementId }; + const totalPacks = normalizedLines.reduce((sum, line) => sum + Number(line.packsCount || 0), 0); + const byCoffee = new Map(); for (const line of normalizedLines) { byCoffee.set(line.coffeeId, (byCoffee.get(line.coffeeId) || 0) + line.capsulesCount); @@ -157,6 +210,11 @@ class PoolInflowService { return { ok: false, reason: 'no_active_system_pools', invoice, abonementId, normalizedLines }; } + const selectedPools = selectPoolsForAbonement(pools, totalPacks); + if (!selectedPools.length) { + return { ok: false, reason: 'no_matching_system_pools', invoice, abonementId, normalizedLines, totalPacks }; + } + return { ok: true, reason: 'ok', @@ -164,8 +222,10 @@ class PoolInflowService { abonementId, paidAtDate, byCoffee, - pools, + pools: selectedPools, normalizedLines, + totalPacks, + selectedAboPoolName: resolveAboPoolName(totalPacks), currency: invoice.currency || abonement.currency || 'EUR', }; } @@ -182,12 +242,20 @@ class PoolInflowService { const byCoffee = analysis.byCoffee; const pools = analysis.pools; const currency = analysis.currency; + const selectedAboPoolName = analysis.selectedAboPoolName; const conn = await db.getConnection(); let inserted = 0; try { let alreadyExists = 0; + let staleRemoved = 0; await conn.beginTransaction(); + staleRemoved = await this.removeStaleAboPoolInflowsForInvoice({ + conn, + invoiceId: normalizedInvoiceId, + selectedAboPoolName, + }); + await this.upsertCapsuleSalesForInvoice({ conn, invoiceId: normalizedInvoiceId, @@ -221,6 +289,8 @@ class PoolInflowService { paid_at: paidAtDate, booking_basis: 'gross', compatibility_note: 'gross values stored in existing net columns', + total_packs: analysis.totalPacks, + selected_abo_pool: resolveAboPoolName(analysis.totalPacks), member_multiplier: memberMultiplier, core_members_count: pool.pool_name === 'Core' ? memberMultiplier : null, }; @@ -250,7 +320,13 @@ class PoolInflowService { } await conn.commit(); - return { inserted, alreadyExists, skipped: Math.max(0, totalCandidates - inserted - alreadyExists), reason: 'ok' }; + return { + inserted, + alreadyExists, + staleRemoved, + skipped: Math.max(0, totalCandidates - inserted - alreadyExists), + reason: 'ok', + }; } catch (err) { await conn.rollback(); throw err; diff --git a/services/template/DocumentTemplateService.js b/services/template/DocumentTemplateService.js index 36cfb60..d49d059 100644 --- a/services/template/DocumentTemplateService.js +++ b/services/template/DocumentTemplateService.js @@ -1,6 +1,7 @@ const DocumentTemplateRepository = require('../../repositories/template/DocumentTemplateRepository'); const UnitOfWork = require('../../database/UnitOfWork'); const { logger } = require('../../middleware/logger'); +const { normalizeLanguageCode } = require('../../utils/languageUtils'); const ALLOWED_USER_TYPES = new Set(['personal', 'company', 'both']); const ALLOWED_CONTRACT_TYPES = new Set(['contract', 'gdpr', 'abo']); @@ -20,6 +21,23 @@ function normalizeInvoiceTaxMode(value) { return ALLOWED_INVOICE_TAX_MODES.has(normalized) ? normalized : 'both'; } +function selectTemplateByLanguage(list, requestedLanguage) { + if (!Array.isArray(list) || !list.length) return null; + + const preferredLanguage = normalizeLanguageCode(requestedLanguage); + const candidates = []; + + if (preferredLanguage) candidates.push(preferredLanguage); + if (!candidates.includes('en')) candidates.push('en'); + + for (const candidate of candidates) { + const match = list.find((template) => normalizeLanguageCode(template?.lang) === candidate); + if (match) return match; + } + + return list[0]; +} + class DocumentTemplateService { async listTemplates() { logger.info('DocumentTemplateService.listTemplates:start'); @@ -44,7 +62,7 @@ class DocumentTemplateService { const rawContractType = (data.contract_type || data.contractType); const normalizedContractType = normalizeContractType(rawContractType); const user_type = normalizeTemplateUserType(data.user_type || data.userType); - const contract_type = (data.type === 'contract' && ALLOWED_CONTRACT_TYPES.includes(normalizedContractType)) + const contract_type = (data.type === 'contract' && ALLOWED_CONTRACT_TYPES.has(normalizedContractType)) ? normalizedContractType : (data.type === 'contract' ? 'contract' : null); const tax_mode = data.type === 'invoice' @@ -107,7 +125,7 @@ class DocumentTemplateService { } const nextType = data.type !== undefined ? data.type : current.type; const contract_type = nextType === 'contract' - ? (ALLOWED_CONTRACT_TYPES.includes(normalizeContractType(data.contract_type || data.contractType || current.contract_type)) + ? (ALLOWED_CONTRACT_TYPES.has(normalizeContractType(data.contract_type || data.contractType || current.contract_type)) ? normalizeContractType(data.contract_type || data.contractType || current.contract_type) : 'contract') : null; @@ -200,7 +218,7 @@ class DocumentTemplateService { const effectiveType = data.type || previous.type; const effectiveUserType = normalizeTemplateUserType(data.user_type || data.userType || previous.user_type); const effectiveContractType = effectiveType === 'contract' - ? (ALLOWED_CONTRACT_TYPES.includes(normalizeContractType(data.contract_type || data.contractType || previous.contract_type)) + ? (ALLOWED_CONTRACT_TYPES.has(normalizeContractType(data.contract_type || data.contractType || previous.contract_type)) ? normalizeContractType(data.contract_type || data.contractType || previous.contract_type) : 'contract') : null; @@ -267,11 +285,11 @@ class DocumentTemplateService { } // Convenience: return the most recent active template for a user type (by createdAt desc) - async getLatestActiveForUserType(userType, templateType = 'contract', contractType = null, taxMode = null) { - logger.info('DocumentTemplateService.getLatestActiveForUserType:start', { userType, templateType, contractType, taxMode }); + async getLatestActiveForUserType(userType, templateType = 'contract', contractType = null, taxMode = null, lang = null) { + logger.info('DocumentTemplateService.getLatestActiveForUserType:start', { userType, templateType, contractType, taxMode, lang }); try { const list = await DocumentTemplateRepository.findActiveByUserType(userType, templateType, contractType, taxMode); - const latest = Array.isArray(list) && list.length ? list[0] : null; + const latest = selectTemplateByLanguage(list, lang); logger.info('DocumentTemplateService.getLatestActiveForUserType:result', { found: !!latest, id: latest?.id }); return latest; } catch (error) { diff --git a/utils/languageUtils.js b/utils/languageUtils.js new file mode 100644 index 0000000..cb4ac83 --- /dev/null +++ b/utils/languageUtils.js @@ -0,0 +1,89 @@ +const BUILTIN_LANGUAGES = [ + { languageCode: 'de', label: 'Deutsch', isEnabled: true, isCustom: false }, + { languageCode: 'en', label: 'English', isEnabled: true, isCustom: false }, + { languageCode: 'sl', label: 'Slovenian', isEnabled: true, isCustom: false }, +]; + +function normalizeLanguageCode(value) { + const raw = String(value == null ? '' : value).trim(); + if (!raw) return ''; + + const normalized = raw.replace('_', '-'); + if (!/^[a-z]{2,5}(?:-[a-zA-Z0-9]{2,8})?$/.test(normalized)) { + return ''; + } + + return normalized.toLowerCase(); +} + +function normalizeBoolean(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; +} + +function compareLanguageCodes(left, right) { + const order = ['de', 'en', 'sl']; + const leftIndex = order.indexOf(left); + const rightIndex = order.indexOf(right); + + if (leftIndex !== -1 || rightIndex !== -1) { + if (leftIndex === -1) return 1; + if (rightIndex === -1) return -1; + return leftIndex - rightIndex; + } + + return left.localeCompare(right); +} + +function mergeLanguageDescriptors(rows = []) { + const merged = new Map(); + + BUILTIN_LANGUAGES.forEach((entry) => { + merged.set(entry.languageCode, { ...entry }); + }); + + rows.forEach((row) => { + const languageCode = normalizeLanguageCode( + row?.languageCode ?? row?.language_code ?? row?.code ?? row?.lang, + ); + if (!languageCode) return; + + const existing = merged.get(languageCode) || { + languageCode, + label: languageCode.toUpperCase(), + isEnabled: true, + isCustom: true, + }; + + const label = String(row?.label ?? row?.name ?? existing.label ?? languageCode.toUpperCase()).trim() || existing.label; + + merged.set(languageCode, { + ...existing, + languageCode, + label, + isEnabled: normalizeBoolean(row?.isEnabled ?? row?.is_enabled, existing.isEnabled), + isCustom: normalizeBoolean(row?.isCustom ?? row?.is_custom, existing.isCustom), + }); + }); + + return Array.from(merged.values()).sort((left, right) => compareLanguageCodes(left.languageCode, right.languageCode)); +} + +function resolveDocumentTemplateStorageFolder(languageCode) { + const normalized = normalizeLanguageCode(languageCode); + if (normalized === 'en') return 'english'; + if (normalized === 'de') return 'german'; + return normalized || 'other'; +} + +module.exports = { + BUILTIN_LANGUAGES, + mergeLanguageDescriptors, + normalizeLanguageCode, + resolveDocumentTemplateStorageFolder, +}; \ No newline at end of file