/** * Carbone Template Service - Node.js + Express * * Self-hosted Carbone (Community Edition) integration for CV template management. * Uses LibreOffice for DOCX -> PDF conversion. * * Endpoints: * GET /api/carbone/templates - List saved templates * GET /api/carbone/master-template - Download master blank DOCX template * POST /api/carbone/templates/upload - Upload a custom .docx template * POST /api/carbone/templates/:id/render - Render a template with CV data -> PDF * DELETE /api/carbone/templates/:id - Delete a template */ const express = require('express'); const multer = require('multer'); const carbone = require('carbone'); const fs = require('fs'); const path = require('path'); const { v4: uuidv4 } = require('uuid'); const router = express.Router(); // ============================================================ // CONFIGURATION // ============================================================ const TEMPLATES_DIR = path.join(__dirname, 'carbone-templates'); const MASTER_TEMPLATE_PATH = path.join(TEMPLATES_DIR, 'master_cv_template.docx'); // Ensure directories exist fs.mkdirSync(TEMPLATES_DIR, { recursive: true }); // Multer config for file uploads const storage = multer.diskStorage({ destination: (req, file, cb) => cb(null, TEMPLATES_DIR), filename: (req, file, cb) => { const id = uuidv4(); const ext = path.extname(file.originalname) || '.docx'; cb(null, `${id}${ext}`); } }); const upload = multer({ storage, limits: { fileSize: 10 * 1024 * 1024 }, // 10MB limit fileFilter: (req, file, cb) => { const allowed = ['.docx', '.doc', '.odt', '.rtf']; const ext = path.extname(file.originalname).toLowerCase(); if (allowed.includes(ext)) { cb(null, true); } else { cb(new Error(`File type ${ext} not supported. Use .docx, .doc, .odt, or .rtf`)); } } }); // Simple in-memory template registry (persisted to JSON file) const REGISTRY_PATH = path.join(TEMPLATES_DIR, 'registry.json'); function loadRegistry() { try { return JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf-8')); } catch { return []; } } function saveRegistry(registry) { fs.writeFileSync(REGISTRY_PATH, JSON.stringify(registry, null, 2)); } // ============================================================ // CARBONE TAG DOCUMENTATION // ============================================================ /** * CARBONE TAG REFERENCE (for use inside .docx templates) * ============================================ * * Carbone uses {d.property} syntax to map JSON data into the document. * Our application's data schema maps as follows: * * SIMPLE FIELDS (text replacement): * {d.firstName} -> Candidate's first name * {d.lastName} -> Candidate's last name * {d.email} -> Email address * {d.phone} -> Phone number * {d.address} -> Physical address * {d.linkedin} -> LinkedIn URL * {d.github} -> GitHub URL * {d.website} -> Website URL * {d.summary} -> Professional summary text * * LOOP TAGS (for arrays - repeat a section for each item): * * Work Experience loop: * {d.experience[i].position} -> Job title * {d.experience[i].company} -> Company name * {d.experience[i].location} -> Location * {d.experience[i].startDate} -> Start date (formatted) * {d.experience[i].endDate} -> End date or "Present" * {d.experience[i].description} -> Job description * {d.experience[i].duration} -> Calculated duration (e.g., "2.5 years") * * To create a loop in Carbone, wrap the repeated section: * {d.experience[i]:begin} ...content... {d.experience[i]:end} * * Achievements sub-loop (inside experience loop): * {d.experience[i].achievements[j]:begin} * • {d.experience[i].achievements[j]} * {d.experience[i].achievements[j]:end} * * Skills loop: * {d.skills[i].name} -> Skill name * {d.skills[i].category} -> Skill category * {d.skills[i].proficiency} -> Proficiency level * {d.skills[i].years} -> Years of experience (dynamic) * * Skills grouped by category: * {d.skillsByCategory[i].category:begin} * {d.skillsByCategory[i].category} * {d.skillsByCategory[i].skills[j].name:begin} * • {d.skillsByCategory[i].skills[j].name} ({d.skillsByCategory[i].skills[j].years}y) * {d.skillsByCategory[i].skills[j].name:end} * {d.skillsByCategory[i].category:end} * * Education loop: * {d.education[i].degree} -> Degree name * {d.education[i].field} -> Field of study * {d.education[i].institution} -> Institution name * {d.education[i].startDate} -> Start date * {d.education[i].endDate} -> End date * {d.education[i].grade} -> Grade/Result * * Certifications loop: * {d.certifications[i].name} -> Certification name * {d.certifications[i].issuer} -> Issuing organization * {d.certifications[i].date} -> Issue date * * CONDITIONALS: * {d.summary:ifExist:begin} ...shows only if summary exists... {d.summary:ifExist:end} * {d.linkedin:ifExist:begin} LinkedIn: {d.linkedin} {d.linkedin:ifExist:end} * * FORMATTING: * {d.experience[i].startDate:formatD(MMM YYYY)} -> Formats date as "Jan 2022" * {d.experience[i].endDate:formatD(MMM YYYY):ifEmpty(Present)} * * COMPOSITE (for full name): * {d.firstName} {d.lastName} */ // ============================================================ // DATA TRANSFORMER // ============================================================ /** * Transform our application's candidate data into the flat structure * that Carbone template tags expect. This handles date formatting, * dynamic experience calculation, and grouping skills by category. * * @param {Object} candidateData - Our app's candidate data (from /api/candidates/:id) * @param {string} referenceDate - Date for dynamic experience calc (YYYY-MM-DD) * @returns {Object} Flattened data object for Carbone */ function transformCandidateData(candidateData, referenceDate) { const refDate = referenceDate ? new Date(referenceDate) : new Date(); const candidate = candidateData.candidate || candidateData; // Helper: format date as "Mon YYYY" const fmtDate = (d) => { if (!d) return ''; try { const date = new Date(d); return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short' }); } catch { return String(d); } }; // Helper: calculate years between dates const calcYears = (start, end) => { if (!start) return 0; const endDate = end ? new Date(end) : refDate; const startDate = new Date(start); const diff = (endDate - startDate) / (365.25 * 24 * 3600 * 1000); return Math.round(diff * 10) / 10; }; // Transform experience const experience = (candidateData.experience || []).map(exp => { const achievements = Array.isArray(exp.achievements) ? exp.achievements : (typeof exp.achievements === 'string' ? safeJsonParse(exp.achievements, []) : []); const skillsUsed = Array.isArray(exp.skills_used) ? exp.skills_used : (typeof exp.skills_used === 'string' ? safeJsonParse(exp.skills_used, []) : []); return { position: exp.position || '', company: exp.company || '', location: exp.location || '', startDate: fmtDate(exp.start_date), endDate: exp.end_date ? fmtDate(exp.end_date) : 'Present', description: exp.description || '', duration: calcYears(exp.start_date, exp.end_date) + ' years', achievements: achievements, skillsUsed: skillsUsed.join(', ') }; }); // Transform skills with dynamic years const skills = (candidateData.skills || []).map(s => ({ name: s.skill_name || '', category: s.skill_category || 'Other', proficiency: s.proficiency || '', years: s.years_experience || calcYears(s.start_date, s.end_date), startYear: s.start_date ? new Date(s.start_date).getFullYear() : '' })); // Group skills by category for the grouped loop const categoryMap = {}; for (const s of skills) { if (!categoryMap[s.category]) categoryMap[s.category] = []; categoryMap[s.category].push(s); } const skillsByCategory = Object.entries(categoryMap).map(([category, items]) => ({ category, skills: items })); // Transform education const education = (candidateData.education || []).map(e => ({ degree: e.degree || '', field: e.field_of_study || '', institution: e.institution || '', startDate: fmtDate(e.start_date), endDate: fmtDate(e.end_date), grade: e.grade || '' })); // Transform certifications const certifications = (candidateData.certifications || []).map(c => ({ name: c.name || '', issuer: c.issuer || '', date: fmtDate(c.issue_date), expiryDate: fmtDate(c.expiry_date) })); return { // Personal details firstName: candidate.first_name || '', lastName: candidate.last_name || '', fullName: `${candidate.first_name || ''} ${candidate.last_name || ''}`.trim(), email: candidate.email || '', phone: candidate.phone || '', address: candidate.address || '', linkedin: candidate.linkedin || '', github: candidate.github || '', website: candidate.website || '', summary: candidate.summary || '', // Arrays (for loops) experience: experience, skills: skills, skillsByCategory: skillsByCategory, education: education, certifications: certifications, // Metadata generationDate: refDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }), referenceDate: refDate.toISOString().split('T')[0] }; } function safeJsonParse(str, fallback) { try { return JSON.parse(str); } catch { return fallback; } } // ============================================================ // MASTER TEMPLATE GENERATOR // ============================================================ /** * Generate the master blank CV template (.docx) with all Carbone tags. * This is the file users download, customize in Word, and re-upload. * * We build it programmatically using the docx library so we don't need * a pre-made binary file. */ async function generateMasterTemplate() { // We'll use a simple approach: create a minimal DOCX with the tags as text. // Carbone processes the text content of the DOCX, so we just need the tags // present in the document body. const docxContent = createMasterDocxXML(); const templatePath = MASTER_TEMPLATE_PATH; // Write as a .docx (which is a ZIP file with XML inside) // For simplicity, we use the 'docx' npm package if available, // otherwise we create a minimal valid DOCX manually. try { const DocxTemplater = require('docx'); // If docx package is available, use it // But for now, we'll create a minimal DOCX manually } catch (e) { // docx package not available — use manual approach } // Create a minimal valid DOCX file (ZIP with required XML parts) const archiver = require('archiver') ? require('archiver') : null; if (archiver) { // Use archiver to create the ZIP } // Simplest approach: use carbone's built-in template creation // Actually, the easiest way is to create the DOCX using python-docx // which is already installed in our venv const { execSync } = require('child_process'); const pythonScript = path.join(__dirname, 'generate_master_template.py'); fs.writeFileSync(pythonScript, MASTER_TEMPLATE_PYTHON); execSync(`python3 ${pythonScript} "${templatePath}"`, { timeout: 30000 }); return templatePath; } // Python script content for generating the master DOCX template const MASTER_TEMPLATE_PYTHON = ` import sys from docx import Document from docx.shared import Pt, Inches, RGBColor from docx.enum.text import WD_ALIGN_PARAGRAPH def create_master_template(output_path): doc = Document() # Set margins for section in doc.sections: section.top_margin = Inches(0.5) section.bottom_margin = Inches(0.5) section.left_margin = Inches(0.75) section.right_margin = Inches(0.75) # === HEADER: Name + Contact === p = doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.CENTER run = p.add_run('{d.fullName}') run.font.size = Pt(24) run.font.bold = True p = doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.CENTER run = p.add_run('{d.email} | {d.phone} | {d.address}') run.font.size = Pt(10) run.font.color.rgb = RGBColor(0x66, 0x66, 0x66) p = doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.CENTER run = p.add_run('{d.linkedin:ifExist:begin}LinkedIn: {d.linkedin} | {d.linkedin:ifExist:end}{d.github:ifExist:begin}GitHub: {d.github} | {d.github:ifExist:end}{d.website:ifExist:begin}Web: {d.website}{d.website:ifExist:end}') run.font.size = Pt(10) run.font.color.rgb = RGBColor(0x66, 0x66, 0x66) # Divider doc.add_paragraph('_' * 80) # === PROFESSIONAL SUMMARY === h = doc.add_heading('Professional Summary', level=2) p = doc.add_paragraph('{d.summary}') # === WORK EXPERIENCE (loop) === doc.add_heading('Work Experience', level=2) # Carbone loop: {d.experience[i]:begin} ... {d.experience[i]:end} p = doc.add_paragraph('{d.experience[i]:begin}') p = doc.add_paragraph() run = p.add_run('{d.experience[i].position}') run.font.bold = True run.font.size = Pt(12) run = p.add_run(' at {d.experience[i].company}') run.font.size = Pt(12) p = doc.add_paragraph('{d.experience[i].startDate} - {d.experience[i].endDate} ({d.experience[i].duration})') p.runs[0].font.size = Pt(10) p.runs[0].font.italic = True p.runs[0].font.color.rgb = RGBColor(0x88, 0x88, 0x88) p = doc.add_paragraph('{d.experience[i].description}') # Achievements sub-loop p = doc.add_paragraph('{d.experience[i].achievements[j]:begin}') p = doc.add_paragraph('{d.experience[i].achievements[j]}', style='List Bullet') p = doc.add_paragraph('{d.experience[i].achievements[j]:end}') p = doc.add_paragraph('Skills: {d.experience[i].skillsUsed}') p.runs[0].font.size = Pt(10) p.runs[0].font.italic = True p = doc.add_paragraph('{d.experience[i]:end}') # === SKILLS (grouped by category) === doc.add_heading('Skills', level=2) p = doc.add_paragraph('{d.skillsByCategory[i]:begin}') p = doc.add_paragraph() run = p.add_run('{d.skillsByCategory[i].category}: ') run.font.bold = True run.font.size = Pt(11) p = doc.add_paragraph('{d.skillsByCategory[i].skills[j]:begin}') p = doc.add_paragraph('{d.skillsByCategory[i].skills[j].name} ({d.skillsByCategory[i].skills[j].years}y){d.skillsByCategory[i].skills[j]:end}', style='List Bullet') p = doc.add_paragraph('{d.skillsByCategory[i].end}') # === EDUCATION (loop) === doc.add_heading('Education', level=2) p = doc.add_paragraph('{d.education[i]:begin}') p = doc.add_paragraph() run = p.add_run('{d.education[i].degree}') run.font.bold = True run = p.add_run(' in {d.education[i].field}') p = doc.add_paragraph('{d.education[i].institution}') p.runs[0].font.size = Pt(11) p = doc.add_paragraph('{d.education[i].startDate} - {d.education[i].endDate}') p.runs[0].font.size = Pt(10) p.runs[0].font.color.rgb = RGBColor(0x88, 0x88, 0x88) p = doc.add_paragraph('{d.education[i].grade:ifExist:begin}Grade: {d.education[i].grade}{d.education[i].grade:ifExist:end}') p = doc.add_paragraph('{d.education[i]:end}') # === CERTIFICATIONS (loop) === doc.add_heading('Certifications', level=2) p = doc.add_paragraph('{d.certifications[i]:begin}') p = doc.add_paragraph('{d.certifications[i].name} - {d.certifications[i].issuer} ({d.certifications[i].date})', style='List Bullet') p = doc.add_paragraph('{d.certifications[i]:end}') # Footer doc.add_paragraph() p = doc.add_paragraph('Generated on {d.generationDate}') p.alignment = WD_ALIGN_PARAGRAPH.CENTER p.runs[0].font.size = Pt(8) p.runs[0].font.color.rgb = RGBColor(0xAA, 0xAA, 0xAA) doc.save(output_path) print(f'Master template saved to {output_path}') if __name__ == '__main__': create_master_template(sys.argv[1]) `; // ============================================================ // API ROUTES // ============================================================ // GET /api/carbone/templates - List all saved templates router.get('/templates', (req, res) => { const registry = loadRegistry(); res.json({ templates: registry }); }); // GET /api/carbone/master-template - Download master template router.get('/master-template', async (req, res) => { try { // Generate master template if it doesn't exist if (!fs.existsSync(MASTER_TEMPLATE_PATH)) { await generateMasterTemplate(); } res.download(MASTER_TEMPLATE_PATH, 'master_cv_template.docx'); } catch (err) { console.error('Master template error:', err); res.status(500).json({ error: 'Failed to generate master template: ' + err.message }); } }); // GET /api/carbone/help-docx - Download tags reference as .docx router.get('/help-docx', (req, res) => { const helpPath = path.join(TEMPLATES_DIR, 'HELP_TAGS_REFERENCE.docx'); if (fs.existsSync(helpPath)) { res.download(helpPath, 'Carbone_Tags_Reference.docx'); } else { res.status(404).json({ error: 'Help file not found' }); } }); // GET /api/carbone/help - Get tags reference as JSON (for in-app viewing) router.get('/help', (req, res) => { res.json({ sections: [ { title: '1. Personal Details', type: 'simple', tags: [ { tag: '{d.fullName}', desc: 'Full name (first + last)' }, { tag: '{d.firstName}', desc: 'First name only' }, { tag: '{d.lastName}', desc: 'Last name only' }, { tag: '{d.email}', desc: 'Email address' }, { tag: '{d.phone}', desc: 'Phone number' }, { tag: '{d.address}', desc: 'Physical address' }, { tag: '{d.linkedin}', desc: 'LinkedIn profile URL' }, { tag: '{d.github}', desc: 'GitHub profile URL' }, { tag: '{d.website}', desc: 'Personal website' }, { tag: '{d.summary}', desc: 'Professional summary paragraph' }, { tag: '{d.generationDate}', desc: 'Date the CV was generated' } ] }, { title: '2. Work Experience (Loop)', type: 'loop', note: 'Carbone auto-detects loops from [i]. Section repeats for each job.', tags: [ { tag: '{d.experience[i].position}', desc: 'Job title' }, { tag: '{d.experience[i].company}', desc: 'Company name' }, { tag: '{d.experience[i].location}', desc: 'Work location' }, { tag: '{d.experience[i].startDate}', desc: 'Start date (Mon YYYY)' }, { tag: '{d.experience[i].endDate}', desc: 'End date or "Present"' }, { tag: '{d.experience[i].duration}', desc: 'Calculated duration (e.g., 4.5 years)' }, { tag: '{d.experience[i].description}', desc: 'Job description' }, { tag: '{d.experience[i].skillsUsed}', desc: 'Comma-separated skills used' }, { tag: '{d.experience[i].achievements[j]}', desc: 'Achievement bullet (sub-loop)' } ] }, { title: '3. Skills', type: 'loop', tags: [ { tag: '{d.skills[i].name}', desc: 'Skill name' }, { tag: '{d.skills[i].category}', desc: 'Skill category' }, { tag: '{d.skills[i].proficiency}', desc: 'Proficiency level' }, { tag: '{d.skills[i].years}', desc: 'Years of experience (dynamic)' }, { tag: '{d.skillsByCategory[i].category}', desc: 'Category name (grouped)' }, { tag: '{d.skillsByCategory[i].skills[j].name}', desc: 'Skill in category (grouped)' }, { tag: '{d.skillsByCategory[i].skills[j].years}', desc: 'Years in category (grouped)' } ] }, { title: '4. Education (Loop)', type: 'loop', tags: [ { tag: '{d.education[i].degree}', desc: 'Degree name' }, { tag: '{d.education[i].field}', desc: 'Field of study' }, { tag: '{d.education[i].institution}', desc: 'University/institution' }, { tag: '{d.education[i].startDate}', desc: 'Start date' }, { tag: '{d.education[i].endDate}', desc: 'End date' }, { tag: '{d.education[i].grade}', desc: 'Grade/result' } ] }, { title: '5. Certifications (Loop)', type: 'loop', tags: [ { tag: '{d.certifications[i].name}', desc: 'Certification name' }, { tag: '{d.certifications[i].issuer}', desc: 'Issuing organization' }, { tag: '{d.certifications[i].date}', desc: 'Issue date' }, { tag: '{d.certifications[i].expiryDate}', desc: 'Expiry date (if any)' } ] }, { title: '6. Conditionals (Show/Hide)', type: 'simple', note: 'Show content only when a field has a value (or is empty).', tags: [ { tag: '{d.field:showBegin}...{d.field:showEnd}', desc: 'Show if NOT empty' }, { tag: '{d.field:hideBegin}...{d.field:hideEnd}', desc: 'Show if empty' }, { tag: '{d.field:ifEM(replacement text)}', desc: 'Show text if field is empty' }, { tag: '{d.field:ifNEM(replacement text)}', desc: 'Show text if field is not empty' }, { tag: '{d.field:ifEQ(value):showBegin}...{d.field:showEnd}', desc: 'Show if equals value' }, { tag: '{d.field:ifContain(text):showBegin}...{d.field:showEnd}', desc: 'Show if contains text' }, { tag: '{d.field:ifGT(5):showBegin}...{d.field:showEnd}', desc: 'Show if greater than value' } ] }, { title: '7. Formatting', type: 'simple', tags: [ { tag: '{d.field:formatD(YYYY-MM-DD)}', desc: 'Format date: 2022-01-01' }, { tag: '{d.field:formatD(MMM YYYY)}', desc: 'Format date: Jan 2022' }, { tag: '{d.field:formatN(0.0)}', desc: 'Format number with 1 decimal' }, { tag: '{d.field:upperCase}', desc: 'Convert to UPPERCASE' }, { tag: '{d.field:lowerCase}', desc: 'Convert to lowercase' }, { tag: '{d.field:ucWords}', desc: 'Capitalize Each Word' }, { tag: '{d.field:substr(0, 100)}', desc: 'First 100 characters' } ] }, { title: '8. Array Operations', type: 'simple', tags: [ { tag: '{d.skills:arrayJoin(, )}', desc: 'Join all items with separator' }, { tag: '{d.skills:arrayMap(name):arrayJoin(, )}', desc: 'Extract field and join' }, { tag: '{d.experience:count}', desc: 'Count items in array' } ] } ] }); }); // POST /api/carbone/templates/upload - Upload a custom .docx template router.post('/templates/upload', upload.single('template'), (req, res) => { if (!req.file) { return res.status(400).json({ error: 'No file uploaded' }); } const name = req.body.name || path.basename(req.file.originalname, path.extname(req.file.originalname)); const id = path.basename(req.file.filename, path.extname(req.file.filename)); const registry = loadRegistry(); const entry = { id: id, name: name, filename: req.file.filename, originalName: req.file.originalname, path: req.file.path, uploadedAt: new Date().toISOString() }; registry.push(entry); saveRegistry(registry); res.json({ success: true, template: entry }); }); // POST /api/carbone/templates/:id/render - Render template with data -> PDF router.post('/templates/:id/render', async (req, res) => { const templateId = req.params.id; const registry = loadRegistry(); const template = registry.find(t => t.id === templateId); if (!template) { return res.status(404).json({ error: 'Template not found' }); } if (!fs.existsSync(template.path)) { return res.status(404).json({ error: 'Template file missing from disk' }); } // Get candidate data from request body const candidateData = req.body.candidateData || req.body; const referenceDate = req.body.referenceDate || req.body.generation_date; // Transform data for Carbone const carboneData = transformCandidateData(candidateData, referenceDate); // Step 1: Use Carbone to merge data into the DOCX template (no PDF conversion) const mergePromise = new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('Carbone merge timed out')), 30000); carbone.render(template.path, carboneData, {}, (err, result) => { clearTimeout(timer); if (err) reject(err); else resolve(result); }); }); try { const docxBuffer = await mergePromise; // Step 2: Convert merged DOCX to PDF using LibreOffice directly // (Carbone's built-in converter has issues with pipe communication) const tmpDir = path.join(__dirname, 'output'); fs.mkdirSync(tmpDir, { recursive: true }); const tmpId = uuidv4(); const tmpDocx = path.join(tmpDir, `${tmpId}.docx`); const tmpPdf = path.join(tmpDir, `${tmpId}.pdf`); fs.writeFileSync(tmpDocx, docxBuffer); // Call LibreOffice headless for PDF conversion const { execFile } = require('child_process'); const convertPromise = new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('LibreOffice conversion timed out after 60s')), 60000); execFile('soffice', [ '--headless', '--invisible', '--nocrashreport', '--nodefault', '--nologo', '--nofirststartwizard', '--norestore', '--convert-to', 'pdf', '--outdir', tmpDir, tmpDocx ], { timeout: 60000 }, (err, stdout, stderr) => { clearTimeout(timer); if (err) reject(err); else if (!fs.existsSync(tmpPdf)) reject(new Error('PDF was not generated')); else resolve(tmpPdf); }); }); const pdfPath = await convertPromise; const pdfBuffer = fs.readFileSync(pdfPath); // Clean up temp files try { fs.unlinkSync(tmpDocx); } catch {} try { fs.unlinkSync(tmpPdf); } catch {} // Set headers for PDF download res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `attachment; filename="${template.name.replace(/\s+/g, '_')}_CV.pdf"`); res.setHeader('Content-Length', pdfBuffer.length); res.send(pdfBuffer); } catch (err) { console.error('Carbone render error:', err); if (err.message.includes('timed out')) { res.status(504).json({ error: 'PDF generation timed out. LibreOffice may be busy. Try again.' }); } else if (err.message.includes('corrupt') || err.message.includes('invalid')) { res.status(400).json({ error: 'Template file appears to be corrupted or invalid' }); } else { res.status(500).json({ error: 'PDF generation failed: ' + err.message }); } } }); // DELETE /api/carbone/templates/:id - Delete a template router.delete('/templates/:id', (req, res) => { const templateId = req.params.id; const registry = loadRegistry(); const template = registry.find(t => t.id === templateId); if (!template) { return res.status(404).json({ error: 'Template not found' }); } // Delete file from disk if (fs.existsSync(template.path)) { fs.unlinkSync(template.path); } // Remove from registry const newRegistry = registry.filter(t => t.id !== templateId); saveRegistry(newRegistry); res.json({ success: true, message: 'Template deleted' }); }); module.exports = { router, transformCandidateData, generateMasterTemplate };