Initial commit — Mek-Tech AI Consultancy Framework v2.0
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const { getDb } = require('../db');
|
||||
|
||||
const AI_CONFIG_PATH = path.join(__dirname, '..', 'data', 'ai_config.json');
|
||||
|
||||
const PROVIDER_PRESETS = {
|
||||
openrouter: {
|
||||
label: 'OpenRouter',
|
||||
base_url: 'https://openrouter.ai/api/v1',
|
||||
models: ['qwen/qwen3-coder:free', 'meta-llama/llama-3.3-70b-instruct:free', 'google/gemma-3-27b-it:free', 'anthropic/claude-3.5-sonnet', 'openai/gpt-4o-mini']
|
||||
},
|
||||
openai: {
|
||||
label: 'OpenAI',
|
||||
base_url: 'https://api.openai.com/v1',
|
||||
models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'o1-mini']
|
||||
},
|
||||
anthropic: {
|
||||
label: 'Anthropic',
|
||||
base_url: 'https://api.anthropic.com/v1',
|
||||
models: ['claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', 'claude-3-opus-20240229']
|
||||
},
|
||||
deepseek: {
|
||||
label: 'DeepSeek',
|
||||
base_url: 'https://api.deepseek.com/v1',
|
||||
models: ['deepseek-chat', 'deepseek-reasoner']
|
||||
},
|
||||
groq: {
|
||||
label: 'Groq',
|
||||
base_url: 'https://api.groq.com/openai/v1',
|
||||
models: ['llama-3.3-70b-versatile', 'mixtral-8x7b-32768']
|
||||
},
|
||||
ollama: {
|
||||
label: 'Ollama (local)',
|
||||
base_url: 'http://localhost:11434/v1',
|
||||
models: ['llama3.2', 'mistral', 'codellama', 'qwen2.5']
|
||||
},
|
||||
azure: {
|
||||
label: 'Azure OpenAI',
|
||||
base_url: '',
|
||||
models: ['gpt-4o', 'gpt-4o-mini']
|
||||
},
|
||||
custom: {
|
||||
label: 'Custom (OpenAI-compatible)',
|
||||
base_url: '',
|
||||
models: []
|
||||
}
|
||||
};
|
||||
|
||||
function initAiSchema() {
|
||||
const db = getDb();
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ai_providers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
provider_type TEXT NOT NULL DEFAULT 'openrouter',
|
||||
api_key TEXT DEFAULT '',
|
||||
base_url TEXT DEFAULT '',
|
||||
default_model TEXT DEFAULT '',
|
||||
is_active INTEGER DEFAULT 1,
|
||||
is_default INTEGER DEFAULT 0,
|
||||
extra_headers TEXT DEFAULT '{}',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS integrations (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT DEFAULT '',
|
||||
api_key TEXT DEFAULT '',
|
||||
webhook_url TEXT DEFAULT '',
|
||||
config TEXT DEFAULT '{}',
|
||||
status TEXT DEFAULT 'inactive',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
|
||||
const count = db.prepare('SELECT COUNT(*) as c FROM ai_providers').get().c;
|
||||
if (count === 0 && process.env.DEEPSEEK_API_KEY) {
|
||||
db.prepare(`INSERT INTO ai_providers (name, provider_type, api_key, base_url, default_model, is_default)
|
||||
VALUES (?, ?, ?, ?, ?, 1)`).run(
|
||||
'OpenRouter Default', 'openrouter', process.env.DEEPSEEK_API_KEY,
|
||||
'https://openrouter.ai/api/v1', 'qwen/qwen3-coder:free'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let _aiSchemaReady = false;
|
||||
function ensureAiSchema() {
|
||||
if (_aiSchemaReady) return;
|
||||
initAiSchema();
|
||||
_aiSchemaReady = true;
|
||||
}
|
||||
|
||||
function getProviders() {
|
||||
ensureAiSchema();
|
||||
return getDb().prepare('SELECT id, name, provider_type, base_url, default_model, is_active, is_default, created_at, updated_at FROM ai_providers ORDER BY is_default DESC, name').all();
|
||||
}
|
||||
|
||||
function getProvider(id) {
|
||||
ensureAiSchema();
|
||||
return getDb().prepare('SELECT * FROM ai_providers WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
function getDefaultProvider() {
|
||||
ensureAiSchema();
|
||||
let p = getDb().prepare('SELECT * FROM ai_providers WHERE is_default = 1 AND is_active = 1 LIMIT 1').get();
|
||||
if (!p) p = getDb().prepare('SELECT * FROM ai_providers WHERE is_active = 1 ORDER BY id LIMIT 1').get();
|
||||
return p;
|
||||
}
|
||||
|
||||
function saveProvider(data) {
|
||||
ensureAiSchema();
|
||||
const db = getDb();
|
||||
if (data.is_default) {
|
||||
db.prepare('UPDATE ai_providers SET is_default = 0').run();
|
||||
}
|
||||
if (data.id) {
|
||||
db.prepare(`UPDATE ai_providers SET name=?, provider_type=?, api_key=?, base_url=?, default_model=?,
|
||||
is_active=?, is_default=?, updated_at=datetime('now') WHERE id=?`).run(
|
||||
data.name, data.provider_type, data.api_key || '', data.base_url || '',
|
||||
data.default_model || '', data.is_active ? 1 : 0, data.is_default ? 1 : 0, data.id
|
||||
);
|
||||
syncAiConfigFile();
|
||||
return data.id;
|
||||
}
|
||||
const r = db.prepare(`INSERT INTO ai_providers (name, provider_type, api_key, base_url, default_model, is_active, is_default)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(
|
||||
data.name, data.provider_type, data.api_key || '', data.base_url || '',
|
||||
data.default_model || '', data.is_active !== false ? 1 : 0, data.is_default ? 1 : 0
|
||||
);
|
||||
syncAiConfigFile();
|
||||
return r.lastInsertRowid;
|
||||
}
|
||||
|
||||
function deleteProvider(id) {
|
||||
ensureAiSchema();
|
||||
getDb().prepare('DELETE FROM ai_providers WHERE id = ?').run(id);
|
||||
syncAiConfigFile();
|
||||
}
|
||||
|
||||
function syncAiConfigFile() {
|
||||
ensureAiSchema();
|
||||
const providers = getDb().prepare('SELECT * FROM ai_providers WHERE is_active = 1').all();
|
||||
const defaultProvider = providers.find(p => p.is_default) || providers[0] || null;
|
||||
const config = {
|
||||
default_provider_id: defaultProvider?.id || null,
|
||||
providers: providers.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
provider_type: p.provider_type,
|
||||
api_key: p.api_key,
|
||||
base_url: p.base_url || PROVIDER_PRESETS[p.provider_type]?.base_url || '',
|
||||
default_model: p.default_model,
|
||||
is_default: !!p.is_default
|
||||
}))
|
||||
};
|
||||
const dir = path.dirname(AI_CONFIG_PATH);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(AI_CONFIG_PATH, JSON.stringify(config, null, 2));
|
||||
|
||||
const leadgenConfig = {
|
||||
api_key: defaultProvider?.api_key || process.env.DEEPSEEK_API_KEY || '',
|
||||
base_url: defaultProvider?.base_url || 'https://openrouter.ai/api/v1',
|
||||
model: defaultProvider?.default_model || 'qwen/qwen3-coder:free',
|
||||
provider_type: defaultProvider?.provider_type || 'openrouter'
|
||||
};
|
||||
fs.writeFileSync(path.join(dir, 'leadgen_config.json'), JSON.stringify(leadgenConfig, null, 2));
|
||||
}
|
||||
|
||||
function maskKey(key) {
|
||||
if (!key || key.length < 8) return key ? '••••••••' : '';
|
||||
return key.slice(0, 6) + '••••' + key.slice(-4);
|
||||
}
|
||||
|
||||
function chatCompletion(messages, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const provider = options.providerId
|
||||
? getProvider(options.providerId)
|
||||
: getDefaultProvider();
|
||||
if (!provider || !provider.api_key) {
|
||||
return reject(new Error('Geen actieve AI provider geconfigureerd. Ga naar /ai om een provider toe te voegen.'));
|
||||
}
|
||||
|
||||
const preset = PROVIDER_PRESETS[provider.provider_type] || PROVIDER_PRESETS.custom;
|
||||
const baseUrl = provider.base_url || preset.base_url;
|
||||
const model = options.model || provider.default_model || preset.models[0];
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL('/chat/completions', baseUrl.endsWith('/') ? baseUrl : baseUrl + '/');
|
||||
} catch (e) {
|
||||
return reject(new Error('Ongeldige base URL: ' + baseUrl));
|
||||
}
|
||||
|
||||
const body = JSON.stringify({
|
||||
model,
|
||||
messages,
|
||||
temperature: options.temperature ?? 0.7,
|
||||
max_tokens: options.max_tokens ?? 4096
|
||||
});
|
||||
|
||||
const isHttps = url.protocol === 'https:';
|
||||
const lib = isHttps ? https : http;
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body)
|
||||
};
|
||||
|
||||
if (provider.provider_type === 'anthropic') {
|
||||
headers['x-api-key'] = provider.api_key;
|
||||
headers['anthropic-version'] = '2023-06-01';
|
||||
} else {
|
||||
headers['Authorization'] = `Bearer ${provider.api_key}`;
|
||||
}
|
||||
if (provider.provider_type === 'openrouter') {
|
||||
headers['HTTP-Referer'] = process.env.BASE_URL || 'http://localhost:3000';
|
||||
headers['X-Title'] = 'Mek-Tech Platform';
|
||||
}
|
||||
|
||||
const reqOpts = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || (isHttps ? 443 : 80),
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers,
|
||||
timeout: 120000
|
||||
};
|
||||
|
||||
const req = lib.request(reqOpts, (res) => {
|
||||
let data = '';
|
||||
res.on('data', c => { data += c; });
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
if (res.statusCode >= 400) {
|
||||
reject(new Error(parsed.error?.message || parsed.message || data.slice(0, 200)));
|
||||
} else {
|
||||
resolve(parsed);
|
||||
}
|
||||
} catch (e) {
|
||||
reject(new Error('AI response parse error: ' + data.slice(0, 200)));
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => { req.destroy(); reject(new Error('AI request timeout')); });
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PROVIDER_PRESETS,
|
||||
initAiSchema: ensureAiSchema,
|
||||
getProviders,
|
||||
getProvider,
|
||||
getDefaultProvider,
|
||||
saveProvider,
|
||||
deleteProvider,
|
||||
syncAiConfigFile,
|
||||
maskKey,
|
||||
chatCompletion
|
||||
};
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execSync } = require('child_process');
|
||||
const { getDb } = require('../db');
|
||||
|
||||
const APP_VERSION = '2.0.0';
|
||||
const MAX_STORED_BACKUPS = 20;
|
||||
|
||||
function getPaths() {
|
||||
const root = path.join(__dirname, '..');
|
||||
const dataDir = process.env.DB_PATH
|
||||
? path.dirname(path.resolve(process.env.DB_PATH))
|
||||
: path.join(root, 'data');
|
||||
return {
|
||||
root,
|
||||
dataDir,
|
||||
dbPath: process.env.DB_PATH || path.join(dataDir, 'consulting.db'),
|
||||
sessionsPath: path.join(dataDir, 'sessions.db'),
|
||||
uploadsDir: path.join(dataDir, 'uploads'),
|
||||
backupsDir: path.join(dataDir, 'backups')
|
||||
};
|
||||
}
|
||||
|
||||
function ensureBackupsDir() {
|
||||
const { backupsDir } = getPaths();
|
||||
if (!fs.existsSync(backupsDir)) fs.mkdirSync(backupsDir, { recursive: true });
|
||||
return backupsDir;
|
||||
}
|
||||
|
||||
function checkpointDatabases() {
|
||||
try {
|
||||
const db = getDb();
|
||||
db.pragma('wal_checkpoint(TRUNCATE)');
|
||||
} catch (e) { /* ignore */ }
|
||||
const { sessionsPath } = getPaths();
|
||||
if (fs.existsSync(sessionsPath)) {
|
||||
try {
|
||||
const Database = require('better-sqlite3');
|
||||
const sdb = new Database(sessionsPath);
|
||||
sdb.pragma('wal_checkpoint(TRUNCATE)');
|
||||
sdb.close();
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
function collectStats() {
|
||||
const db = getDb();
|
||||
const tables = ['clients', 'engagements', 'racks', 'rack_devices', 'data_diagrams', 'projects', 'invoices', 'assessments', 'users'];
|
||||
const counts = {};
|
||||
for (const t of tables) {
|
||||
try {
|
||||
counts[t] = db.prepare(`SELECT COUNT(*) as c FROM ${t}`).get().c;
|
||||
} catch (e) {
|
||||
counts[t] = 0;
|
||||
}
|
||||
}
|
||||
const { dbPath, sessionsPath, uploadsDir, dataDir } = getPaths();
|
||||
let uploadsBytes = 0;
|
||||
let uploadFiles = 0;
|
||||
if (fs.existsSync(uploadsDir)) {
|
||||
const walk = (dir) => {
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
const p = path.join(dir, name);
|
||||
const st = fs.statSync(p);
|
||||
if (st.isDirectory()) walk(p);
|
||||
else { uploadsBytes += st.size; uploadFiles++; }
|
||||
}
|
||||
};
|
||||
walk(uploadsDir);
|
||||
}
|
||||
return {
|
||||
counts,
|
||||
dbSizeBytes: fs.existsSync(dbPath) ? fs.statSync(dbPath).size : 0,
|
||||
sessionsSizeBytes: fs.existsSync(sessionsPath) ? fs.statSync(sessionsPath).size : 0,
|
||||
uploadsBytes,
|
||||
uploadFiles,
|
||||
dataDir
|
||||
};
|
||||
}
|
||||
|
||||
function formatBytes(n) {
|
||||
if (n < 1024) return n + ' B';
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
||||
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB';
|
||||
return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB';
|
||||
}
|
||||
|
||||
function timestampLabel() {
|
||||
const d = new Date();
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
function safeBackupName(name) {
|
||||
return /^mek-tech-backup-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{6}\.tar\.gz$/.test(name);
|
||||
}
|
||||
|
||||
function pruneOldBackups() {
|
||||
const list = listBackups();
|
||||
if (list.length <= MAX_STORED_BACKUPS) return;
|
||||
const toRemove = list.slice(MAX_STORED_BACKUPS);
|
||||
for (const b of toRemove) {
|
||||
try { fs.unlinkSync(b.path); } catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
function createBackup() {
|
||||
ensureBackupsDir();
|
||||
checkpointDatabases();
|
||||
|
||||
const paths = getPaths();
|
||||
const stats = collectStats();
|
||||
const label = timestampLabel();
|
||||
const bundleName = `mek-tech-backup-${label}`;
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mek-backup-'));
|
||||
const bundleDir = path.join(tmpDir, bundleName);
|
||||
|
||||
try {
|
||||
fs.mkdirSync(bundleDir, { recursive: true });
|
||||
|
||||
const manifest = {
|
||||
app: 'mek-tech-consulting-framework',
|
||||
version: APP_VERSION,
|
||||
created_at: new Date().toISOString(),
|
||||
stats: stats.counts,
|
||||
includes: []
|
||||
};
|
||||
|
||||
if (fs.existsSync(paths.dbPath)) {
|
||||
fs.copyFileSync(paths.dbPath, path.join(bundleDir, 'consulting.db'));
|
||||
manifest.includes.push('consulting.db');
|
||||
}
|
||||
if (fs.existsSync(paths.sessionsPath)) {
|
||||
fs.copyFileSync(paths.sessionsPath, path.join(bundleDir, 'sessions.db'));
|
||||
manifest.includes.push('sessions.db');
|
||||
}
|
||||
if (fs.existsSync(paths.uploadsDir)) {
|
||||
copyDirSync(paths.uploadsDir, path.join(bundleDir, 'uploads'));
|
||||
manifest.includes.push('uploads/');
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(bundleDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
||||
|
||||
const archiveName = `${bundleName}.tar.gz`;
|
||||
const archivePath = path.join(paths.backupsDir, archiveName);
|
||||
execSync(`tar -czf "${archivePath}" -C "${tmpDir}" "${bundleName}"`, { stdio: 'pipe' });
|
||||
|
||||
const size = fs.statSync(archivePath).size;
|
||||
pruneOldBackups();
|
||||
|
||||
return {
|
||||
filename: archiveName,
|
||||
path: archivePath,
|
||||
size,
|
||||
sizeLabel: formatBytes(size),
|
||||
created_at: manifest.created_at,
|
||||
stats: stats.counts
|
||||
};
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function copyDirSync(src, dest) {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
for (const name of fs.readdirSync(src)) {
|
||||
const s = path.join(src, name);
|
||||
const d = path.join(dest, name);
|
||||
if (fs.statSync(s).isDirectory()) copyDirSync(s, d);
|
||||
else fs.copyFileSync(s, d);
|
||||
}
|
||||
}
|
||||
|
||||
function listBackups() {
|
||||
ensureBackupsDir();
|
||||
const { backupsDir } = getPaths();
|
||||
if (!fs.existsSync(backupsDir)) return [];
|
||||
return fs.readdirSync(backupsDir)
|
||||
.filter(f => f.endsWith('.tar.gz') && safeBackupName(f))
|
||||
.map(f => {
|
||||
const p = path.join(backupsDir, f);
|
||||
const st = fs.statSync(p);
|
||||
return {
|
||||
filename: f,
|
||||
path: p,
|
||||
size: st.size,
|
||||
sizeLabel: formatBytes(st.size),
|
||||
created_at: st.mtime.toISOString()
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
||||
}
|
||||
|
||||
function getBackupPath(filename) {
|
||||
if (!safeBackupName(filename)) return null;
|
||||
const p = path.join(getPaths().backupsDir, filename);
|
||||
return fs.existsSync(p) ? p : null;
|
||||
}
|
||||
|
||||
function deleteBackup(filename) {
|
||||
const p = getBackupPath(filename);
|
||||
if (!p) return false;
|
||||
fs.unlinkSync(p);
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateArchive(archivePath) {
|
||||
if (!fs.existsSync(archivePath)) throw new Error('Backupbestand niet gevonden');
|
||||
const listing = execSync(`tar -tzf "${archivePath}"`, { encoding: 'utf8' });
|
||||
if (!listing.includes('manifest.json')) throw new Error('Ongeldig backup: manifest.json ontbreekt');
|
||||
if (!listing.includes('consulting.db')) throw new Error('Ongeldig backup: consulting.db ontbreekt');
|
||||
return listing;
|
||||
}
|
||||
|
||||
function restoreBackup(archivePath) {
|
||||
validateArchive(archivePath);
|
||||
|
||||
const auto = createBackup();
|
||||
const paths = getPaths();
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mek-restore-'));
|
||||
|
||||
try {
|
||||
execSync(`tar -xzf "${archivePath}" -C "${tmpDir}"`, { stdio: 'pipe' });
|
||||
const entries = fs.readdirSync(tmpDir);
|
||||
const bundleDir = entries.length === 1 ? path.join(tmpDir, entries[0]) : tmpDir;
|
||||
|
||||
const manifestPath = path.join(bundleDir, 'manifest.json');
|
||||
if (!fs.existsSync(manifestPath)) throw new Error('manifest.json niet gevonden in backup');
|
||||
|
||||
checkpointDatabases();
|
||||
|
||||
const dbDest = paths.dbPath;
|
||||
const dbSrc = path.join(bundleDir, 'consulting.db');
|
||||
if (fs.existsSync(dbSrc)) {
|
||||
for (const ext of ['-wal', '-shm']) {
|
||||
const sidecar = dbDest + ext;
|
||||
if (fs.existsSync(sidecar)) fs.unlinkSync(sidecar);
|
||||
}
|
||||
fs.copyFileSync(dbSrc, dbDest);
|
||||
}
|
||||
|
||||
const sessSrc = path.join(bundleDir, 'sessions.db');
|
||||
if (fs.existsSync(sessSrc)) {
|
||||
for (const ext of ['-wal', '-shm']) {
|
||||
const sidecar = paths.sessionsPath + ext;
|
||||
if (fs.existsSync(sidecar)) fs.unlinkSync(sidecar);
|
||||
}
|
||||
fs.copyFileSync(sessSrc, paths.sessionsPath);
|
||||
}
|
||||
|
||||
const uploadsSrc = path.join(bundleDir, 'uploads');
|
||||
if (fs.existsSync(uploadsSrc)) {
|
||||
if (fs.existsSync(paths.uploadsDir)) fs.rmSync(paths.uploadsDir, { recursive: true, force: true });
|
||||
copyDirSync(uploadsSrc, paths.uploadsDir);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
preRestoreBackup: auto.filename,
|
||||
restored_at: new Date().toISOString()
|
||||
};
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getPaths,
|
||||
collectStats,
|
||||
formatBytes,
|
||||
createBackup,
|
||||
listBackups,
|
||||
getBackupPath,
|
||||
deleteBackup,
|
||||
restoreBackup,
|
||||
safeBackupName,
|
||||
APP_VERSION
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
/** Mek-Tech Consultancy Hub — toolkits, playbooks & delivery frameworks */
|
||||
|
||||
const TOOLKIT = [
|
||||
{
|
||||
id: 'discovery',
|
||||
title: 'Discovery & Assessment',
|
||||
desc: 'Intake, maturity scans & stakeholder alignment',
|
||||
color: '#00d4ff',
|
||||
tools: [
|
||||
{ icon: 'brain', label: 'Enterprise Framework', href: '/consulting', desc: '5-staps solution architect assessment + roadmap' },
|
||||
{ icon: 'clipboard-list', label: 'Quick Assessment', href: '/assess', desc: 'Snelle infra/data/AI intake per cliënt' },
|
||||
{ icon: 'search', label: 'LeadGen AI Scan', href: '/?tab=leadgen', desc: 'Website tech-stack & pain point analyse' },
|
||||
{ icon: 'building-2', label: 'Market Intel', href: '/market', desc: 'Prospects, datacenters & sector mapping' },
|
||||
{ icon: 'users', label: 'Cliënt 360°', href: '/clients', desc: 'Volledig klantbeeld & engagement history' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'architecture',
|
||||
title: 'Architectuur & Design',
|
||||
desc: 'Data, systeem & netwerk ontwerp',
|
||||
color: '#a855f7',
|
||||
tools: [
|
||||
{ icon: 'layers', label: 'Architectuur Designer', href: '/architecture', desc: 'Visueel data-architectuur canvas' },
|
||||
{ icon: 'pen-tool', label: 'Pro Designer', href: '/architecture/designer', desc: 'Geavanceerde Python design editor' },
|
||||
{ icon: 'server', label: 'Rack & DC Design', href: '/racks', desc: 'Server racks, PDU, koeling & patching' },
|
||||
{ icon: 'wifi', label: 'Netwerk Topologie', href: '/networking', desc: 'Switches, firewalls & verbindingen' },
|
||||
{ icon: 'calculator', label: 'Sizing Calculator', href: '/sizing', desc: 'Capaciteit & hardware sizing' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'data-ai',
|
||||
title: 'Data, AI & Kwaliteit',
|
||||
desc: 'Profiling, governance & AI readiness',
|
||||
color: '#00ff88',
|
||||
tools: [
|
||||
{ icon: 'shield-check', label: 'Data Quality', href: '/quality', desc: 'Profiling, validatie & kwaliteitsrapporten' },
|
||||
{ icon: 'bot', label: 'AI Providers', href: '/ai', desc: 'Multi-provider LLM configuratie' },
|
||||
{ icon: 'database', label: 'Data Modellen', href: '/quality', desc: 'SQL schema & dbt model editor' },
|
||||
{ icon: 'file-bar-chart', label: 'Rapporten', href: '/reports', desc: 'Consultancy deliverables exporteren' },
|
||||
{ icon: 'plug', label: 'Integraties', href: '/integrations', desc: 'Externe systemen koppelen' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'delivery',
|
||||
title: 'Delivery & Operations',
|
||||
desc: 'Projectuitvoering, taken & tijd',
|
||||
color: '#ffb347',
|
||||
tools: [
|
||||
{ icon: 'briefcase', label: 'Engagements', href: '/engagements', desc: 'Projecten per fase beheren' },
|
||||
{ icon: 'check-square', label: 'Taken & Deadlines', href: '/?tab=tasks', desc: 'Werkpakketten & prioriteiten' },
|
||||
{ icon: 'clock', label: 'Urenregistratie', href: '/time', desc: 'Billable hours & project tracking' },
|
||||
{ icon: 'folder-kanban', label: 'Projecten', href: '/projects', desc: 'Portfolio & milestone overzicht' },
|
||||
{ icon: 'calendar', label: 'Planning', href: '/calendar', desc: 'Afspraken, reviews & deadlines' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'commercial',
|
||||
title: 'Commercial & Relatie',
|
||||
desc: 'Offertes, facturatie & communicatie',
|
||||
color: '#ff6b8a',
|
||||
tools: [
|
||||
{ icon: 'receipt', label: 'Facturatie', href: '/invoices', desc: 'Facturen & betalingsstatus' },
|
||||
{ icon: 'bar-chart-2', label: 'Finance Dashboard', href: '/finance', desc: 'Omzet, marge & outstanding' },
|
||||
{ icon: 'mail', label: 'Email Hub', href: '/email', desc: 'Klantcommunicatie & follow-ups' },
|
||||
{ icon: 'bell', label: 'Notificaties', href: '/notifications', desc: 'Alerts & follow-up reminders' },
|
||||
{ icon: 'file-text', label: 'Audit Trail', href: '/audit', desc: 'Alle consultancy acties gelogd' },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const PLAYBOOKS = [
|
||||
{ phase: '1', title: 'Intake & Discovery', steps: ['Kick-off meeting', 'Stakeholder mapping', 'Enterprise assessment', 'Pain points documenteren'], duration: '1-2 weken', color: '#00d4ff' },
|
||||
{ phase: '2', title: 'Analyse & Design', steps: ['As-is architectuur', 'Gap analyse', 'To-be design', 'Sizing & kostenraming'], duration: '2-4 weken', color: '#a855f7' },
|
||||
{ phase: '3', title: 'Validatie & Proposal', steps: ['Design review', 'POC / pilot scope', 'Offerte & SOW', 'Go/no-go beslissing'], duration: '1-2 weken', color: '#00ff88' },
|
||||
{ phase: '4', title: 'Implementatie', steps: ['Projectplan & sprints', 'Infra provisioning', 'Data pipelines', 'Test & acceptatie'], duration: '4-12 weken', color: '#ffb347' },
|
||||
{ phase: '5', title: 'Run & Optimize', steps: ['Monitoring opzetten', 'Knowledge transfer', 'Retainer / SLA', 'Continuous improvement'], duration: 'Doorlopend', color: '#ff6b8a' },
|
||||
];
|
||||
|
||||
const MATURITY_LAYERS = [
|
||||
{ name: 'Data Foundation', items: ['Bronnen in kaart', 'Data governance', 'Quality profiling', 'Warehouse / Lakehouse'] },
|
||||
{ name: 'Platform & Infra', items: ['Cloud / hybrid strategie', 'Netwerk & security', 'Compute sizing', 'Observability'] },
|
||||
{ name: 'Analytics & AI', items: ['BI maturity', 'ML use cases', 'LLM / GenAI readiness', 'MLOps pipeline'] },
|
||||
{ name: 'Organisatie', items: ['Team capabilities', 'Change management', 'Training plan', 'Operating model'] },
|
||||
];
|
||||
|
||||
function getConsultingToolkit() {
|
||||
return { toolkit: TOOLKIT, playbooks: PLAYBOOKS, maturityLayers: MATURITY_LAYERS };
|
||||
}
|
||||
|
||||
module.exports = { getConsultingToolkit, TOOLKIT, PLAYBOOKS, MATURITY_LAYERS };
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
const { getDb } = require('../db');
|
||||
|
||||
function crmContextMiddleware(req, res, next) {
|
||||
const db = getDb();
|
||||
const qClientId = req.query.client_id || req.query.clientId;
|
||||
if (qClientId && req.session) {
|
||||
req.session.activeClientId = parseInt(qClientId, 10) || null;
|
||||
}
|
||||
|
||||
const activeClientId = (req.session && req.session.activeClientId) || null;
|
||||
let activeClient = null;
|
||||
if (activeClientId) {
|
||||
activeClient = db.prepare('SELECT id, name, status, industry FROM clients WHERE id = ?').get(activeClientId);
|
||||
if (!activeClient) req.session.activeClientId = null;
|
||||
}
|
||||
|
||||
const allClients = db.prepare('SELECT id, name, status FROM clients ORDER BY name').all();
|
||||
|
||||
res.locals.activeClient = activeClient;
|
||||
res.locals.activeClientId = activeClient ? activeClient.id : null;
|
||||
res.locals.allClients = allClients;
|
||||
res.locals.clientQuery = (path) => {
|
||||
if (!activeClient) return path;
|
||||
const sep = path.includes('?') ? '&' : '?';
|
||||
return `${path}${sep}client_id=${activeClient.id}`;
|
||||
};
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
function getClientHubLinks(clientId) {
|
||||
const id = clientId;
|
||||
const q = `?client_id=${id}`;
|
||||
return [
|
||||
{ href: `/clients/${id}`, icon: 'eye', label: 'Overzicht' },
|
||||
{ href: `/engagements/new${q}`, icon: 'briefcase', label: 'Engagement' },
|
||||
{ href: `/consulting${q}`, icon: 'clipboard-list', label: 'Framework' },
|
||||
{ href: `/assess/${id}`, icon: 'file-search', label: 'Assessment' },
|
||||
{ href: `/architecture/new/${id}`, icon: 'network', label: 'Design' },
|
||||
{ href: `/architecture/designer${q}`, icon: 'pen-tool', label: 'Pro Designer' },
|
||||
{ href: `/quality${q}`, icon: 'shield-check', label: 'Data Quality' },
|
||||
{ href: `/racks${q}`, icon: 'server', label: 'Racks' },
|
||||
{ href: `/networking${q}`, icon: 'share-2', label: 'Netwerk' },
|
||||
{ href: `/time${q}`, icon: 'clock', label: 'Uren' },
|
||||
{ href: `/invoices/new${q}`, icon: 'receipt', label: 'Factuur' },
|
||||
{ href: `/email/compose${q}`, icon: 'mail', label: 'Email' }
|
||||
];
|
||||
}
|
||||
|
||||
module.exports = { crmContextMiddleware, getClientHubLinks };
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
const NAV_SECTIONS = [
|
||||
{
|
||||
label: 'CRM',
|
||||
items: [
|
||||
{ href: '/', icon: 'layout-dashboard', label: 'Dashboard', match: /^\/(\?.*)?$/ },
|
||||
{ href: '/clients', icon: 'users', label: 'Cliënten', match: /^\/clients/ },
|
||||
{ href: '/engagements', icon: 'briefcase', label: 'Engagements', match: /^\/engagements/ },
|
||||
{ href: '/tasks', icon: 'check-circle', label: 'Taken', match: /^\/tasks/ },
|
||||
{ href: '/calendar', icon: 'calendar', label: 'Agenda', match: /^\/calendar/ },
|
||||
{ href: '/email', icon: 'mail', label: 'Email', match: /^\/email/ }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Delivery',
|
||||
items: [
|
||||
{ href: '/consulting', icon: 'clipboard-list', label: 'Framework', match: /^\/consulting/ },
|
||||
{ href: '/assess', icon: 'file-search', label: 'Assessments', match: /^\/assess/ },
|
||||
{ href: '/projects', icon: 'folder-kanban', label: 'Projecten', match: /^\/projects/ },
|
||||
{ href: '/time', icon: 'clock', label: 'Uren', match: /^\/time/ },
|
||||
{ href: '/invoices', icon: 'receipt', label: 'Facturen', match: /^\/invoices/ },
|
||||
{ href: '/reports', icon: 'bar-chart-3', label: 'Rapporten', match: /^\/reports/ }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Data Engineering',
|
||||
items: [
|
||||
{ href: '/quality', icon: 'shield-check', label: 'Data Quality', match: /^\/quality/ },
|
||||
{ href: '/architecture', icon: 'network', label: 'Architecture', match: /^\/architecture/ },
|
||||
{ href: '/architecture/designer', icon: 'pen-tool', label: 'Pro Designer', match: /^\/architecture\/designer/ },
|
||||
{ href: '/market', icon: 'radar', label: 'Market Intel', match: /^\/market/ },
|
||||
{ href: '/sizing', icon: 'cpu', label: 'Sizing', match: /^\/sizing/ }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Infrastructure',
|
||||
items: [
|
||||
{ href: '/racks', icon: 'server', label: 'Racks', match: /^\/racks/ },
|
||||
{ href: '/networking', icon: 'share-2', label: 'Netwerk', match: /^\/networking/ },
|
||||
{ href: '/infrastructure', icon: 'building-2', label: 'Infra', match: /^\/infrastructure/ }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Platform',
|
||||
items: [
|
||||
{ href: '/finance', icon: 'wallet', label: 'Finance', match: /^\/finance/ },
|
||||
{ href: '/ai', icon: 'sparkles', label: 'AI Providers', match: /^\/ai/ },
|
||||
{ href: '/integrations', icon: 'plug', label: 'Integraties', match: /^\/integrations/ },
|
||||
{ href: '/settings', icon: 'settings', label: 'Instellingen', match: /^\/settings/ }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
function getNavSections(activePath) {
|
||||
return NAV_SECTIONS.map(section => ({
|
||||
...section,
|
||||
items: section.items.map(item => ({
|
||||
...item,
|
||||
active: item.match.test(activePath || '/')
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = { NAV_SECTIONS, getNavSections };
|
||||
@@ -0,0 +1,124 @@
|
||||
const DEVICE_TO_NODE = {
|
||||
server: 'hw-dell-pe',
|
||||
switch: 'net-catalyst',
|
||||
storage: 'storage-netapp',
|
||||
firewall: 'net-fortinet',
|
||||
router: 'net-junper-srx',
|
||||
loadbalancer: 'net-f5',
|
||||
san: 'storage-pure',
|
||||
pdu: 'infra-pdu',
|
||||
ups: 'infra-apc-ups',
|
||||
cooling: 'infra-cooling',
|
||||
patch_panel: 'infra-patch-panel',
|
||||
tape: 'storage-netapp',
|
||||
kvm: 'infra-rack',
|
||||
other: 'infra-rack'
|
||||
};
|
||||
|
||||
const MANUFACTURER_NODE = {
|
||||
hpe: 'hw-hpe-dl',
|
||||
dell: 'hw-dell-pe',
|
||||
lenovo: 'hw-lenovo-ts',
|
||||
cisco: 'net-catalyst',
|
||||
juniper: 'net-junper-ex',
|
||||
arista: 'net-arista',
|
||||
netapp: 'storage-netapp',
|
||||
pure: 'storage-pure',
|
||||
fortinet: 'net-fortinet',
|
||||
f5: 'net-f5',
|
||||
palo: 'net-paloalto',
|
||||
nvidia: 'ai-nvidia',
|
||||
apc: 'infra-apc-ups'
|
||||
};
|
||||
|
||||
const DIAGRAM_TYPE_LABELS = {
|
||||
data_architecture: 'Data Architectuur',
|
||||
network_topology: 'Netwerk Topologie',
|
||||
system_architecture: 'Systeem Architectuur',
|
||||
application_flow: 'Applicatie Flow',
|
||||
application_landscape: 'Applicatie Landschap'
|
||||
};
|
||||
|
||||
function resolveNodeType(device) {
|
||||
const m = (device.manufacturer || '').toLowerCase();
|
||||
for (const [key, type] of Object.entries(MANUFACTURER_NODE)) {
|
||||
if (m.includes(key)) return type;
|
||||
}
|
||||
return DEVICE_TO_NODE[device.device_type] || 'hw-dell-pe';
|
||||
}
|
||||
|
||||
function rackDevicesToNodes(devices, startX = 40, startY = 40) {
|
||||
return devices.map((d, i) => ({
|
||||
id: 'rd-' + d.id,
|
||||
type: resolveNodeType(d),
|
||||
x: startX + (i % 4) * 180,
|
||||
y: startY + Math.floor(i / 4) * 90,
|
||||
label: d.name,
|
||||
sub: (d.model || d.device_type || '').toString(),
|
||||
css: 'node-hw',
|
||||
w: 160,
|
||||
h: 48,
|
||||
ip: d.mgmt_ip || '',
|
||||
vendor: d.manufacturer || '',
|
||||
model: d.model || '',
|
||||
specs: d.specs || '',
|
||||
notes: d.notes || '',
|
||||
rack_device_id: d.id,
|
||||
layer: 'infra'
|
||||
}));
|
||||
}
|
||||
|
||||
function parseDiagramJson(diagram) {
|
||||
let nodes = [];
|
||||
let edges = [];
|
||||
try {
|
||||
nodes = JSON.parse(diagram.nodes || '[]');
|
||||
edges = JSON.parse(diagram.edges || '[]');
|
||||
} catch (e) { /* ignore */ }
|
||||
if (!Array.isArray(nodes) && nodes && nodes.nodes) {
|
||||
edges = nodes.edges || [];
|
||||
nodes = nodes.nodes || [];
|
||||
}
|
||||
if (!Array.isArray(edges) && edges && edges.edges) {
|
||||
nodes = edges.nodes || nodes;
|
||||
edges = edges.edges || [];
|
||||
}
|
||||
if (!Array.isArray(nodes)) nodes = [];
|
||||
if (!Array.isArray(edges)) edges = [];
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
function summarizeDiagram(diagram) {
|
||||
const { nodes, edges } = parseDiagramJson(diagram);
|
||||
const layers = {};
|
||||
nodes.forEach(n => {
|
||||
const layer = n.layer || (n.type || '').split('-')[0] || 'other';
|
||||
layers[layer] = (layers[layer] || 0) + 1;
|
||||
});
|
||||
const protocols = {};
|
||||
edges.forEach(e => {
|
||||
const p = e.protocol || 'unspecified';
|
||||
protocols[p] = (protocols[p] || 0) + 1;
|
||||
});
|
||||
return { nodes, edges, layers, protocols, nodeCount: nodes.length, edgeCount: edges.length };
|
||||
}
|
||||
|
||||
function getLayerForCategory(catLabel) {
|
||||
const c = (catLabel || '').toLowerCase();
|
||||
if (c.includes('data bron') || c.includes('ingest') || c.includes('opslag') || c.includes('database')) return 'data';
|
||||
if (c.includes('microservice') || c.includes('apps')) return 'app';
|
||||
if (c.includes('api') || c.includes('messaging') || c.includes('queue')) return 'integration';
|
||||
if (c.includes('netwerk') || c.includes('compute / server') || c.includes('infrastructure') || c.includes('security') || c.includes('observability') || c.includes('ci/cd')) return 'infra';
|
||||
if (c.includes('ai')) return 'app';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEVICE_TO_NODE,
|
||||
DIAGRAM_TYPE_LABELS,
|
||||
resolveNodeType,
|
||||
rackDevicesToNodes,
|
||||
parseDiagramJson,
|
||||
summarizeDiagram,
|
||||
getLayerForCategory
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
const http = require('http');
|
||||
|
||||
function checkService(host, port, path, timeout = 3000) {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.request({ hostname: host, port, path, method: 'GET', timeout }, (res) => {
|
||||
resolve({ online: res.statusCode < 500, status: res.statusCode });
|
||||
});
|
||||
req.on('error', () => resolve({ online: false, status: 0 }));
|
||||
req.on('timeout', () => { req.destroy(); resolve({ online: false, status: 0 }); });
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function getServicesStatus() {
|
||||
const qsHost = process.env.QS_HOST || 'python-quality';
|
||||
const [quality, designer, leadgen] = await Promise.all([
|
||||
checkService(qsHost, 3002, '/python-quality/'),
|
||||
checkService('python-designer', 3001, '/python-editor/'),
|
||||
checkService('python-leadgen', 3003, '/python-leadgen/health')
|
||||
]);
|
||||
return {
|
||||
consulting: { online: true, label: 'Mek-Tech Core', port: 3000 },
|
||||
quality: { ...quality, label: 'Data Quality', port: 3002 },
|
||||
designer: { ...designer, label: 'Pro Designer', port: 3001 },
|
||||
leadgen: { ...leadgen, label: 'Lead Generation', port: 3003 }
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { checkService, getServicesStatus };
|
||||
Reference in New Issue
Block a user