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.
This commit is contained in:
parent
5882bf718c
commit
427c12be3c
@ -1,6 +1,21 @@
|
|||||||
const UnitOfWork = require('../../database/UnitOfWork');
|
const UnitOfWork = require('../../database/UnitOfWork');
|
||||||
const UserSettingsRepository = require('../../repositories/settings/UserSettingsRepository');
|
const UserSettingsRepository = require('../../repositories/settings/UserSettingsRepository');
|
||||||
|
const I18nPreferencesRepository = require('../../repositories/settings/I18nPreferencesRepository');
|
||||||
const { logger } = require('../../middleware/logger'); // <-- import logger
|
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 {
|
class UserSettingsController {
|
||||||
static async getSettings(req, res) {
|
static async getSettings(req, res) {
|
||||||
@ -24,6 +39,47 @@ class UserSettingsController {
|
|||||||
res.status(500).json({ success: false, message: error.message });
|
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;
|
module.exports = UserSettingsController;
|
||||||
|
|||||||
@ -12,7 +12,11 @@ const db = require('../../database/database');
|
|||||||
const UnitOfWork = require('../../database/UnitOfWork');
|
const UnitOfWork = require('../../database/UnitOfWork');
|
||||||
const { logger } = require('../../middleware/logger');
|
const { logger } = require('../../middleware/logger');
|
||||||
const CompanyStampService = require('../../services/stamp/company/CompanyStampService');
|
const CompanyStampService = require('../../services/stamp/company/CompanyStampService');
|
||||||
|
const I18nPreferencesRepository = require('../../repositories/settings/I18nPreferencesRepository');
|
||||||
const { s3: sharedExoscaleClient } = require('../../utils/exoscaleUploader');
|
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
|
// Ensure debug directory exists and helper to save files
|
||||||
function ensureDebugDir() {
|
function ensureDebugDir() {
|
||||||
@ -41,6 +45,29 @@ function normalizeInvoiceTaxMode(rawValue) {
|
|||||||
return ['standard', 'reverse_charge', 'both'].includes(normalized) ? normalized : 'both';
|
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
|
// Helper to remove/empty placeholders except allow-list
|
||||||
// Updated: match any content inside {{ ... }} (not only \w+) so placeholders like {{company.name}} are sanitized.
|
// 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').
|
// 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)
|
// optional: upload sanitized preview to S3 (keeps earlier behavior for offline debugging)
|
||||||
try {
|
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 previewKey = `DocumentTemplates/previews/${langFolder}/template_${template.id}_preview_${Date.now()}.html`;
|
||||||
const putCmd = new PutObjectCommand({
|
const putCmd = new PutObjectCommand({
|
||||||
Bucket: process.env.EXOSCALE_BUCKET,
|
Bucket: process.env.EXOSCALE_BUCKET,
|
||||||
@ -350,7 +377,8 @@ async function fetchLatestActiveContractTemplateHtml({ userType, contractType })
|
|||||||
? String(contractType).toLowerCase()
|
? String(contractType).toLowerCase()
|
||||||
: 'contract';
|
: '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: '' };
|
if (!latest) return { latest: null, html: '' };
|
||||||
|
|
||||||
const s3 = new S3Client({
|
const s3 = new S3Client({
|
||||||
@ -371,13 +399,13 @@ async function fetchLatestActiveContractTemplateHtml({ userType, contractType })
|
|||||||
return { latest, html: ensureHtmlDocument(html) };
|
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 allowedContractTypes = ['contract', 'gdpr', 'abo'];
|
||||||
const safeContractType = allowedContractTypes.includes(String(contractType || '').toLowerCase())
|
const safeContractType = allowedContractTypes.includes(String(contractType || '').toLowerCase())
|
||||||
? String(contractType).toLowerCase()
|
? String(contractType).toLowerCase()
|
||||||
: 'contract';
|
: '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: '' };
|
if (!latest) return { latest: null, html: '' };
|
||||||
|
|
||||||
const s3 = new S3Client({
|
const s3 = new S3Client({
|
||||||
@ -480,7 +508,8 @@ exports.uploadTemplate = async (req, res) => {
|
|||||||
const user_type = allowed.includes(rawUserType) ? rawUserType : 'both';
|
const user_type = allowed.includes(rawUserType) ? rawUserType : 'both';
|
||||||
const file = req.file;
|
const file = req.file;
|
||||||
if (!file) return res.status(400).json({ error: 'No file uploaded' });
|
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 allowedContractTypes = ['contract', 'gdpr', 'abo'];
|
||||||
const normalizedContractType = rawContractType !== undefined && rawContractType !== null
|
const normalizedContractType = rawContractType !== undefined && rawContractType !== null
|
||||||
@ -491,9 +520,7 @@ exports.uploadTemplate = async (req, res) => {
|
|||||||
: (type === 'contract' ? 'contract' : null);
|
: (type === 'contract' ? 'contract' : null);
|
||||||
const tax_mode = type === 'invoice' ? normalizeInvoiceTaxMode(rawTaxMode) : null;
|
const tax_mode = type === 'invoice' ? normalizeInvoiceTaxMode(rawTaxMode) : null;
|
||||||
|
|
||||||
// Use "english" for en, "german" for de
|
const key = buildTemplateStorageKey(supportedLang, file.originalname);
|
||||||
const langFolder = lang === 'en' ? 'english' : 'german';
|
|
||||||
const key = `DocumentTemplates/${langFolder}/${Date.now()}_${file.originalname}`;
|
|
||||||
|
|
||||||
const s3 = new S3Client({
|
const s3 = new S3Client({
|
||||||
region: process.env.EXOSCALE_REGION,
|
region: process.env.EXOSCALE_REGION,
|
||||||
@ -510,7 +537,7 @@ exports.uploadTemplate = async (req, res) => {
|
|||||||
ContentType: file.mimetype
|
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
|
// Enrich with previewUrl, fileUrl, html
|
||||||
const enriched = await enrichTemplate(template, s3);
|
const enriched = await enrichTemplate(template, s3);
|
||||||
res.status(201).json(enriched);
|
res.status(201).json(enriched);
|
||||||
@ -550,6 +577,12 @@ exports.updateTemplate = async (req, res) => {
|
|||||||
const current = await DocumentTemplateService.getTemplate(id);
|
const current = await DocumentTemplateService.getTemplate(id);
|
||||||
if (!current) return res.status(404).json({ error: 'Template not found' });
|
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 nextType = type !== undefined ? type : current.type;
|
||||||
const allowedContractTypes = ['contract', 'gdpr', 'abo'];
|
const allowedContractTypes = ['contract', 'gdpr', 'abo'];
|
||||||
let contract_type = null;
|
let contract_type = null;
|
||||||
@ -566,10 +599,8 @@ exports.updateTemplate = async (req, res) => {
|
|||||||
? normalizeInvoiceTaxMode(rawTaxMode !== undefined ? rawTaxMode : current.tax_mode)
|
? normalizeInvoiceTaxMode(rawTaxMode !== undefined ? rawTaxMode : current.tax_mode)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Use "english" for en, "german" for de
|
|
||||||
const langFolder = lang ? (lang === 'en' ? 'english' : 'german') : (current.lang === 'en' ? 'english' : 'german');
|
|
||||||
if (file) {
|
if (file) {
|
||||||
storageKey = `DocumentTemplates/${langFolder}/${Date.now()}_${file.originalname}`;
|
storageKey = buildTemplateStorageKey(nextLang, file.originalname);
|
||||||
const s3 = new S3Client({
|
const s3 = new S3Client({
|
||||||
region: process.env.EXOSCALE_REGION,
|
region: process.env.EXOSCALE_REGION,
|
||||||
endpoint: process.env.EXOSCALE_ENDPOINT,
|
endpoint: process.env.EXOSCALE_ENDPOINT,
|
||||||
@ -592,7 +623,7 @@ exports.updateTemplate = async (req, res) => {
|
|||||||
contract_type,
|
contract_type,
|
||||||
tax_mode,
|
tax_mode,
|
||||||
description: description !== undefined ? description : current.description,
|
description: description !== undefined ? description : current.description,
|
||||||
lang: lang !== undefined ? lang : current.lang,
|
lang: nextLang,
|
||||||
storageKey: storageKey || current.storageKey,
|
storageKey: storageKey || current.storageKey,
|
||||||
...(user_type !== undefined ? { user_type } : {})
|
...(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' });
|
if (!previous) return res.status(404).json({ error: 'Template not found' });
|
||||||
|
|
||||||
const nextType = type !== undefined ? type : previous.type;
|
const nextType = type !== undefined ? type : previous.type;
|
||||||
const nextLang = lang !== undefined ? lang : previous.lang;
|
const nextLang = lang !== undefined
|
||||||
if (!nextLang || !['en', 'de'].includes(nextLang)) return res.status(400).json({ error: 'Invalid or missing language' });
|
? 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 allowedUserTypes = ['personal', 'company', 'both'];
|
||||||
const user_type = allowedUserTypes.includes(rawUserType)
|
const user_type = allowedUserTypes.includes(rawUserType)
|
||||||
@ -647,9 +680,7 @@ exports.reviseTemplate = async (req, res) => {
|
|||||||
? normalizeInvoiceTaxMode(rawTaxMode !== undefined ? rawTaxMode : previous.tax_mode)
|
? normalizeInvoiceTaxMode(rawTaxMode !== undefined ? rawTaxMode : previous.tax_mode)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Use "english" for en, "german" for de
|
const storageKey = buildTemplateStorageKey(nextLang, file.originalname);
|
||||||
const langFolder = nextLang === 'en' ? 'english' : 'german';
|
|
||||||
const storageKey = `DocumentTemplates/${langFolder}/${Date.now()}_${file.originalname}`;
|
|
||||||
|
|
||||||
const s3 = new S3Client({
|
const s3 = new S3Client({
|
||||||
region: process.env.EXOSCALE_REGION,
|
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 contractTypeParam = (req.query.contract_type || req.query.contractType || '').toString().toLowerCase();
|
||||||
const allowedContractTypes = ['contract', 'gdpr', 'abo'];
|
const allowedContractTypes = ['contract', 'gdpr', 'abo'];
|
||||||
const contractType = allowedContractTypes.includes(contractTypeParam) ? contractTypeParam : 'contract';
|
const contractType = allowedContractTypes.includes(contractTypeParam) ? contractTypeParam : 'contract';
|
||||||
|
const lang = getRequestedTemplateLanguage(req);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { latest, html } = await fetchLatestActiveContractTemplateHtml({ userType, contractType });
|
const { latest, html } = await fetchLatestActiveContractTemplateHtml({ userType, contractType, lang });
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
logger.info('[previewLatestForMe] no active template', { userId: targetUserId, userType, contractType });
|
logger.info('[previewLatestForMe] no active template', { userId: targetUserId, userType, contractType });
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
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' });
|
if (!req.user) return res.status(401).json({ success: false, message: 'Unauthorized' });
|
||||||
const targetUserId = req.user.id || req.user.userId;
|
const targetUserId = req.user.id || req.user.userId;
|
||||||
const userType = (req.user.user_type || req.user.userType || '').toString().toLowerCase();
|
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' });
|
if (!targetUserId || !userType) return res.status(400).json({ success: false, message: 'Invalid authenticated user' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { latest, html } = await fetchLatestActiveContractTemplateHtml({ userType, contractType: 'abo' });
|
const { latest, html } = await fetchLatestActiveContractTemplateHtml({ userType, contractType: 'abo', lang });
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
logger.info('[previewLatestAboForMe] no active template', { userId: targetUserId, userType, contractType: 'abo' });
|
logger.info('[previewLatestAboForMe] no active template', { userId: targetUserId, userType, contractType: 'abo' });
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
|||||||
@ -2,6 +2,7 @@ const UnitOfWork = require('../../database/UnitOfWork');
|
|||||||
const ContractUploadService = require('../../services/contracts/ContractUploadService');
|
const ContractUploadService = require('../../services/contracts/ContractUploadService');
|
||||||
const UserDocumentRepository = require('../../repositories/documents/UserDocumentRepository');
|
const UserDocumentRepository = require('../../repositories/documents/UserDocumentRepository');
|
||||||
const { logger } = require('../../middleware/logger');
|
const { logger } = require('../../middleware/logger');
|
||||||
|
const { normalizeLanguageCode } = require('../../utils/languageUtils');
|
||||||
|
|
||||||
function normalizeSignature(signatureImage) {
|
function normalizeSignature(signatureImage) {
|
||||||
if (!signatureImage) return null;
|
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 {
|
class ContractUploadController {
|
||||||
static async uploadPersonalContract(req, res) {
|
static async uploadPersonalContract(req, res) {
|
||||||
const userId = req.user.userId;
|
const userId = req.user.userId;
|
||||||
logger.info(`[ContractUploadController] uploadPersonalContract called for userId: ${userId}`);
|
logger.info(`[ContractUploadController] uploadPersonalContract called for userId: ${userId}`);
|
||||||
const file = req.file; // optional, we now generate from templates when absent
|
const file = req.file; // optional, we now generate from templates when absent
|
||||||
const contractData = req.body.contractData ? JSON.parse(req.body.contractData) : undefined;
|
const contractData = req.body.contractData ? JSON.parse(req.body.contractData) : undefined;
|
||||||
|
const lang = resolveRequestedLanguage(contractData, req.user);
|
||||||
const signatureImage = req.body.signatureImage;
|
const signatureImage = req.body.signatureImage;
|
||||||
|
|
||||||
const unitOfWork = new UnitOfWork();
|
const unitOfWork = new UnitOfWork();
|
||||||
@ -63,6 +80,7 @@ class ContractUploadController {
|
|||||||
contractCategory: 'personal',
|
contractCategory: 'personal',
|
||||||
unitOfWork,
|
unitOfWork,
|
||||||
contractData,
|
contractData,
|
||||||
|
lang,
|
||||||
signatureImage: signatureMeta ? signatureMeta.base64 : null,
|
signatureImage: signatureMeta ? signatureMeta.base64 : null,
|
||||||
contract_type,
|
contract_type,
|
||||||
user_type: 'personal'
|
user_type: 'personal'
|
||||||
@ -110,6 +128,7 @@ class ContractUploadController {
|
|||||||
logger.info(`[ContractUploadController] uploadCompanyContract called for userId: ${userId}`);
|
logger.info(`[ContractUploadController] uploadCompanyContract called for userId: ${userId}`);
|
||||||
const file = req.file;
|
const file = req.file;
|
||||||
const contractData = req.body.contractData ? JSON.parse(req.body.contractData) : undefined;
|
const contractData = req.body.contractData ? JSON.parse(req.body.contractData) : undefined;
|
||||||
|
const lang = resolveRequestedLanguage(contractData, req.user);
|
||||||
const signatureImage = req.body.signatureImage;
|
const signatureImage = req.body.signatureImage;
|
||||||
const unitOfWork = new UnitOfWork();
|
const unitOfWork = new UnitOfWork();
|
||||||
await unitOfWork.start();
|
await unitOfWork.start();
|
||||||
@ -143,6 +162,7 @@ class ContractUploadController {
|
|||||||
contractCategory: 'company',
|
contractCategory: 'company',
|
||||||
unitOfWork,
|
unitOfWork,
|
||||||
contractData,
|
contractData,
|
||||||
|
lang,
|
||||||
signatureImage: signatureMeta ? signatureMeta.base64 : null,
|
signatureImage: signatureMeta ? signatureMeta.base64 : null,
|
||||||
contract_type,
|
contract_type,
|
||||||
user_type: 'company'
|
user_type: 'company'
|
||||||
|
|||||||
@ -846,6 +846,7 @@ const createDatabase = async () => {
|
|||||||
high_contrast_mode BOOLEAN DEFAULT FALSE,
|
high_contrast_mode BOOLEAN DEFAULT FALSE,
|
||||||
two_factor_auth_enabled BOOLEAN DEFAULT FALSE,
|
two_factor_auth_enabled BOOLEAN DEFAULT FALSE,
|
||||||
account_visibility ENUM('public', 'private') DEFAULT 'public',
|
account_visibility ENUM('public', 'private') DEFAULT 'public',
|
||||||
|
preferred_language VARCHAR(16) NULL,
|
||||||
show_email BOOLEAN DEFAULT TRUE,
|
show_email BOOLEAN DEFAULT TRUE,
|
||||||
show_phone BOOLEAN DEFAULT TRUE,
|
show_phone BOOLEAN DEFAULT TRUE,
|
||||||
data_export_requested BOOLEAN DEFAULT FALSE,
|
data_export_requested BOOLEAN DEFAULT FALSE,
|
||||||
@ -857,6 +858,8 @@ const createDatabase = async () => {
|
|||||||
`);
|
`);
|
||||||
console.log('✅ User settings table created/verified');
|
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) ---
|
// --- Company Settings (single-row, global invoice / company info) ---
|
||||||
await connection.query(`
|
await connection.query(`
|
||||||
CREATE TABLE IF NOT EXISTS company_settings (
|
CREATE TABLE IF NOT EXISTS company_settings (
|
||||||
|
|||||||
@ -2,6 +2,8 @@ const jwt = require('jsonwebtoken');
|
|||||||
const { logger } = require('./logger');
|
const { logger } = require('./logger');
|
||||||
const UnitOfWork = require('../database/UnitOfWork');
|
const UnitOfWork = require('../database/UnitOfWork');
|
||||||
const UserStatusRepository = require('../repositories/status/UserStatusRepository');
|
const UserStatusRepository = require('../repositories/status/UserStatusRepository');
|
||||||
|
const UserSettingsRepository = require('../repositories/settings/UserSettingsRepository');
|
||||||
|
const { normalizeLanguageCode } = require('../utils/languageUtils');
|
||||||
|
|
||||||
async function authMiddleware(req, res, next) {
|
async function authMiddleware(req, res, next) {
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
@ -57,10 +59,29 @@ async function authMiddleware(req, res, next) {
|
|||||||
const unitOfWork = new UnitOfWork();
|
const unitOfWork = new UnitOfWork();
|
||||||
await unitOfWork.start();
|
await unitOfWork.start();
|
||||||
unitOfWork.registerRepository('status', new UserStatusRepository(unitOfWork));
|
unitOfWork.registerRepository('status', new UserStatusRepository(unitOfWork));
|
||||||
|
unitOfWork.registerRepository('settings', new UserSettingsRepository(unitOfWork));
|
||||||
const statusRepo = unitOfWork.getRepository('status');
|
const statusRepo = unitOfWork.getRepository('status');
|
||||||
|
const settingsRepo = unitOfWork.getRepository('settings');
|
||||||
const userStatus = await statusRepo.getStatusByUserId(normalizedUserId);
|
const userStatus = await statusRepo.getStatusByUserId(normalizedUserId);
|
||||||
|
const userSettings = await settingsRepo.getSettingsByUserId(normalizedUserId, unitOfWork);
|
||||||
await unitOfWork.commit();
|
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') {
|
if (userStatus && userStatus.status === 'suspended') {
|
||||||
logger.warn('authMiddleware:user_suspended', {
|
logger.warn('authMiddleware:user_suspended', {
|
||||||
userId: normalizedUserId,
|
userId: normalizedUserId,
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
const db = require('../../database/database');
|
const db = require('../../database/database');
|
||||||
|
const { mergeLanguageDescriptors } = require('../../utils/languageUtils');
|
||||||
|
|
||||||
class I18nPreferencesRepository {
|
class I18nPreferencesRepository {
|
||||||
_safeJsonArray(value) {
|
_safeJsonArray(value) {
|
||||||
@ -39,6 +40,25 @@ class I18nPreferencesRepository {
|
|||||||
return this._normalizeRow(rows[0]);
|
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 } = {}) {
|
async upsert({ categories, globalKeys, updatedByUserId } = {}) {
|
||||||
const current = await this.get();
|
const current = await this.get();
|
||||||
|
|
||||||
|
|||||||
@ -5,10 +5,10 @@ class UserSettingsRepository {
|
|||||||
this.unitOfWork = unitOfWork;
|
this.unitOfWork = unitOfWork;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSettingsByUserId(userId) {
|
async getSettingsByUserId(userId, unitOfWork = null) {
|
||||||
logger.info('UserSettingsRepository.getSettingsByUserId:start', { userId });
|
logger.info('UserSettingsRepository.getSettingsByUserId:start', { userId });
|
||||||
try {
|
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]);
|
const [rows] = await conn.query('SELECT * FROM user_settings WHERE user_id = ?', [userId]);
|
||||||
logger.info('UserSettingsRepository.getSettingsByUserId:success', { userId, found: rows.length > 0 });
|
logger.info('UserSettingsRepository.getSettingsByUserId:success', { userId, found: rows.length > 0 });
|
||||||
return rows.length > 0 ? rows[0] : null;
|
return rows.length > 0 ? rows[0] : null;
|
||||||
@ -32,6 +32,32 @@ class UserSettingsRepository {
|
|||||||
throw error;
|
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;
|
module.exports = UserSettingsRepository;
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
const db = require('../../database/database');
|
const db = require('../../database/database');
|
||||||
const { logger } = require('../../middleware/logger');
|
const { logger } = require('../../middleware/logger');
|
||||||
|
const { normalizeLanguageCode } = require('../../utils/languageUtils');
|
||||||
|
|
||||||
const ALLOWED_USER_TYPES = new Set(['personal', 'company', 'both']);
|
const ALLOWED_USER_TYPES = new Set(['personal', 'company', 'both']);
|
||||||
const ALLOWED_CONTRACT_TYPES = new Set(['contract', 'gdpr', 'abo']);
|
const ALLOWED_CONTRACT_TYPES = new Set(['contract', 'gdpr', 'abo']);
|
||||||
@ -179,7 +180,7 @@ class DocumentTemplateRepository {
|
|||||||
const safeContractType = (normalizedContractType === 'gdpr' || normalizedContractType === 'contract' || normalizedContractType === 'abo')
|
const safeContractType = (normalizedContractType === 'gdpr' || normalizedContractType === 'contract' || normalizedContractType === 'abo')
|
||||||
? normalizedContractType
|
? normalizedContractType
|
||||||
: 'contract';
|
: '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';
|
const safeUserType = (user_type === 'personal' || user_type === 'company' || user_type === 'both') ? user_type : 'both';
|
||||||
|
|
||||||
// user_type overlap rules
|
// user_type overlap rules
|
||||||
@ -222,7 +223,7 @@ class DocumentTemplateRepository {
|
|||||||
// Deactivate other active invoice templates for the same language and user_type.
|
// Deactivate other active invoice templates for the same language and user_type.
|
||||||
async deactivateOtherActiveInvoices({ excludeId, lang, user_type, tax_mode }, conn) {
|
async deactivateOtherActiveInvoices({ excludeId, lang, user_type, tax_mode }, conn) {
|
||||||
logger.info('DocumentTemplateRepository.deactivateOtherActiveInvoices:start', { excludeId, lang, user_type, tax_mode });
|
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 safeUserType = normalizeTemplateUserType(user_type);
|
||||||
const safeTaxMode = normalizeInvoiceTaxMode(tax_mode);
|
const safeTaxMode = normalizeInvoiceTaxMode(tax_mode);
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ const router = express.Router();
|
|||||||
const authMiddleware = require('../middleware/authMiddleware');
|
const authMiddleware = require('../middleware/authMiddleware');
|
||||||
const adminOnly = require('../middleware/adminOnly');
|
const adminOnly = require('../middleware/adminOnly');
|
||||||
const AdminUserController = require('../controller/admin/AdminUserController');
|
const AdminUserController = require('../controller/admin/AdminUserController');
|
||||||
|
const UserSettingsController = require('../controller/auth/UserSettingsController');
|
||||||
const DocumentTemplateController = require('../controller/documentTemplate/DocumentTemplateController');
|
const DocumentTemplateController = require('../controller/documentTemplate/DocumentTemplateController');
|
||||||
const CoffeeController = require('../controller/admin/CoffeeController');
|
const CoffeeController = require('../controller/admin/CoffeeController');
|
||||||
const CompanySettingsController = require('../controller/admin/CompanySettingsController');
|
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)
|
// PUT /document-templates/:id (moved from routes/documentTemplates.js)
|
||||||
router.put('/document-templates/:id', authMiddleware, upload.single('file'), DocumentTemplateController.updateTemplate);
|
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)
|
// Admin: update coffee product (supports picture file replacement)
|
||||||
router.put('/admin/coffee/:id', authMiddleware, adminOnly, upload.single('picture'), CoffeeController.update);
|
router.put('/admin/coffee/:id', authMiddleware, adminOnly, upload.single('picture'), CoffeeController.update);
|
||||||
|
|
||||||
|
|||||||
223
scripts/compareAboContractDocxVersions.js
Normal file
223
scripts/compareAboContractDocxVersions.js
Normal 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(/&/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(/<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;
|
||||||
|
});
|
||||||
@ -7,19 +7,20 @@ const DocumentTemplateService = require('../template/DocumentTemplateService');
|
|||||||
const MailService = require('../email/MailService');
|
const MailService = require('../email/MailService');
|
||||||
const pool = require('../../database/database');
|
const pool = require('../../database/database');
|
||||||
const { logger } = require('../../middleware/logger');
|
const { logger } = require('../../middleware/logger');
|
||||||
|
const { normalizeLanguageCode } = require('../../utils/languageUtils');
|
||||||
|
|
||||||
class AboContractService {
|
class AboContractService {
|
||||||
constructor() {
|
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).
|
* 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.
|
* Falls back to the local file if no active template is found.
|
||||||
*/
|
*/
|
||||||
async _loadTemplate(userType = 'both') {
|
async _loadTemplate(userType = 'both', lang = null) {
|
||||||
try {
|
try {
|
||||||
const latest = await DocumentTemplateService.getLatestActiveForUserType(userType, 'contract', 'abo');
|
const latest = await DocumentTemplateService.getLatestActiveForUserType(userType, 'contract', 'abo', null, lang);
|
||||||
if (latest?.storageKey) {
|
if (latest?.storageKey) {
|
||||||
const command = new GetObjectCommand({
|
const command = new GetObjectCommand({
|
||||||
Bucket: process.env.EXOSCALE_BUCKET,
|
Bucket: process.env.EXOSCALE_BUCKET,
|
||||||
@ -41,8 +42,21 @@ class AboContractService {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn('AboContractService:s3_template_load_failed', { message: e?.message });
|
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) {
|
_escapeHtml(value) {
|
||||||
@ -131,7 +145,7 @@ class AboContractService {
|
|||||||
const userType = await this._resolveUserType(actorUser);
|
const userType = await this._resolveUserType(actorUser);
|
||||||
let template;
|
let template;
|
||||||
try {
|
try {
|
||||||
template = await this._loadTemplate(userType);
|
template = await this._loadTemplate(userType, lang);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('AboContractService:template_missing', { message: e?.message });
|
logger.error('AboContractService:template_missing', { message: e?.message });
|
||||||
throw new Error('ABO contract template missing');
|
throw new Error('ABO contract template missing');
|
||||||
|
|||||||
@ -9,6 +9,7 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { logger } = require('../../middleware/logger');
|
const { logger } = require('../../middleware/logger');
|
||||||
const puppeteer = require('puppeteer');
|
const puppeteer = require('puppeteer');
|
||||||
|
const { normalizeLanguageCode } = require('../../utils/languageUtils');
|
||||||
|
|
||||||
// Robust stream/Body -> Buffer reader (supports async iterable, web streams, node streams, buffers)
|
// Robust stream/Body -> Buffer reader (supports async iterable, web streams, node streams, buffers)
|
||||||
async function streamToBuffer(body) {
|
async function streamToBuffer(body) {
|
||||||
@ -228,7 +229,23 @@ class ContractUploadService {
|
|||||||
contract_type = 'contract',
|
contract_type = 'contract',
|
||||||
user_type = 'personal'
|
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 pdfBuffer, originalFilename, mimeType, fileSize;
|
||||||
let contractBody;
|
let contractBody;
|
||||||
|
|
||||||
@ -240,10 +257,12 @@ class ContractUploadService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// If templateId and lang are provided, fetch HTML template from object storage
|
// If templateId and lang are provided, fetch HTML template from object storage
|
||||||
if (templateId && lang) {
|
if (templateId && resolvedLanguage) {
|
||||||
logger.info('ContractUploadService.uploadContract:fetching_template', { templateId, lang });
|
logger.info('ContractUploadService.uploadContract:fetching_template', { templateId, lang: resolvedLanguage });
|
||||||
const templateMeta = await DocumentTemplateService.getTemplate(templateId);
|
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
|
// Fetch HTML from object storage
|
||||||
const s3 = new S3Client({ region: process.env.EXOSCALE_REGION });
|
const s3 = new S3Client({ region: process.env.EXOSCALE_REGION });
|
||||||
const getObj = await s3.send(new GetObjectCommand({
|
const getObj = await s3.send(new GetObjectCommand({
|
||||||
@ -315,7 +334,13 @@ class ContractUploadService {
|
|||||||
fileSize = file.size;
|
fileSize = file.size;
|
||||||
} else {
|
} else {
|
||||||
// NEW: auto-generate PDF from latest active template (contract_type) when no file provided
|
// 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) {
|
if (!tmpl) {
|
||||||
throw new Error(`No active ${contract_type} template found for user type ${user_type}`);
|
throw new Error(`No active ${contract_type} template found for user type ${user_type}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ const { s3: sharedExoscaleClient } = require('../../utils/exoscaleUploader');
|
|||||||
const { logger } = require('../../middleware/logger');
|
const { logger } = require('../../middleware/logger');
|
||||||
const puppeteer = require('puppeteer');
|
const puppeteer = require('puppeteer');
|
||||||
const pool = require('../../database/database');
|
const pool = require('../../database/database');
|
||||||
|
const { normalizeLanguageCode } = require('../../utils/languageUtils');
|
||||||
|
|
||||||
const CoffeeShippingFeeService = require('../subscriptions/CoffeeShippingFeeService');
|
const CoffeeShippingFeeService = require('../subscriptions/CoffeeShippingFeeService');
|
||||||
|
|
||||||
@ -233,35 +234,52 @@ class InvoiceService {
|
|||||||
_selectInvoiceTemplate(templates, { lang = 'en', userType = 'personal', taxMode = 'standard' } = {}) {
|
_selectInvoiceTemplate(templates, { lang = 'en', userType = 'personal', taxMode = 'standard' } = {}) {
|
||||||
if (!Array.isArray(templates) || !templates.length) return null;
|
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 safeUserType = this._normalizeInvoiceUserType(userType);
|
||||||
const safeTaxMode = this._normalizeInvoiceTemplateTaxMode(taxMode);
|
const safeTaxMode = this._normalizeInvoiceTemplateTaxMode(taxMode);
|
||||||
const matchesTaxMode = (template, mode) => this._normalizeInvoiceTemplateTaxMode(template?.tax_mode) === mode;
|
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 matchesLanguage = (template, candidateLanguage) => normalizeLanguageCode(template?.lang) === candidateLanguage;
|
||||||
const selected = templates.find(matches);
|
const userTypeCandidates = [safeUserType, 'both'];
|
||||||
if (selected) return selected;
|
|
||||||
|
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];
|
return templates[0];
|
||||||
|
|||||||
@ -1,6 +1,27 @@
|
|||||||
const db = require('../../database/database');
|
const db = require('../../database/database');
|
||||||
|
|
||||||
const SYSTEM_POOL_NAMES = ['ABO 60', 'ABO 120', 'Business', 'Gigantea', 'Core'];
|
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) {
|
function toTwo(value) {
|
||||||
return Number(Number(value || 0).toFixed(2));
|
return Number(Number(value || 0).toFixed(2));
|
||||||
@ -11,6 +32,28 @@ function toFour(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class PoolInflowService {
|
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 }) {
|
async upsertCapsuleSalesForInvoice({ conn, invoiceId, abonementId, paidAtDate, currency, byCoffee }) {
|
||||||
const entries = Array.from(byCoffee.entries());
|
const entries = Array.from(byCoffee.entries());
|
||||||
for (const [coffeeId, capsulesCountRaw] of entries) {
|
for (const [coffeeId, capsulesCountRaw] of entries) {
|
||||||
@ -126,13 +169,23 @@ class PoolInflowService {
|
|||||||
? breakdown
|
? breakdown
|
||||||
.map((line) => ({
|
.map((line) => ({
|
||||||
coffeeId: Number(line?.coffee_table_id),
|
coffeeId: Number(line?.coffee_table_id),
|
||||||
|
packsCount: Number(line?.packs || 0),
|
||||||
capsulesCount: Number(line?.packs || 0) * 10,
|
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 };
|
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();
|
const byCoffee = new Map();
|
||||||
for (const line of normalizedLines) {
|
for (const line of normalizedLines) {
|
||||||
byCoffee.set(line.coffeeId, (byCoffee.get(line.coffeeId) || 0) + line.capsulesCount);
|
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 };
|
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 {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
reason: 'ok',
|
reason: 'ok',
|
||||||
@ -164,8 +222,10 @@ class PoolInflowService {
|
|||||||
abonementId,
|
abonementId,
|
||||||
paidAtDate,
|
paidAtDate,
|
||||||
byCoffee,
|
byCoffee,
|
||||||
pools,
|
pools: selectedPools,
|
||||||
normalizedLines,
|
normalizedLines,
|
||||||
|
totalPacks,
|
||||||
|
selectedAboPoolName: resolveAboPoolName(totalPacks),
|
||||||
currency: invoice.currency || abonement.currency || 'EUR',
|
currency: invoice.currency || abonement.currency || 'EUR',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -182,12 +242,20 @@ class PoolInflowService {
|
|||||||
const byCoffee = analysis.byCoffee;
|
const byCoffee = analysis.byCoffee;
|
||||||
const pools = analysis.pools;
|
const pools = analysis.pools;
|
||||||
const currency = analysis.currency;
|
const currency = analysis.currency;
|
||||||
|
const selectedAboPoolName = analysis.selectedAboPoolName;
|
||||||
const conn = await db.getConnection();
|
const conn = await db.getConnection();
|
||||||
let inserted = 0;
|
let inserted = 0;
|
||||||
try {
|
try {
|
||||||
let alreadyExists = 0;
|
let alreadyExists = 0;
|
||||||
|
let staleRemoved = 0;
|
||||||
await conn.beginTransaction();
|
await conn.beginTransaction();
|
||||||
|
|
||||||
|
staleRemoved = await this.removeStaleAboPoolInflowsForInvoice({
|
||||||
|
conn,
|
||||||
|
invoiceId: normalizedInvoiceId,
|
||||||
|
selectedAboPoolName,
|
||||||
|
});
|
||||||
|
|
||||||
await this.upsertCapsuleSalesForInvoice({
|
await this.upsertCapsuleSalesForInvoice({
|
||||||
conn,
|
conn,
|
||||||
invoiceId: normalizedInvoiceId,
|
invoiceId: normalizedInvoiceId,
|
||||||
@ -221,6 +289,8 @@ class PoolInflowService {
|
|||||||
paid_at: paidAtDate,
|
paid_at: paidAtDate,
|
||||||
booking_basis: 'gross',
|
booking_basis: 'gross',
|
||||||
compatibility_note: 'gross values stored in existing net columns',
|
compatibility_note: 'gross values stored in existing net columns',
|
||||||
|
total_packs: analysis.totalPacks,
|
||||||
|
selected_abo_pool: resolveAboPoolName(analysis.totalPacks),
|
||||||
member_multiplier: memberMultiplier,
|
member_multiplier: memberMultiplier,
|
||||||
core_members_count: pool.pool_name === 'Core' ? memberMultiplier : null,
|
core_members_count: pool.pool_name === 'Core' ? memberMultiplier : null,
|
||||||
};
|
};
|
||||||
@ -250,7 +320,13 @@ class PoolInflowService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await conn.commit();
|
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) {
|
} catch (err) {
|
||||||
await conn.rollback();
|
await conn.rollback();
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
const DocumentTemplateRepository = require('../../repositories/template/DocumentTemplateRepository');
|
const DocumentTemplateRepository = require('../../repositories/template/DocumentTemplateRepository');
|
||||||
const UnitOfWork = require('../../database/UnitOfWork');
|
const UnitOfWork = require('../../database/UnitOfWork');
|
||||||
const { logger } = require('../../middleware/logger');
|
const { logger } = require('../../middleware/logger');
|
||||||
|
const { normalizeLanguageCode } = require('../../utils/languageUtils');
|
||||||
|
|
||||||
const ALLOWED_USER_TYPES = new Set(['personal', 'company', 'both']);
|
const ALLOWED_USER_TYPES = new Set(['personal', 'company', 'both']);
|
||||||
const ALLOWED_CONTRACT_TYPES = new Set(['contract', 'gdpr', 'abo']);
|
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';
|
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 {
|
class DocumentTemplateService {
|
||||||
async listTemplates() {
|
async listTemplates() {
|
||||||
logger.info('DocumentTemplateService.listTemplates:start');
|
logger.info('DocumentTemplateService.listTemplates:start');
|
||||||
@ -44,7 +62,7 @@ class DocumentTemplateService {
|
|||||||
const rawContractType = (data.contract_type || data.contractType);
|
const rawContractType = (data.contract_type || data.contractType);
|
||||||
const normalizedContractType = normalizeContractType(rawContractType);
|
const normalizedContractType = normalizeContractType(rawContractType);
|
||||||
const user_type = normalizeTemplateUserType(data.user_type || data.userType);
|
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
|
? normalizedContractType
|
||||||
: (data.type === 'contract' ? 'contract' : null);
|
: (data.type === 'contract' ? 'contract' : null);
|
||||||
const tax_mode = data.type === 'invoice'
|
const tax_mode = data.type === 'invoice'
|
||||||
@ -107,7 +125,7 @@ class DocumentTemplateService {
|
|||||||
}
|
}
|
||||||
const nextType = data.type !== undefined ? data.type : current.type;
|
const nextType = data.type !== undefined ? data.type : current.type;
|
||||||
const contract_type = nextType === 'contract'
|
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)
|
? normalizeContractType(data.contract_type || data.contractType || current.contract_type)
|
||||||
: 'contract')
|
: 'contract')
|
||||||
: null;
|
: null;
|
||||||
@ -200,7 +218,7 @@ class DocumentTemplateService {
|
|||||||
const effectiveType = data.type || previous.type;
|
const effectiveType = data.type || previous.type;
|
||||||
const effectiveUserType = normalizeTemplateUserType(data.user_type || data.userType || previous.user_type);
|
const effectiveUserType = normalizeTemplateUserType(data.user_type || data.userType || previous.user_type);
|
||||||
const effectiveContractType = effectiveType === 'contract'
|
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)
|
? normalizeContractType(data.contract_type || data.contractType || previous.contract_type)
|
||||||
: 'contract')
|
: 'contract')
|
||||||
: null;
|
: null;
|
||||||
@ -267,11 +285,11 @@ class DocumentTemplateService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convenience: return the most recent active template for a user type (by createdAt desc)
|
// Convenience: return the most recent active template for a user type (by createdAt desc)
|
||||||
async getLatestActiveForUserType(userType, templateType = 'contract', contractType = null, taxMode = null) {
|
async getLatestActiveForUserType(userType, templateType = 'contract', contractType = null, taxMode = null, lang = null) {
|
||||||
logger.info('DocumentTemplateService.getLatestActiveForUserType:start', { userType, templateType, contractType, taxMode });
|
logger.info('DocumentTemplateService.getLatestActiveForUserType:start', { userType, templateType, contractType, taxMode, lang });
|
||||||
try {
|
try {
|
||||||
const list = await DocumentTemplateRepository.findActiveByUserType(userType, templateType, contractType, taxMode);
|
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 });
|
logger.info('DocumentTemplateService.getLatestActiveForUserType:result', { found: !!latest, id: latest?.id });
|
||||||
return latest;
|
return latest;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
89
utils/languageUtils.js
Normal file
89
utils/languageUtils.js
Normal 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,
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user