diff --git a/controller/documentTemplate/DocumentTemplateController.js b/controller/documentTemplate/DocumentTemplateController.js index 7ade45d..40a2517 100644 --- a/controller/documentTemplate/DocumentTemplateController.js +++ b/controller/documentTemplate/DocumentTemplateController.js @@ -448,6 +448,73 @@ exports.updateTemplate = async (req, res) => { res.json(enriched); }; +// NEW: revise a template by creating a NEW template record (version bump) and deactivating the previous one. +// Route suggestion: POST /document-templates/:id/revise (multipart/form-data with 'file') +exports.reviseTemplate = async (req, res) => { + const previousId = req.params.id; + const { name, type, description, lang } = req.body; + const rawUserType = req.body.user_type || req.body.userType; + const rawContractType = req.body.contract_type || req.body.contractType; + const requestedState = req.body.state; // optional: 'active' | 'inactive' + + const file = req.file; + if (!file) return res.status(400).json({ error: 'No file uploaded' }); + + const previous = await DocumentTemplateService.getTemplate(previousId); + if (!previous) return res.status(404).json({ error: 'Template not found' }); + + const nextType = type !== undefined ? type : previous.type; + const nextLang = lang !== undefined ? lang : previous.lang; + if (!nextLang || !['en', 'de'].includes(nextLang)) return res.status(400).json({ error: 'Invalid or missing language' }); + + const allowedUserTypes = ['personal', 'company', 'both']; + const user_type = allowedUserTypes.includes(rawUserType) + ? rawUserType + : (previous.user_type || 'both'); + + const allowedContractTypes = ['contract', 'gdpr']; + let contract_type = null; + if (nextType === 'contract') { + const candidate = rawContractType !== undefined ? rawContractType : previous.contract_type; + contract_type = allowedContractTypes.includes(candidate) ? candidate : 'contract'; + } + + // Use "english" for en, "german" for de + const langFolder = nextLang === 'en' ? 'english' : 'german'; + const storageKey = `DocumentTemplates/${langFolder}/${Date.now()}_${file.originalname}`; + + const s3 = new S3Client({ + region: process.env.EXOSCALE_REGION, + endpoint: process.env.EXOSCALE_ENDPOINT, + credentials: { + accessKeyId: process.env.EXOSCALE_ACCESS_KEY, + secretAccessKey: process.env.EXOSCALE_SECRET_KEY + } + }); + await s3.send(new PutObjectCommand({ + Bucket: process.env.EXOSCALE_BUCKET, + Key: storageKey, + Body: file.buffer, + ContentType: file.mimetype + })); + + const payload = { + name: name !== undefined ? name : previous.name, + type: nextType, + contract_type, + storageKey, + description: description !== undefined ? description : previous.description, + lang: nextLang, + user_type, + ...(requestedState !== undefined ? { state: requestedState } : {}) + }; + + const created = await DocumentTemplateService.reviseTemplate(previousId, payload); + if (!created) return res.status(404).json({ error: 'Template not found' }); + const enriched = await enrichTemplate(created, s3); + res.status(201).json(enriched); +}; + exports.updateTemplateState = async (req, res) => { const id = req.params.id; const { state } = req.body; diff --git a/repositories/template/DocumentTemplateRepository.js b/repositories/template/DocumentTemplateRepository.js index 57774a5..5dd113e 100644 --- a/repositories/template/DocumentTemplateRepository.js +++ b/repositories/template/DocumentTemplateRepository.js @@ -28,21 +28,24 @@ class DocumentTemplateRepository { : null; const finalContractType = type === 'contract' ? (contract_type || 'contract') : null; + const version = Number.isFinite(Number(data.version)) ? Math.max(1, Number(data.version)) : 1; + const state = (data.state === 'active' || data.state === 'inactive') ? data.state : 'inactive'; + const query = ` INSERT INTO document_templates (name, type, contract_type, storageKey, description, lang, user_type, version, state, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, 1, 'inactive', NOW(), NOW()) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) `; try { if (conn) { - const [res] = await conn.execute(query, [name, type, finalContractType, storageKey, description, lang, user_type]); + const [res] = await conn.execute(query, [name, type, finalContractType, storageKey, description, lang, user_type, version, state]); const insertId = res && (res.insertId || res[0]?.insertId); logger.info('DocumentTemplateRepository.create:success', { id: insertId || res.insertId }); - return { id: insertId || res.insertId, name, type, contract_type: finalContractType, storageKey, description, lang, user_type, version: 1, state: 'inactive' }; + return { id: insertId || res.insertId, name, type, contract_type: finalContractType, storageKey, description, lang, user_type, version, state }; } - const result = await db.execute(query, [name, type, finalContractType, storageKey, description, lang, user_type]); + const result = await db.execute(query, [name, type, finalContractType, storageKey, description, lang, user_type, version, state]); const insertId = result && result.insertId ? result.insertId : (Array.isArray(result) && result[0] && result[0].insertId ? result[0].insertId : undefined); logger.info('DocumentTemplateRepository.create:success', { id: insertId }); - return { id: insertId, name, type, contract_type: finalContractType, storageKey, description, lang, user_type, version: 1, state: 'inactive' }; + return { id: insertId, name, type, contract_type: finalContractType, storageKey, description, lang, user_type, version, state }; } catch (error) { logger.error('DocumentTemplateRepository.create:error', { error: error.message }); throw error; @@ -141,6 +144,55 @@ class DocumentTemplateRepository { } } + // Deactivate other active contract templates that would conflict with the one being activated. + // Conflict dimensions: + // - same type='contract' + // - same contract_type ('contract' | 'gdpr') + // - same lang + // - overlapping user_type (personal/company/both) + async deactivateOtherActiveContracts({ excludeId, contract_type, lang, user_type }, conn) { + logger.info('DocumentTemplateRepository.deactivateOtherActiveContracts:start', { excludeId, contract_type, lang, user_type }); + const safeContractType = (contract_type === 'gdpr' || contract_type === 'contract') ? contract_type : 'contract'; + const safeLang = (lang === 'en' || lang === 'de') ? lang : 'en'; + const safeUserType = (user_type === 'personal' || user_type === 'company' || user_type === 'both') ? user_type : 'both'; + + // user_type overlap rules + const overlap = safeUserType === 'both' + ? ['personal', 'company', 'both'] + : [safeUserType, 'both']; + + const query = ` + UPDATE document_templates + SET state = 'inactive', updatedAt = NOW() + WHERE id <> ? + AND type = 'contract' + AND ( + contract_type = ? + OR (contract_type IS NULL AND ? = 'contract') + ) + AND lang = ? + AND state = 'active' + AND ( + user_type IN (${overlap.map(() => '?').join(', ')}) + OR user_type IS NULL + ) + `; + const params = [excludeId, safeContractType, safeContractType, safeLang, ...overlap]; + try { + if (conn) { + const [res] = await conn.execute(query, params); + logger.info('DocumentTemplateRepository.deactivateOtherActiveContracts:success', { affected: res?.affectedRows }); + return res?.affectedRows || 0; + } + const res = await db.execute(query, params); + logger.info('DocumentTemplateRepository.deactivateOtherActiveContracts:success', { affected: res?.affectedRows }); + return res?.affectedRows || 0; + } catch (error) { + logger.error('DocumentTemplateRepository.deactivateOtherActiveContracts:error', { error: error.message }); + throw error; + } + } + async delete(id, conn) { logger.info('DocumentTemplateRepository.delete:start', { id }); const query = `DELETE FROM document_templates WHERE id = ?`; diff --git a/routes/postRoutes.js b/routes/postRoutes.js index c4b214d..9ca2c60 100644 --- a/routes/postRoutes.js +++ b/routes/postRoutes.js @@ -59,6 +59,7 @@ router.post('/permissions', authMiddleware, PermissionController.create); // Document templates upload & signature generation POSTs (moved) router.post('/document-templates', authMiddleware, upload.single('file'), DocumentTemplateController.uploadTemplate); +router.post('/document-templates/:id/revise', authMiddleware, upload.single('file'), DocumentTemplateController.reviseTemplate); router.post('/document-templates/:id/generate-pdf-with-signature', authMiddleware, DocumentTemplateController.generatePdfWithSignature); // Document uploads (moved from routes/documents.js) diff --git a/services/template/DocumentTemplateService.js b/services/template/DocumentTemplateService.js index 65003a2..351066b 100644 --- a/services/template/DocumentTemplateService.js +++ b/services/template/DocumentTemplateService.js @@ -116,7 +116,25 @@ class DocumentTemplateService { 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); + } + const updated = await DocumentTemplateRepository.findById(id, uow.connection); await uow.commit(); logger.info('DocumentTemplateService.updateTemplateState:success', { id, state }); @@ -128,6 +146,59 @@ class DocumentTemplateService { } } + // 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 created = await DocumentTemplateRepository.create( + { + ...data, + 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: (data.contract_type || previous.contract_type || 'contract'), + lang: (data.lang || previous.lang), + user_type: (data.user_type || previous.user_type || 'both') + }, 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) { logger.info('DocumentTemplateService.getActiveTemplatesForUserType:start', { userType, templateType, contractType }); try {