refactor/reverseChargeTemplate #31

Merged
Seazn merged 2 commits from refactor/reverseChargeTemplate into dev 2026-06-07 19:14:59 +00:00
16 changed files with 718 additions and 69 deletions
Showing only changes of commit 427c12be3c - Show all commits

View File

@ -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;

View File

@ -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');

View File

@ -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'

View File

@ -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 (

View File

@ -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,

View File

@ -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();

View File

@ -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;

View File

@ -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);

View File

@ -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);

View File

@ -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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/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(/<w:tab\b[^>]*\/>/gi, '\t')
.replace(/<w:(?:br|cr)\b[^>]*\/>/gi, '\n')
.replace(/<\/w:p>/gi, '\n')
.replace(/<\/w:tr>/gi, '\n')
.replace(/<\/w:tc>/gi, '\t')
.replace(/<w:t\b[^>]*>([\s\S]*?)<\/w:t>/gi, (_, value) => decodeXml(value))
.replace(/<w:delText\b[^>]*>([\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;
});

View File

@ -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');

View File

@ -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}`);
}

View File

@ -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];

View File

@ -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;

View File

@ -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) {

89
utils/languageUtils.js Normal file
View File

@ -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,
};