const express = require('express'); const cors = require('cors'); const fs = require('fs').promises; const fsSync = require('fs'); const path = require('path'); const { v4: uuidv4 } = require('uuid'); const { spawn } = require('child_process'); const app = express(); const PORT = process.env.PORT || 3002; const DATA_DIR = process.env.DATA_DIR || '/data'; const SCRIPTS_DIR = process.env.SCRIPTS_DIR || '/scripts'; const PUBLIC_DIR = process.env.PUBLIC_DIR || '/srv/data'; // Middleware app.use(cors()); app.use(express.json({ limit: '50mb' })); // Helper functions const getFilePath = (collection) => path.join(DATA_DIR, `${collection}.json`); const readCollection = async (collection) => { const filePath = getFilePath(collection); try { const data = await fs.readFile(filePath, 'utf8'); return JSON.parse(data); } catch (error) { if (error.code === 'ENOENT') { return null; } throw error; } }; const writeCollection = async (collection, data) => { const filePath = getFilePath(collection); await fs.mkdir(DATA_DIR, { recursive: true }); await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf8'); }; // Simple hash function for passwords const simpleHash = (str) => { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; } return hash.toString(36); }; // Initialize default data const initializeDefaults = async () => { // Initialize auth data with default admin const authData = await readCollection('auth'); if (!authData) { const defaultAuth = { users: [ { id: 'admin-1', username: 'admin', email: 'support@nabd-co.com', password: simpleHash('admin123'), role: 'admin', status: 'approved', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }, ], }; await writeCollection('auth', defaultAuth); console.log('Initialized default auth data'); } // Initialize budget data const budgetData = await readCollection('budget'); if (!budgetData) { const defaultBudget = { transactions: [], founders: [], settings: { currency: 'USD', fiscalYearStart: 1, runwayWarningMonths: 6, }, targets: [], recurringTransactions: [], }; await writeCollection('budget', defaultBudget); console.log('Initialized default budget data'); } // Initialize planning data const planningData = await readCollection('planning'); if (!planningData) { const defaultPlanning = { sprints: [], tasks: [], comments: [], timeLogs: [], }; await writeCollection('planning', defaultPlanning); console.log('Initialized default planning data'); } // Initialize documentation data const docsData = await readCollection('documentation'); if (!docsData) { const defaultDocs = { documents: [], categories: ['Architecture', 'API', 'User Guide', 'Development'], }; await writeCollection('documentation', defaultDocs); console.log('Initialized default documentation data'); } }; // ==================== GENERIC CRUD ROUTES ==================== // Get entire collection app.get('/api/:collection', async (req, res) => { try { const { collection } = req.params; const data = await readCollection(collection); res.json(data || {}); } catch (error) { console.error(`Error reading ${req.params.collection}:`, error); res.status(500).json({ error: 'Failed to read data' }); } }); // Update entire collection app.put('/api/:collection', async (req, res) => { try { const { collection } = req.params; await writeCollection(collection, req.body); res.json({ success: true }); } catch (error) { console.error(`Error writing ${req.params.collection}:`, error); res.status(500).json({ error: 'Failed to write data' }); } }); // ==================== AUTH ROUTES ==================== app.post('/api/auth/login', async (req, res) => { try { const { username, password } = req.body; const authData = await readCollection('auth'); const hashedPassword = simpleHash(password); const user = authData.users.find( u => u.username === username && u.password === hashedPassword ); if (!user) { return res.json({ success: false, message: 'Invalid username or password' }); } if (user.status === 'pending') { return res.json({ success: false, message: 'Your account is pending approval.' }); } if (user.status === 'rejected') { return res.json({ success: false, message: 'Your account was not approved.' }); } // Remove password from response const { password: _, ...safeUser } = user; res.json({ success: true, message: 'Login successful', user: safeUser }); } catch (error) { console.error('Login error:', error); res.status(500).json({ success: false, message: 'Server error' }); } }); app.post('/api/auth/register', async (req, res) => { try { const { username, email, password } = req.body; const authData = await readCollection('auth'); if (authData.users.some(u => u.username === username)) { return res.json({ success: false, message: 'Username already taken' }); } if (authData.users.some(u => u.email === email)) { return res.json({ success: false, message: 'Email already registered' }); } const newUser = { id: uuidv4(), username, email, password: simpleHash(password), role: 'member', status: 'pending', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; authData.users.push(newUser); await writeCollection('auth', authData); res.json({ success: true, message: 'Registration successful! Please wait for admin approval.' }); } catch (error) { console.error('Registration error:', error); res.status(500).json({ success: false, message: 'Server error' }); } }); app.get('/api/auth/users', async (req, res) => { try { const authData = await readCollection('auth'); // Remove passwords from response const safeUsers = authData.users.map(({ password, ...user }) => user); res.json(safeUsers); } catch (error) { console.error('Get users error:', error); res.status(500).json({ error: 'Failed to get users' }); } }); app.put('/api/auth/users/:userId', async (req, res) => { try { const { userId } = req.params; const updates = req.body; const authData = await readCollection('auth'); const index = authData.users.findIndex(u => u.id === userId); if (index === -1) { return res.status(404).json({ error: 'User not found' }); } // Hash password if being updated if (updates.password) { updates.password = simpleHash(updates.password); } authData.users[index] = { ...authData.users[index], ...updates, updatedAt: new Date().toISOString(), }; await writeCollection('auth', authData); const { password, ...safeUser } = authData.users[index]; res.json(safeUser); } catch (error) { console.error('Update user error:', error); res.status(500).json({ error: 'Failed to update user' }); } }); app.delete('/api/auth/users/:userId', async (req, res) => { try { const { userId } = req.params; const authData = await readCollection('auth'); const user = authData.users.find(u => u.id === userId); if (!user) { return res.status(404).json({ error: 'User not found' }); } // Prevent deleting last admin const admins = authData.users.filter(u => u.role === 'admin' && u.status === 'approved'); if (user.role === 'admin' && admins.length === 1) { return res.status(400).json({ error: 'Cannot delete the last admin' }); } authData.users = authData.users.filter(u => u.id !== userId); await writeCollection('auth', authData); res.json({ success: true }); } catch (error) { console.error('Delete user error:', error); res.status(500).json({ error: 'Failed to delete user' }); } }); app.post('/api/auth/users', async (req, res) => { try { const { username, email, password, role } = req.body; const authData = await readCollection('auth'); if (authData.users.some(u => u.username === username)) { return res.status(400).json({ error: 'Username already taken' }); } if (authData.users.some(u => u.email === email)) { return res.status(400).json({ error: 'Email already registered' }); } const newUser = { id: uuidv4(), username, email, password: simpleHash(password), role: role || 'member', status: 'approved', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; authData.users.push(newUser); await writeCollection('auth', authData); const { password: _, ...safeUser } = newUser; res.json(safeUser); } catch (error) { console.error('Add user error:', error); res.status(500).json({ error: 'Failed to add user' }); } }); // ==================== BUDGET ROUTES ==================== app.get('/api/budget/transactions', async (req, res) => { try { const budgetData = await readCollection('budget'); res.json(budgetData?.transactions || []); } catch (error) { res.status(500).json({ error: 'Failed to get transactions' }); } }); app.post('/api/budget/transactions', async (req, res) => { try { const budgetData = await readCollection('budget'); const newTransaction = { ...req.body, id: uuidv4(), createdAt: new Date().toISOString(), }; budgetData.transactions.push(newTransaction); await writeCollection('budget', budgetData); res.json(newTransaction); } catch (error) { res.status(500).json({ error: 'Failed to add transaction' }); } }); app.put('/api/budget/transactions/:id', async (req, res) => { try { const { id } = req.params; const budgetData = await readCollection('budget'); const index = budgetData.transactions.findIndex(t => t.id === id); if (index === -1) { return res.status(404).json({ error: 'Transaction not found' }); } budgetData.transactions[index] = { ...budgetData.transactions[index], ...req.body }; await writeCollection('budget', budgetData); res.json(budgetData.transactions[index]); } catch (error) { res.status(500).json({ error: 'Failed to update transaction' }); } }); app.delete('/api/budget/transactions/:id', async (req, res) => { try { const { id } = req.params; const budgetData = await readCollection('budget'); budgetData.transactions = budgetData.transactions.filter(t => t.id !== id); await writeCollection('budget', budgetData); res.json({ success: true }); } catch (error) { res.status(500).json({ error: 'Failed to delete transaction' }); } }); // Founders app.get('/api/budget/founders', async (req, res) => { try { const budgetData = await readCollection('budget'); res.json(budgetData?.founders || []); } catch (error) { res.status(500).json({ error: 'Failed to get founders' }); } }); app.post('/api/budget/founders', async (req, res) => { try { const budgetData = await readCollection('budget'); const newFounder = { ...req.body, id: uuidv4() }; budgetData.founders.push(newFounder); await writeCollection('budget', budgetData); res.json(newFounder); } catch (error) { res.status(500).json({ error: 'Failed to add founder' }); } }); app.put('/api/budget/founders/:id', async (req, res) => { try { const { id } = req.params; const budgetData = await readCollection('budget'); const index = budgetData.founders.findIndex(f => f.id === id); if (index === -1) { return res.status(404).json({ error: 'Founder not found' }); } budgetData.founders[index] = { ...budgetData.founders[index], ...req.body }; await writeCollection('budget', budgetData); res.json(budgetData.founders[index]); } catch (error) { res.status(500).json({ error: 'Failed to update founder' }); } }); app.delete('/api/budget/founders/:id', async (req, res) => { try { const { id } = req.params; const budgetData = await readCollection('budget'); budgetData.founders = budgetData.founders.filter(f => f.id !== id); await writeCollection('budget', budgetData); res.json({ success: true }); } catch (error) { res.status(500).json({ error: 'Failed to delete founder' }); } }); // Settings app.get('/api/budget/settings', async (req, res) => { try { const budgetData = await readCollection('budget'); res.json(budgetData?.settings || { currency: 'USD', fiscalYearStart: 1, runwayWarningMonths: 6 }); } catch (error) { res.status(500).json({ error: 'Failed to get settings' }); } }); app.put('/api/budget/settings', async (req, res) => { try { const budgetData = await readCollection('budget'); budgetData.settings = { ...budgetData.settings, ...req.body }; await writeCollection('budget', budgetData); res.json(budgetData.settings); } catch (error) { res.status(500).json({ error: 'Failed to update settings' }); } }); // Targets app.get('/api/budget/targets', async (req, res) => { try { const budgetData = await readCollection('budget'); res.json(budgetData?.targets || []); } catch (error) { res.status(500).json({ error: 'Failed to get targets' }); } }); app.put('/api/budget/targets', async (req, res) => { try { const budgetData = await readCollection('budget'); budgetData.targets = req.body; await writeCollection('budget', budgetData); res.json(budgetData.targets); } catch (error) { res.status(500).json({ error: 'Failed to update targets' }); } }); // Recurring Transactions app.get('/api/budget/recurring', async (req, res) => { try { const budgetData = await readCollection('budget'); res.json(budgetData?.recurringTransactions || []); } catch (error) { res.status(500).json({ error: 'Failed to get recurring transactions' }); } }); app.post('/api/budget/recurring', async (req, res) => { try { const budgetData = await readCollection('budget'); const newRecurring = { ...req.body, id: uuidv4() }; budgetData.recurringTransactions = budgetData.recurringTransactions || []; budgetData.recurringTransactions.push(newRecurring); await writeCollection('budget', budgetData); res.json(newRecurring); } catch (error) { res.status(500).json({ error: 'Failed to add recurring transaction' }); } }); app.put('/api/budget/recurring/:id', async (req, res) => { try { const { id } = req.params; const budgetData = await readCollection('budget'); const index = budgetData.recurringTransactions.findIndex(r => r.id === id); if (index === -1) { return res.status(404).json({ error: 'Recurring transaction not found' }); } budgetData.recurringTransactions[index] = { ...budgetData.recurringTransactions[index], ...req.body }; await writeCollection('budget', budgetData); res.json(budgetData.recurringTransactions[index]); } catch (error) { res.status(500).json({ error: 'Failed to update recurring transaction' }); } }); app.delete('/api/budget/recurring/:id', async (req, res) => { try { const { id } = req.params; const budgetData = await readCollection('budget'); budgetData.recurringTransactions = budgetData.recurringTransactions.filter(r => r.id !== id); await writeCollection('budget', budgetData); res.json({ success: true }); } catch (error) { res.status(500).json({ error: 'Failed to delete recurring transaction' }); } }); // ==================== TRACEABILITY ROUTES ==================== // Run Python script to fetch and export traceability data app.post('/api/traceability/sync', async (req, res) => { console.log('[Traceability] Sync requested...'); const scriptPath = path.join(SCRIPTS_DIR, 'get_traceability.py'); const outputPath = path.join(PUBLIC_DIR, 'traceability_export.csv'); // Check if script exists try { await fs.access(scriptPath); } catch { console.log('[Traceability] Script not found at:', scriptPath); return res.status(404).json({ error: 'Python script not found', details: `Expected at: ${scriptPath}`, hint: 'Ensure get_traceability.py is mounted to /scripts in the container' }); } try { // Run the Python script const pythonProcess = spawn('python3', [scriptPath], { cwd: PUBLIC_DIR, env: { ...process.env } }); let stdout = ''; let stderr = ''; pythonProcess.stdout.on('data', (data) => { stdout += data.toString(); console.log('[Python]', data.toString().trim()); }); pythonProcess.stderr.on('data', (data) => { stderr += data.toString(); console.error('[Python Error]', data.toString().trim()); }); pythonProcess.on('close', async (code) => { if (code !== 0) { return res.status(500).json({ error: 'Script execution failed', code, stdout, stderr }); } // Read the generated CSV try { const csvData = await fs.readFile(outputPath, 'utf8'); console.log(`[Traceability] CSV generated: ${csvData.length} bytes`); res.json({ success: true, message: 'Traceability data synced successfully', csvLength: csvData.length, stdout }); } catch (readErr) { res.status(500).json({ error: 'Failed to read generated CSV', details: readErr.message }); } }); } catch (error) { console.error('[Traceability] Sync error:', error); res.status(500).json({ error: 'Failed to run sync', details: error.message }); } }); // Get traceability data (returns CSV content) app.get('/api/traceability', async (req, res) => { const csvPath = path.join(PUBLIC_DIR, 'traceability_export.csv'); try { const csvData = await fs.readFile(csvPath, 'utf8'); res.setHeader('Content-Type', 'text/csv'); res.send(csvData); } catch (error) { if (error.code === 'ENOENT') { return res.status(404).json({ error: 'Traceability data not found', hint: 'Run POST /api/traceability/sync first or upload CSV manually' }); } res.status(500).json({ error: 'Failed to read traceability data' }); } }); // Get traceability data as JSON (parsed) app.get('/api/traceability/json', async (req, res) => { const csvPath = path.join(PUBLIC_DIR, 'traceability_export.csv'); try { const csvData = await fs.readFile(csvPath, 'utf8'); const workPackages = parseCSVToWorkPackages(csvData); res.json({ workPackages, count: workPackages.length }); } catch (error) { if (error.code === 'ENOENT') { return res.status(404).json({ error: 'Traceability data not found' }); } res.status(500).json({ error: 'Failed to read traceability data' }); } }); // Upload CSV directly app.post('/api/traceability/upload', async (req, res) => { const { csvContent } = req.body; if (!csvContent) { return res.status(400).json({ error: 'csvContent is required' }); } const csvPath = path.join(PUBLIC_DIR, 'traceability_export.csv'); try { await fs.mkdir(PUBLIC_DIR, { recursive: true }); await fs.writeFile(csvPath, csvContent, 'utf8'); const workPackages = parseCSVToWorkPackages(csvContent); res.json({ success: true, message: 'CSV uploaded successfully', workPackages: workPackages.length }); } catch (error) { res.status(500).json({ error: 'Failed to save CSV', details: error.message }); } }); // Helper: Parse CSV to work packages function parseCSVToWorkPackages(csvText) { const cleanText = csvText.replace(/^\uFEFF/, ''); const workPackages = []; const lines = cleanText.split('\n'); let currentRow = []; let inQuotedField = false; let currentField = ''; const content = lines.slice(1).join('\n'); for (let i = 0; i < content.length; i++) { const char = content[i]; const nextChar = content[i + 1]; if (inQuotedField) { if (char === '"') { if (nextChar === '"') { currentField += '"'; i++; } else { inQuotedField = false; } } else { currentField += char; } } else { if (char === '"' && currentField === '') { inQuotedField = true; } else if (char === ',') { currentRow.push(currentField); currentField = ''; } else if (char === '\n') { currentRow.push(currentField); currentField = ''; if (currentRow.length >= 4 && /^\d+$/.test(currentRow[0]?.trim())) { const [id, type, status, title, description = '', parentId = '', relations = ''] = currentRow; workPackages.push({ id: parseInt(id, 10), type: type?.toLowerCase().replace(/\s+/g, ' ').trim(), status: status || '', title: title || '', description: description || '', parentId: parentId?.trim() || '', relations: relations?.trim() || '' }); } currentRow = []; } else { currentField += char; } } } // Handle last row if (currentField || currentRow.length > 0) { currentRow.push(currentField); if (currentRow.length >= 4 && /^\d+$/.test(currentRow[0]?.trim())) { const [id, type, status, title, description = '', parentId = '', relations = ''] = currentRow; workPackages.push({ id: parseInt(id, 10), type: type?.toLowerCase().replace(/\s+/g, ' ').trim(), status: status || '', title: title || '', description: description || '', parentId: parentId?.trim() || '', relations: relations?.trim() || '' }); } } return workPackages; } // Health check app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); // Start server initializeDefaults().then(() => { app.listen(PORT, () => { console.log(`Data service running on port ${PORT}`); console.log(`Data directory: ${DATA_DIR}`); console.log(`Scripts directory: ${SCRIPTS_DIR}`); console.log(`Public directory: ${PUBLIC_DIR}`); }); }).catch(error => { console.error('Failed to initialize:', error); process.exit(1); });