new features

This commit is contained in:
2026-02-03 20:48:09 +01:00
parent 39655c2913
commit a4739df7de
52 changed files with 11740 additions and 129 deletions

27
data-service/Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
FROM node:20-alpine
WORKDIR /app
# Install Python and pip for running traceability script
RUN apk add --no-cache python3 py3-pip
# Install Python dependencies for the traceability script
RUN pip3 install --break-system-packages requests pandas
# Copy package files
COPY package*.json ./
# Install Node.js dependencies
RUN npm install --production
# Copy source code
COPY . .
# Create directories
RUN mkdir -p /data /scripts /srv/data
# Expose port
EXPOSE 3002
# Start the service
CMD ["npm", "start"]

744
data-service/index.js Normal file
View File

@@ -0,0 +1,744 @@
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);
});

14
data-service/package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "data-service",
"version": "1.0.0",
"description": "Persistent data storage API for Traceability Matrix",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"uuid": "^9.0.0"
}
}