const DocumentTemplateRepository = require('../../repositories/template/DocumentTemplateRepository'); const UnitOfWork = require('../../database/UnitOfWork'); const { logger } = require('../../middleware/logger'); const ALLOWED_USER_TYPES = new Set(['personal', 'company', 'both']); const ALLOWED_CONTRACT_TYPES = new Set(['contract', 'gdpr', 'abo']); const ALLOWED_INVOICE_TAX_MODES = new Set(['standard', 'reverse_charge', 'both']); function normalizeTemplateUserType(value) { return ALLOWED_USER_TYPES.has(value) ? value : 'both'; } function normalizeContractType(value) { return (value === undefined || value === null) ? value : String(value).trim().toLowerCase(); } function normalizeInvoiceTaxMode(value) { if (value === undefined || value === null || String(value).trim() === '') return 'both'; const normalized = String(value).trim().toLowerCase(); return ALLOWED_INVOICE_TAX_MODES.has(normalized) ? normalized : 'both'; } class DocumentTemplateService { async listTemplates() { logger.info('DocumentTemplateService.listTemplates:start'); try { const templates = await DocumentTemplateRepository.findAll(); logger.info('DocumentTemplateService.listTemplates:success', { count: templates.length }); return templates.map(t => ({ ...t, lang: t.lang })); } catch (error) { logger.error('DocumentTemplateService.listTemplates:error', { error: error.message }); throw error; } } async uploadTemplate(data) { logger.info('DocumentTemplateService.uploadTemplate:start', { name: data.name, type: data.type }); const uow = new UnitOfWork(); try { await uow.start(); 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)) ? normalizedContractType : (data.type === 'contract' ? 'contract' : null); const tax_mode = data.type === 'invoice' ? normalizeInvoiceTaxMode(data.tax_mode || data.taxMode) : null; const created = await DocumentTemplateRepository.create({ ...data, user_type, contract_type, tax_mode }, uow.connection); await uow.commit(); logger.info('DocumentTemplateService.uploadTemplate:success', { id: created.id }); return created; } catch (err) { logger.error('DocumentTemplateService.uploadTemplate:error', { error: err.message }); await uow.rollback(err); throw err; } } async getTemplate(id) { logger.info('DocumentTemplateService.getTemplate:start', { id }); try { const template = await DocumentTemplateRepository.findById(id); if (!template) { logger.warn('DocumentTemplateService.getTemplate:not_found', { id }); return null; } logger.debug('DocumentTemplateService.getTemplate:meta', { id, storageKey: template.storageKey, lang: template.lang, version: template.version }); logger.info('DocumentTemplateService.getTemplate:success', { id }); return { ...template, lang: template.lang }; } catch (error) { logger.error('DocumentTemplateService.getTemplate:error', { id, error: error.message }); throw error; } } async deleteTemplate(id) { logger.info('DocumentTemplateService.deleteTemplate:start', { id }); const uow = new UnitOfWork(); try { await uow.start(); await DocumentTemplateRepository.delete(id, uow.connection); await uow.commit(); logger.info('DocumentTemplateService.deleteTemplate:success', { id }); return true; } catch (err) { logger.error('DocumentTemplateService.deleteTemplate:error', { id, error: err.message }); await uow.rollback(err); throw err; } } async updateTemplate(id, data) { logger.info('DocumentTemplateService.updateTemplate:start', { id }); const uow = new UnitOfWork(); try { await uow.start(); const current = await DocumentTemplateRepository.findById(id, uow.connection); if (!current) { logger.warn('DocumentTemplateService.updateTemplate:not_found', { id }); await uow.rollback(); return null; } 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)) ? normalizeContractType(data.contract_type || data.contractType || current.contract_type) : 'contract') : null; const user_type = normalizeTemplateUserType(data.user_type || data.userType || current.user_type); const tax_mode = nextType === 'invoice' ? normalizeInvoiceTaxMode(data.tax_mode || data.taxMode || current.tax_mode) : null; const newVersion = (current.version || 1) + 1; await DocumentTemplateRepository.update( id, { ...data, version: newVersion, user_type, contract_type, tax_mode }, uow.connection ); const updated = await DocumentTemplateRepository.findById(id, uow.connection); await uow.commit(); logger.info('DocumentTemplateService.updateTemplate:success', { id, version: newVersion }); return updated; } catch (err) { logger.error('DocumentTemplateService.updateTemplate:error', { id, error: err.message }); await uow.rollback(err); throw err; } } async updateTemplateState(id, state) { logger.info('DocumentTemplateService.updateTemplateState:start', { id, state }); const uow = new UnitOfWork(); try { await uow.start(); const current = await DocumentTemplateRepository.findById(id, uow.connection); if (!current) { logger.warn('DocumentTemplateService.updateTemplateState:not_found', { id }); await uow.rollback(); return null; } await DocumentTemplateRepository.updateState(id, state, uow.connection); // Enforce singleton active template per (contract_type + lang + overlapping user scope) if (state === 'active' && current.type === 'contract') { await DocumentTemplateRepository.deactivateOtherActiveContracts({ excludeId: id, contract_type: current.contract_type || 'contract', lang: current.lang, user_type: current.user_type || 'both' }, uow.connection); } // Enforce singleton active invoice template per (lang + user_type) if (state === 'active' && current.type === 'invoice') { await DocumentTemplateRepository.deactivateOtherActiveInvoices({ excludeId: id, lang: current.lang, user_type: current.user_type || 'both', tax_mode: current.tax_mode || 'both', }, uow.connection); } const updated = await DocumentTemplateRepository.findById(id, uow.connection); await uow.commit(); logger.info('DocumentTemplateService.updateTemplateState:success', { id, state }); return updated; } catch (err) { logger.error('DocumentTemplateService.updateTemplateState:error', { id, state, error: err.message }); await uow.rollback(err); throw err; } } // NEW: Create a new template as a revision of an existing one. // - New record is created with version = previous.version + 1 // - Previous record is deactivated (state -> inactive) // - New record state defaults to previous state (active stays active, inactive stays inactive) async reviseTemplate(previousId, data) { logger.info('DocumentTemplateService.reviseTemplate:start', { previousId }); const uow = new UnitOfWork(); try { await uow.start(); const previous = await DocumentTemplateRepository.findById(previousId, uow.connection); if (!previous) { logger.warn('DocumentTemplateService.reviseTemplate:not_found', { previousId }); await uow.rollback(); return null; } const nextVersion = (previous.version || 1) + 1; const nextState = (data.state === 'active' || data.state === 'inactive') ? data.state : (previous.state === 'active' ? 'active' : 'inactive'); 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)) ? normalizeContractType(data.contract_type || data.contractType || previous.contract_type) : 'contract') : null; const effectiveTaxMode = effectiveType === 'invoice' ? normalizeInvoiceTaxMode(data.tax_mode || data.taxMode || previous.tax_mode) : null; const created = await DocumentTemplateRepository.create( { ...data, type: effectiveType, user_type: effectiveUserType, contract_type: effectiveContractType, tax_mode: effectiveTaxMode, version: nextVersion, state: nextState, }, uow.connection ); // If new template is active and is a contract template, deactivate other conflicting actives if (nextState === 'active' && (data.type === 'contract' || previous.type === 'contract')) { await DocumentTemplateRepository.deactivateOtherActiveContracts({ excludeId: created.id, contract_type: (effectiveContractType || previous.contract_type || 'contract'), lang: (data.lang || previous.lang), user_type: effectiveUserType, }, uow.connection); } // If new template is active and is an invoice template, deactivate other active invoices for same lang + user_type if (nextState === 'active' && effectiveType === 'invoice') { await DocumentTemplateRepository.deactivateOtherActiveInvoices({ excludeId: created.id, lang: (data.lang || previous.lang), user_type: effectiveUserType, tax_mode: effectiveTaxMode, }, uow.connection); } // Deactivate previous (requirement: deactive the previous one) await DocumentTemplateRepository.updateState(previousId, 'inactive', uow.connection); await uow.commit(); logger.info('DocumentTemplateService.reviseTemplate:success', { previousId, newId: created.id, version: nextVersion }); return created; } catch (err) { logger.error('DocumentTemplateService.reviseTemplate:error', { previousId, error: err.message }); await uow.rollback(err); throw err; } } async getActiveTemplatesForUserType(userType, templateType = null, contractType = null, taxMode = null) { logger.info('DocumentTemplateService.getActiveTemplatesForUserType:start', { userType, templateType, contractType, taxMode }); try { const rows = await DocumentTemplateRepository.findActiveByUserType(userType, templateType, contractType, taxMode); logger.info('DocumentTemplateService.getActiveTemplatesForUserType:success', { count: rows.length }); return rows.map(t => ({ ...t, lang: t.lang })); } catch (error) { logger.error('DocumentTemplateService.getActiveTemplatesForUserType:error', { error: error.message }); throw error; } } // 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 }); try { const list = await DocumentTemplateRepository.findActiveByUserType(userType, templateType, contractType, taxMode); const latest = Array.isArray(list) && list.length ? list[0] : null; logger.info('DocumentTemplateService.getLatestActiveForUserType:result', { found: !!latest, id: latest?.id }); return latest; } catch (error) { logger.error('DocumentTemplateService.getLatestActiveForUserType:error', { error: error.message }); throw error; } } // NEW: Retrieve Profit Planet signature (company stamp) as tag // Fallback order: // 1) Active stamp for provided companyId // 2) Any stamp for provided companyId // 3) Any active stamp globally // 4) Any stamp globally async getProfitPlanetSignatureTag({ companyId = null, maxW = 300, maxH = 300 } = {}) { const uow = new UnitOfWork(); const result = { tag: '', reason: 'not_started' }; logger.debug('DocumentTemplateService.getProfitPlanetSignatureTag:enter', { companyId, maxW, maxH }); try { await uow.start(); const conn = uow.connection; const safeRowMeta = (row) => { if (!row) return null; return { has_image: !!row.image_base64, mime: row.mime_type, image_len: row.image_base64 ? row.image_base64.length : 0, image_head: row.image_base64 ? row.image_base64.slice(0, 30) : '' }; }; // Helper to run single-row query safely const fetchOne = async (sql, params, reasonCode) => { logger.debug('DocumentTemplateService.getProfitPlanetSignatureTag:query:start', { reasonCode, sql, params }); try { const started = Date.now(); const [rows] = await conn.execute(sql, params); const ms = Date.now() - started; const rowCount = rows ? rows.length : 0; logger.debug('DocumentTemplateService.getProfitPlanetSignatureTag:query:result', { reasonCode, duration_ms: ms, rowCount, firstRow: safeRowMeta(rows && rows[0]) }); if (rows && rows[0] && rows[0].image_base64) { const mime = rows[0].mime_type || 'image/png'; const dataUri = `data:${mime};base64,${rows[0].image_base64}`; result.tag = ``; result.reason = reasonCode; logger.debug('DocumentTemplateService.getProfitPlanetSignatureTag:query:match', { reasonCode, mime, dataUri_len: dataUri.length, dataUri_head: dataUri.slice(0, 45) }); return true; } } catch (e) { logger.warn('DocumentTemplateService.getProfitPlanetSignatureTag:query_error', { reason: reasonCode, error: e.message }); } return false; }; if (companyId) { if (await fetchOne( 'SELECT mime_type,image_base64 FROM company_stamps WHERE company_id = ? AND is_active = 1 ORDER BY id DESC LIMIT 1', [companyId], 'company_active' )) { await uow.commit(); logger.debug('DocumentTemplateService.getProfitPlanetSignatureTag:exit', { reason: result.reason }); return result; } if (await fetchOne( 'SELECT mime_type,image_base64 FROM company_stamps WHERE company_id = ? ORDER BY is_active DESC, id DESC LIMIT 1', [companyId], 'company_any' )) { await uow.commit(); logger.debug('DocumentTemplateService.getProfitPlanetSignatureTag:exit', { reason: result.reason }); return result; } } if (await fetchOne( 'SELECT mime_type,image_base64 FROM company_stamps WHERE is_active = 1 ORDER BY id DESC LIMIT 1', [], 'global_active' )) { await uow.commit(); logger.debug('DocumentTemplateService.getProfitPlanetSignatureTag:exit', { reason: result.reason }); return result; } if (await fetchOne( 'SELECT mime_type,image_base64 FROM company_stamps ORDER BY is_active DESC, id DESC LIMIT 1', [], 'global_any' )) { await uow.commit(); logger.debug('DocumentTemplateService.getProfitPlanetSignatureTag:exit', { reason: result.reason }); return result; } result.reason = 'not_found'; await uow.commit(); logger.debug('DocumentTemplateService.getProfitPlanetSignatureTag:not_found', { companyId }); return result; } catch (err) { result.reason = 'error'; logger.error('DocumentTemplateService.getProfitPlanetSignatureTag:error', { error: err.message, companyId }); try { await uow.rollback(err); } catch (rbErr) { logger.error('DocumentTemplateService.getProfitPlanetSignatureTag:rollback_error', { error: rbErr.message }); } return result; } finally { logger.debug('DocumentTemplateService.getProfitPlanetSignatureTag:final', { reason: result.reason, hasTag: !!result.tag }); } } } module.exports = new DocumentTemplateService();