This commit is contained in:
seaznCode 2026-01-17 20:06:35 +01:00
commit 964ff2f37e
4 changed files with 196 additions and 5 deletions

View File

@ -448,6 +448,73 @@ exports.updateTemplate = async (req, res) => {
res.json(enriched); 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) => { exports.updateTemplateState = async (req, res) => {
const id = req.params.id; const id = req.params.id;
const { state } = req.body; const { state } = req.body;

View File

@ -28,21 +28,24 @@ class DocumentTemplateRepository {
: null; : null;
const finalContractType = type === 'contract' ? (contract_type || 'contract') : 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 = ` const query = `
INSERT INTO document_templates (name, type, contract_type, storageKey, description, lang, user_type, version, state, createdAt, updatedAt) 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 { try {
if (conn) { 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); const insertId = res && (res.insertId || res[0]?.insertId);
logger.info('DocumentTemplateRepository.create:success', { id: insertId || res.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); 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 }); 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) { } catch (error) {
logger.error('DocumentTemplateRepository.create:error', { error: error.message }); logger.error('DocumentTemplateRepository.create:error', { error: error.message });
throw error; 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) { async delete(id, conn) {
logger.info('DocumentTemplateRepository.delete:start', { id }); logger.info('DocumentTemplateRepository.delete:start', { id });
const query = `DELETE FROM document_templates WHERE id = ?`; const query = `DELETE FROM document_templates WHERE id = ?`;

View File

@ -60,6 +60,7 @@ router.post('/permissions', authMiddleware, PermissionController.create);
// Document templates upload & signature generation POSTs (moved) // Document templates upload & signature generation POSTs (moved)
router.post('/document-templates', authMiddleware, upload.single('file'), DocumentTemplateController.uploadTemplate); 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); router.post('/document-templates/:id/generate-pdf-with-signature', authMiddleware, DocumentTemplateController.generatePdfWithSignature);
// Document uploads (moved from routes/documents.js) // Document uploads (moved from routes/documents.js)

View File

@ -116,7 +116,25 @@ class DocumentTemplateService {
const uow = new UnitOfWork(); const uow = new UnitOfWork();
try { try {
await uow.start(); 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); 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); const updated = await DocumentTemplateRepository.findById(id, uow.connection);
await uow.commit(); await uow.commit();
logger.info('DocumentTemplateService.updateTemplateState:success', { id, state }); 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) { async getActiveTemplatesForUserType(userType, templateType = null, contractType = null) {
logger.info('DocumentTemplateService.getActiveTemplatesForUserType:start', { userType, templateType, contractType }); logger.info('DocumentTemplateService.getActiveTemplatesForUserType:start', { userType, templateType, contractType });
try { try {