From 4cf9248c0b09b06463f3de34622e0bbd8698c24e Mon Sep 17 00:00:00 2001 From: mo Date: Tue, 21 Jul 2026 21:13:33 +0200 Subject: [PATCH] feat: JSON API voor React SPA onder /api/spa + 401-json voor /api paden --- routes/spa/auth.js | 41 ++++ routes/spa/crm.js | 189 +++++++++++++++ routes/spa/finance.js | 195 +++++++++++++++ routes/spa/index.js | 122 ++++++++++ routes/spa/infra.js | 213 +++++++++++++++++ routes/spa/misc.js | 536 ++++++++++++++++++++++++++++++++++++++++++ server.js | 18 ++ 7 files changed, 1314 insertions(+) create mode 100644 routes/spa/auth.js create mode 100644 routes/spa/crm.js create mode 100644 routes/spa/finance.js create mode 100644 routes/spa/index.js create mode 100644 routes/spa/infra.js create mode 100644 routes/spa/misc.js diff --git a/routes/spa/auth.js b/routes/spa/auth.js new file mode 100644 index 0000000..3ccc5ce --- /dev/null +++ b/routes/spa/auth.js @@ -0,0 +1,41 @@ +const express = require('express'); +const router = express.Router(); +const bcrypt = require('bcryptjs'); +const { getDb, logAudit } = require('../../db'); + +// POST /api/spa/auth/login — JSON login, zet sessie +router.post('/login', (req, res) => { + const { username, password } = req.body || {}; + if (!username || !password) { + return res.status(400).json({ error: 'Gebruikersnaam en wachtwoord zijn verplicht' }); + } + const db = getDb(); + const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username); + if (!user || !bcrypt.compareSync(password, user.password_hash)) { + return res.status(401).json({ error: 'Ongeldige gebruikersnaam of wachtwoord' }); + } + req.session.userId = user.id; + req.session.username = user.username; + logAudit(user.id, user.username, 'user_login', 'user', user.id, `${user.username} ingelogd via SPA`); + res.json({ id: user.id, username: user.username, role: user.role, email: user.email || '' }); +}); + +// POST /api/spa/auth/logout +router.post('/logout', (req, res) => { + req.session.destroy(() => { + res.json({ ok: true }); + }); +}); + +// GET /api/spa/auth/me — huidige gebruiker (ook voor sessie-check bij opstarten SPA) +router.get('/me', (req, res) => { + if (!req.session || !req.session.userId) { + return res.status(401).json({ error: 'Niet ingelogd' }); + } + const db = getDb(); + const user = db.prepare('SELECT id, username, email, role FROM users WHERE id = ?').get(req.session.userId); + if (!user) return res.status(401).json({ error: 'Niet ingelogd' }); + res.json(user); +}); + +module.exports = router; diff --git a/routes/spa/crm.js b/routes/spa/crm.js new file mode 100644 index 0000000..17670c3 --- /dev/null +++ b/routes/spa/crm.js @@ -0,0 +1,189 @@ +const express = require('express'); +const router = express.Router(); +const { getDb, logAudit } = require('../../db'); + +// ============ CLIENTS ============ + +router.get('/clients', (req, res) => { + const db = getDb(); + const { status, search } = req.query; + let sql = `SELECT c.*, + (SELECT COUNT(*) FROM engagements e WHERE e.client_id = c.id) as engagement_count, + (SELECT MAX(a.created_at) FROM assessments a WHERE a.client_id = c.id) as last_assessment + FROM clients c WHERE 1=1`; + const params = []; + if (status) { sql += ' AND c.status = ?'; params.push(status); } + if (search) { sql += ' AND (c.name LIKE ? OR c.contact_name LIKE ? OR c.industry LIKE ?)'; params.push(`%${search}%`, `%${search}%`, `%${search}%`); } + sql += ' ORDER BY c.updated_at DESC'; + res.json(db.prepare(sql).all(...params)); +}); + +router.post('/clients', (req, res) => { + const db = getDb(); + const { name, industry, website, contact_name, contact_email, contact_phone, status, source, notes } = req.body; + if (!name || !name.trim()) return res.status(400).json({ error: 'Bedrijfsnaam is verplicht' }); + const result = db.prepare(`INSERT INTO clients (name, industry, website, contact_name, contact_email, contact_phone, status, source, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(name, industry, website, contact_name, contact_email, contact_phone, status || 'lead', source, notes); + logAudit(req.session.userId, req.session.username, 'client_created', 'client', result.lastInsertRowid, `Client ${name} aangemaakt via SPA`); + res.status(201).json({ id: result.lastInsertRowid }); +}); + +router.get('/clients/:id', (req, res) => { + const db = getDb(); + const client = db.prepare('SELECT * FROM clients WHERE id = ?').get(req.params.id); + if (!client) return res.status(404).json({ error: 'Client niet gevonden' }); + res.json({ + client, + engagements: db.prepare('SELECT * FROM engagements WHERE client_id = ? ORDER BY created_at DESC').all(req.params.id), + assessments: db.prepare('SELECT id, client_id, engagement_id, created_at FROM assessments WHERE client_id = ? ORDER BY created_at DESC').all(req.params.id), + advice: db.prepare('SELECT * FROM advice_log WHERE client_id = ? AND dismissed = 0 ORDER BY created_at DESC').all(req.params.id), + diagrams: db.prepare('SELECT id, name, description, diagram_type, updated_at, created_at FROM data_diagrams WHERE client_id = ? ORDER BY updated_at DESC').all(req.params.id), + notes: db.prepare('SELECT * FROM client_notes WHERE client_id = ? ORDER BY created_at DESC').all(req.params.id), + files: db.prepare('SELECT id, client_id, original_name, mime_type, file_size, created_at FROM client_files WHERE client_id = ? ORDER BY created_at DESC').all(req.params.id), + timeEntries: db.prepare('SELECT t.*, u.username FROM time_entries t LEFT JOIN users u ON u.id = t.user_id WHERE t.client_id = ? ORDER BY t.date DESC').all(req.params.id), + invoices: db.prepare('SELECT * FROM invoices WHERE client_id = ? ORDER BY created_at DESC').all(req.params.id) + }); +}); + +router.put('/clients/:id', (req, res) => { + const db = getDb(); + const { name, industry, website, contact_name, contact_email, contact_phone, status, source, notes } = req.body; + if (!name || !name.trim()) return res.status(400).json({ error: 'Bedrijfsnaam is verplicht' }); + db.prepare(`UPDATE clients SET name=?, industry=?, website=?, contact_name=?, contact_email=?, contact_phone=?, status=?, source=?, notes=?, updated_at=datetime('now') WHERE id=?`) + .run(name, industry, website, contact_name, contact_email, contact_phone, status, source, notes, req.params.id); + logAudit(req.session.userId, req.session.username, 'client_updated', 'client', req.params.id, `Client ${name} bijgewerkt via SPA`); + res.json({ ok: true }); +}); + +router.delete('/clients/:id', (req, res) => { + const db = getDb(); + db.prepare('DELETE FROM clients WHERE id = ?').run(req.params.id); + logAudit(req.session.userId, req.session.username, 'client_deleted', 'client', req.params.id, 'Client verwijderd via SPA'); + res.json({ ok: true }); +}); + +router.post('/clients/:id/advice/:adviceId/dismiss', (req, res) => { + const db = getDb(); + db.prepare('UPDATE advice_log SET dismissed = 1 WHERE id = ? AND client_id = ?').run(req.params.adviceId, req.params.id); + res.json({ ok: true }); +}); + +// ---- Client notes ---- +router.post('/clients/:id/notes', (req, res) => { + const db = getDb(); + const { content } = req.body; + if (!content || !content.trim()) return res.status(400).json({ error: 'Notitie is leeg' }); + const result = db.prepare('INSERT INTO client_notes (client_id, content, created_by) VALUES (?, ?, ?)') + .run(req.params.id, content.trim(), req.session.username || ''); + res.status(201).json({ id: result.lastInsertRowid }); +}); + +router.delete('/clients/:id/notes/:noteId', (req, res) => { + const db = getDb(); + db.prepare('DELETE FROM client_notes WHERE id = ? AND client_id = ?').run(req.params.noteId, req.params.id); + res.json({ ok: true }); +}); + +// ============ ENGAGEMENTS ============ + +router.get('/engagements', (req, res) => { + const db = getDb(); + const { type, status, client_id } = req.query; + let sql = `SELECT e.*, c.name as client_name, + (SELECT COUNT(*) FROM tasks t WHERE t.engagement_id = e.id) as task_count + FROM engagements e JOIN clients c ON c.id = e.client_id WHERE 1=1`; + const params = []; + if (type) { sql += ' AND e.type = ?'; params.push(type); } + if (status) { sql += ' AND e.status = ?'; params.push(status); } + if (client_id) { sql += ' AND e.client_id = ?'; params.push(client_id); } + sql += ' ORDER BY e.created_at DESC'; + res.json(db.prepare(sql).all(...params)); +}); + +router.post('/engagements', (req, res) => { + const db = getDb(); + const { client_id, type, title, description, start_date, end_date, status } = req.body; + if (!client_id || !type || !title) return res.status(400).json({ error: 'Client, type en titel zijn verplicht' }); + const result = db.prepare(`INSERT INTO engagements (client_id, type, title, description, start_date, end_date, status) + VALUES (?, ?, ?, ?, ?, ?, ?)`).run(client_id, type, title, description, start_date, end_date, status || 'planned'); + logAudit(req.session.userId, req.session.username, 'engagement_created', 'engagement', result.lastInsertRowid, `Engagement ${title} aangemaakt via SPA`); + res.status(201).json({ id: result.lastInsertRowid }); +}); + +router.get('/engagements/:id', (req, res) => { + const db = getDb(); + const engagement = db.prepare('SELECT e.*, c.name as client_name FROM engagements e JOIN clients c ON c.id = e.client_id WHERE e.id = ?').get(req.params.id); + if (!engagement) return res.status(404).json({ error: 'Engagement niet gevonden' }); + res.json({ + engagement, + tasks: db.prepare('SELECT * FROM tasks WHERE engagement_id = ? ORDER BY created_at DESC').all(req.params.id), + timeEntries: db.prepare('SELECT t.*, u.username FROM time_entries t LEFT JOIN users u ON u.id = t.user_id WHERE t.engagement_id = ? ORDER BY t.date DESC').all(req.params.id) + }); +}); + +router.put('/engagements/:id', (req, res) => { + const db = getDb(); + const { type, title, description, start_date, end_date, status, outcome } = req.body; + db.prepare(`UPDATE engagements SET type=?, title=?, description=?, start_date=?, end_date=?, status=?, outcome=?, updated_at=datetime('now') WHERE id=?`) + .run(type, title, description, start_date, end_date, status, outcome, req.params.id); + res.json({ ok: true }); +}); + +router.post('/engagements/:id/status', (req, res) => { + const db = getDb(); + db.prepare("UPDATE engagements SET status=?, updated_at=datetime('now') WHERE id=?").run(req.body.status, req.params.id); + res.json({ ok: true }); +}); + +router.delete('/engagements/:id', (req, res) => { + const db = getDb(); + db.prepare('DELETE FROM engagements WHERE id = ?').run(req.params.id); + res.json({ ok: true }); +}); + +// ============ TASKS ============ + +router.get('/tasks', (req, res) => { + const db = getDb(); + const tasks = db.prepare(` + SELECT t.*, e.title as engagement_title, e.id as engagement_id, c.name as client_name, c.id as client_id + FROM tasks t + JOIN engagements e ON e.id = t.engagement_id + JOIN clients c ON c.id = e.client_id + ORDER BY CASE t.status WHEN 'in_progress' THEN 0 WHEN 'todo' THEN 1 WHEN 'done' THEN 2 ELSE 3 END, + CASE t.priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END, + t.due_date IS NULL, t.due_date + `).all(); + res.json(tasks); +}); + +router.post('/tasks', (req, res) => { + const db = getDb(); + const { engagement_id, title, description, priority, due_date, status } = req.body; + if (!engagement_id || !title) return res.status(400).json({ error: 'Engagement en titel zijn verplicht' }); + const result = db.prepare('INSERT INTO tasks (engagement_id, title, description, priority, due_date, status) VALUES (?,?,?,?,?,?)') + .run(engagement_id, title, description || '', priority || 'medium', due_date || null, status || 'todo'); + res.status(201).json({ id: result.lastInsertRowid }); +}); + +router.post('/tasks/:id/status', (req, res) => { + const db = getDb(); + db.prepare("UPDATE tasks SET status=?, updated_at=datetime('now') WHERE id=?").run(req.body.status, req.params.id); + res.json({ ok: true }); +}); + +router.put('/tasks/:id', (req, res) => { + const db = getDb(); + const { title, description, priority, due_date, status } = req.body; + db.prepare("UPDATE tasks SET title=?, description=?, priority=?, due_date=?, status=?, updated_at=datetime('now') WHERE id=?") + .run(title, description, priority, due_date, status, req.params.id); + res.json({ ok: true }); +}); + +router.delete('/tasks/:id', (req, res) => { + const db = getDb(); + db.prepare('DELETE FROM tasks WHERE id = ?').run(req.params.id); + res.json({ ok: true }); +}); + +module.exports = router; diff --git a/routes/spa/finance.js b/routes/spa/finance.js new file mode 100644 index 0000000..85d78e6 --- /dev/null +++ b/routes/spa/finance.js @@ -0,0 +1,195 @@ +const express = require('express'); +const router = express.Router(); +const { getDb, logAudit } = require('../../db'); +const emailService = require('../../email'); + +// ============ TIME ENTRIES ============ + +router.get('/time', (req, res) => { + const db = getDb(); + const { client_id, date_from, date_to } = req.query; + let sql = ` + SELECT t.*, c.name as client_name, e.title as engagement_title, u.username + FROM time_entries t + LEFT JOIN clients c ON c.id = t.client_id + LEFT JOIN engagements e ON e.id = t.engagement_id + LEFT JOIN users u ON u.id = t.user_id + WHERE 1=1`; + const params = []; + if (client_id) { sql += ' AND t.client_id = ?'; params.push(client_id); } + if (date_from) { sql += ' AND t.date >= ?'; params.push(date_from); } + if (date_to) { sql += ' AND t.date <= ?'; params.push(date_to); } + sql += ' ORDER BY t.date DESC, t.created_at DESC'; + const entries = db.prepare(sql).all(...params); + res.json({ + entries, + totalHours: entries.reduce((s, e) => s + e.hours, 0), + billableHours: entries.filter(e => e.billable).reduce((s, e) => s + e.hours, 0) + }); +}); + +router.post('/time', (req, res) => { + const db = getDb(); + const { client_id, engagement_id, date, hours, description, billable, hourly_rate } = req.body; + if (!date || !hours) return res.status(400).json({ error: 'Datum en uren zijn verplicht' }); + const result = db.prepare('INSERT INTO time_entries (user_id, client_id, engagement_id, date, hours, description, billable, hourly_rate) VALUES (?,?,?,?,?,?,?,?)') + .run(req.session.userId, client_id || null, engagement_id || null, date, parseFloat(hours), description || '', billable ? 1 : 0, parseFloat(hourly_rate) || 150.0); + logAudit(req.session.userId, req.session.username, 'time_entry_created', 'time_entry', result.lastInsertRowid, `${hours}u geregistreerd op ${date} via SPA`); + res.status(201).json({ id: result.lastInsertRowid }); +}); + +router.put('/time/:id', (req, res) => { + const db = getDb(); + const { client_id, engagement_id, date, hours, description, billable, hourly_rate } = req.body; + db.prepare("UPDATE time_entries SET client_id=?, engagement_id=?, date=?, hours=?, description=?, billable=?, hourly_rate=?, updated_at=datetime('now') WHERE id=?") + .run(client_id || null, engagement_id || null, date, parseFloat(hours), description || '', billable ? 1 : 0, parseFloat(hourly_rate) || 150.0, req.params.id); + res.json({ ok: true }); +}); + +router.delete('/time/:id', (req, res) => { + const db = getDb(); + db.prepare('DELETE FROM time_entries WHERE id = ?').run(req.params.id); + res.json({ ok: true }); +}); + +// Factuur genereren uit billable uren +router.post('/time/create-invoice', (req, res) => { + const db = getDb(); + const { client_id, date_from, date_to } = req.body; + if (!client_id) return res.status(400).json({ error: 'Client is verplicht' }); + const entries = db.prepare(` + SELECT t.*, e.title as engagement_title + FROM time_entries t + LEFT JOIN engagements e ON e.id = t.engagement_id + WHERE t.client_id = ? AND t.billable = 1 + AND (? IS NULL OR t.date >= ?) AND (? IS NULL OR t.date <= ?) + `).all(client_id, date_from || null, date_from || null, date_to || null, date_to || null); + if (entries.length === 0) return res.status(400).json({ error: 'Geen facturabele uren gevonden' }); + const totalHours = entries.reduce((s, e) => s + e.hours, 0); + const rate = entries[0].hourly_rate || 150; + const subtotal = totalHours * rate; + const tax = subtotal * 0.21; + const count = db.prepare('SELECT COUNT(*) as cnt FROM invoices').get().cnt + 1; + const number = `FACT-${new Date().getFullYear()}-${String(count).padStart(4, '0')}`; + const date = new Date().toISOString().split('T')[0]; + const result = db.prepare('INSERT INTO invoices (client_id, number, status, date, due_date, subtotal, tax, total, notes) VALUES (?,?,?,?,?,?,?,?,?)') + .run(client_id, number, 'draft', date, '', subtotal, tax, subtotal + tax, `Factuur gegenereerd uit ${entries.length} tijdregistraties`); + const invoiceId = result.lastInsertRowid; + const insert = db.prepare('INSERT INTO invoice_items (invoice_id, description, quantity, unit_price, total) VALUES (?,?,?,?,?)'); + entries.forEach(e => { + const lineTotal = e.hours * (e.hourly_rate || rate); + insert.run(invoiceId, `${e.date} - ${e.engagement_title || 'Werkzaamheden'}: ${e.description || ''}`, e.hours, e.hourly_rate || rate, lineTotal); + }); + logAudit(req.session.userId, req.session.username, 'invoice_from_time', 'invoice', invoiceId, `Factuur ${number} gemaakt uit ${entries.length} uren via SPA`); + res.status(201).json({ id: invoiceId, number }); +}); + +// ============ INVOICES ============ + +router.get('/invoices', (req, res) => { + const db = getDb(); + const invoices = db.prepare(` + SELECT i.*, c.name as client_name + FROM invoices i JOIN clients c ON c.id = i.client_id + ORDER BY i.created_at DESC + `).all(); + res.json({ + invoices, + totalOutstanding: invoices.filter(i => i.status === 'sent' || i.status === 'overdue').reduce((s, i) => s + i.total, 0), + totalPaid: invoices.filter(i => i.status === 'paid').reduce((s, i) => s + i.total, 0) + }); +}); + +router.post('/invoices', (req, res) => { + const db = getDb(); + const { client_id, date, due_date, notes, items, subtotal, tax } = req.body; + if (!client_id) return res.status(400).json({ error: 'Client is verplicht' }); + const count = db.prepare('SELECT COUNT(*) as cnt FROM invoices').get().cnt + 1; + const number = `FACT-${new Date().getFullYear()}-${String(count).padStart(4, '0')}`; + const sub = parseFloat(subtotal) || 0; + const taxAmt = parseFloat(tax) || 0; + const result = db.prepare('INSERT INTO invoices (client_id, number, status, date, due_date, subtotal, tax, total, notes) VALUES (?,?,?,?,?,?,?,?,?)') + .run(client_id, number, 'draft', date || new Date().toISOString().split('T')[0], due_date || '', sub, taxAmt, sub + taxAmt, notes || ''); + const invoiceId = result.lastInsertRowid; + if (items && Array.isArray(items)) { + const insert = db.prepare('INSERT INTO invoice_items (invoice_id, description, quantity, unit_price, total) VALUES (?,?,?,?,?)'); + for (const item of items) { + if (item.description) { + const itemTotal = (parseFloat(item.quantity) || 1) * (parseFloat(item.unit_price) || 0); + insert.run(invoiceId, item.description, parseFloat(item.quantity) || 1, parseFloat(item.unit_price) || 0, itemTotal); + } + } + } + logAudit(req.session.userId, req.session.username, 'invoice_created', 'invoice', invoiceId, `Factuur ${number} aangemaakt via SPA`); + res.status(201).json({ id: invoiceId, number }); +}); + +router.get('/invoices/:id', (req, res) => { + const db = getDb(); + const invoice = db.prepare('SELECT i.*, c.name as client_name, c.contact_name, c.contact_email, c.contact_phone, c.website FROM invoices i JOIN clients c ON c.id = i.client_id WHERE i.id = ?').get(req.params.id); + if (!invoice) return res.status(404).json({ error: 'Factuur niet gevonden' }); + const items = db.prepare('SELECT * FROM invoice_items WHERE invoice_id = ?').all(req.params.id); + res.json({ invoice, items }); +}); + +router.post('/invoices/:id/status', (req, res) => { + const db = getDb(); + db.prepare("UPDATE invoices SET status=?, updated_at=datetime('now') WHERE id=?").run(req.body.status, req.params.id); + res.json({ ok: true }); +}); + +router.delete('/invoices/:id', (req, res) => { + const db = getDb(); + db.prepare('DELETE FROM invoices WHERE id = ?').run(req.params.id); + res.json({ ok: true }); +}); + +router.post('/invoices/:id/send-email', async (req, res) => { + const db = getDb(); + const invoice = db.prepare('SELECT i.*, c.name as client_name, c.contact_email FROM invoices i JOIN clients c ON c.id = i.client_id WHERE i.id = ?').get(req.params.id); + if (!invoice) return res.status(404).json({ error: 'Factuur niet gevonden' }); + const account = db.prepare('SELECT * FROM email_accounts WHERE user_id = ? AND active = 1 LIMIT 1').get(req.session.userId); + if (!account) return res.status(400).json({ error: 'Geen e-mailaccount geconfigureerd' }); + const items = db.prepare('SELECT * FROM invoice_items WHERE invoice_id = ?').all(req.params.id); + let text = `Factuur ${invoice.number}\n\nClient: ${invoice.client_name}\nDatum: ${invoice.date}\nVervaldatum: ${invoice.due_date || '-'}\n\n`; + items.forEach(item => { text += `${item.description} x${item.quantity} EUR ${(item.unit_price || 0).toFixed(2)} EUR ${(item.total || 0).toFixed(2)}\n`; }); + text += `\nSubtotaal: EUR ${(invoice.subtotal || 0).toFixed(2)}\nBTW: EUR ${(invoice.tax || 0).toFixed(2)}\nTotaal: EUR ${(invoice.total || 0).toFixed(2)}\n\nMek-Tech Consulting`; + const to = invoice.contact_email || account.email; + try { + await emailService.sendEmail(account.id, to, `Factuur ${invoice.number}`, text, text.replace(/\n/g, '
')); + logAudit(req.session.userId, req.session.username, 'invoice_emailed', 'invoice', invoice.id, `Factuur ${invoice.number} gemaild naar ${to}`); + db.prepare("UPDATE invoices SET status='sent', updated_at=datetime('now') WHERE id=?").run(req.params.id); + res.json({ ok: true }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +// ============ FINANCE OVERZICHT ============ + +router.get('/finance', (req, res) => { + const db = getDb(); + const year = parseInt(req.query.year) || new Date().getFullYear(); + const timeStats = db.prepare(` + SELECT strftime('%m', date) as month, SUM(hours) as hours, + SUM(CASE WHEN billable = 1 THEN hours ELSE 0 END) as billable_hours + FROM time_entries WHERE strftime('%Y', date) = ? + GROUP BY month ORDER BY month + `).all(String(year)); + const invoiceStats = db.prepare(` + SELECT strftime('%m', date) as month, + SUM(CASE WHEN status = 'paid' THEN total ELSE 0 END) as paid, + SUM(CASE WHEN status IN ('sent','overdue') THEN total ELSE 0 END) as outstanding + FROM invoices WHERE strftime('%Y', date) = ? + GROUP BY month ORDER BY month + `).all(String(year)); + const totals = { + hoursThisYear: db.prepare("SELECT COALESCE(SUM(hours),0) as s FROM time_entries WHERE strftime('%Y', date) = ?").get(String(year)).s, + paidThisYear: db.prepare("SELECT COALESCE(SUM(total),0) as s FROM invoices WHERE status='paid' AND strftime('%Y', date) = ?").get(String(year)).s, + outstanding: db.prepare("SELECT COALESCE(SUM(total),0) as s FROM invoices WHERE status IN ('sent','overdue')").get().s, + draftTotal: db.prepare("SELECT COALESCE(SUM(total),0) as s FROM invoices WHERE status='draft'").get().s + }; + res.json({ year, timeStats, invoiceStats, totals }); +}); + +module.exports = router; diff --git a/routes/spa/index.js b/routes/spa/index.js new file mode 100644 index 0000000..d104dea --- /dev/null +++ b/routes/spa/index.js @@ -0,0 +1,122 @@ +const express = require('express'); +const router = express.Router(); +const { getDb, getSettings } = require('../../db'); +const { getServicesStatus } = require('../../lib/services'); + +// ---- Auth is publiek (login), rest vereist sessie ---- +router.use('/auth', require('./auth')); + +// Sessie-guard voor alle andere /api/spa routes: 401 JSON (nooit redirect) +router.use((req, res, next) => { + if (!req.session || !req.session.userId) { + return res.status(401).json({ error: 'Niet ingelogd' }); + } + next(); +}); + +router.use(require('./crm')); +router.use(require('./finance')); +for (const mod of ['./infra', './misc']) { + try { router.use(require(mod)); } catch (e) { console.warn(`SPA module ${mod} niet geladen:`, e.message); } +} + +// ============ DASHBOARD (geaggregeerde data voor SPA-home) ============ +router.get('/dashboard', async (req, res) => { + try { + const db = getDb(); + + const clients = db.prepare(` + SELECT c.*, + (SELECT COUNT(*) FROM engagements e WHERE e.client_id = c.id) as engagement_count, + (SELECT COUNT(*) FROM engagements e WHERE e.client_id = c.id AND e.status = 'in_progress') as active_engagements, + (SELECT MAX(a.created_at) FROM assessments a WHERE a.client_id = c.id) as last_assessment + FROM clients c ORDER BY c.updated_at DESC LIMIT 12 + `).all(); + + const stats = { + totalClients: db.prepare('SELECT COUNT(*) as cnt FROM clients').get().cnt, + activeClients: db.prepare("SELECT COUNT(*) as cnt FROM clients WHERE status='active'").get().cnt, + leads: db.prepare("SELECT COUNT(*) as cnt FROM clients WHERE status='lead'").get().cnt, + activeEngagements: db.prepare("SELECT COUNT(*) as cnt FROM engagements WHERE status='in_progress'").get().cnt, + totalTasks: db.prepare('SELECT COUNT(*) as cnt FROM tasks').get().cnt, + completedTasks: db.prepare("SELECT COUNT(*) as cnt FROM tasks WHERE status='done'").get().cnt, + overdueTasks: db.prepare("SELECT COUNT(*) as cnt FROM tasks WHERE status IN ('todo','in_progress') AND due_date < date('now')").get().cnt, + totalRacks: db.prepare('SELECT COUNT(*) as cnt FROM racks').get().cnt, + totalDiagrams: db.prepare('SELECT COUNT(*) as cnt FROM data_diagrams').get().cnt, + totalNetworkDevices: db.prepare('SELECT COUNT(*) as cnt FROM network_devices').get().cnt, + totalHours: db.prepare('SELECT COALESCE(SUM(hours),0) as h FROM time_entries').get().h, + billableHours: db.prepare('SELECT COALESCE(SUM(hours),0) as h FROM time_entries WHERE billable=1').get().h, + totalInvoiced: db.prepare("SELECT COALESCE(SUM(total),0) as t FROM invoices WHERE status != 'draft'").get().t, + totalPaid: db.prepare("SELECT COALESCE(SUM(total),0) as t FROM invoices WHERE status='paid'").get().t, + totalOutstanding: db.prepare("SELECT COALESCE(SUM(total),0) as t FROM invoices WHERE status IN ('sent','overdue')").get().t + }; + try { stats.datacenters = db.prepare('SELECT COUNT(*) as cnt FROM datacenters').get().cnt; } catch (e) { stats.datacenters = 0; } + try { stats.marketCompanies = db.prepare('SELECT COUNT(*) as cnt FROM tech_companies').get().cnt; } catch (e) { stats.marketCompanies = 0; } + + const recentEngagements = db.prepare(` + SELECT e.*, c.name as client_name FROM engagements e JOIN clients c ON c.id = e.client_id + ORDER BY e.updated_at DESC LIMIT 8 + `).all(); + + const upcomingTasks = db.prepare(` + SELECT t.*, e.title as engagement_title, c.name as client_name + FROM tasks t JOIN engagements e ON e.id = t.engagement_id JOIN clients c ON c.id = e.client_id + WHERE t.status IN ('todo','in_progress') + ORDER BY t.due_date IS NULL, t.due_date, CASE t.priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END + LIMIT 10 + `).all(); + + const recentInvoices = db.prepare(` + SELECT i.*, c.name as client_name FROM invoices i JOIN clients c ON c.id = i.client_id + ORDER BY i.created_at DESC LIMIT 6 + `).all(); + + const engagementByType = db.prepare('SELECT type, COUNT(*) as cnt FROM engagements GROUP BY type ORDER BY cnt DESC').all(); + const engagementByStatus = db.prepare('SELECT status, COUNT(*) as cnt FROM engagements GROUP BY status ORDER BY cnt DESC').all(); + const clientsByStatus = db.prepare('SELECT status, COUNT(*) as cnt FROM clients GROUP BY status').all(); + const invoicesByStatus = db.prepare('SELECT status, COUNT(*) as cnt, COALESCE(SUM(total),0) as total FROM invoices GROUP BY status').all(); + + // Uren per maand (afgelopen 6 maanden) voor grafiek + const hoursByMonth = db.prepare(` + SELECT strftime('%Y-%m', date) as month, SUM(hours) as hours, + SUM(CASE WHEN billable=1 THEN hours ELSE 0 END) as billable + FROM time_entries + WHERE date >= date('now', '-6 months', 'start of month') + GROUP BY month ORDER BY month + `).all(); + + // Omzet per maand (afgelopen 6 maanden) + const revenueByMonth = db.prepare(` + SELECT strftime('%Y-%m', date) as month, + SUM(CASE WHEN status='paid' THEN total ELSE 0 END) as paid, + SUM(CASE WHEN status IN ('sent','overdue') THEN total ELSE 0 END) as outstanding + FROM invoices + WHERE date >= date('now', '-6 months', 'start of month') + GROUP BY month ORDER BY month + `).all(); + + const activeAdvice = db.prepare(` + SELECT a.*, c.name as client_name + FROM advice_log a JOIN clients c ON c.id = a.client_id + WHERE a.dismissed = 0 + ORDER BY CASE a.priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END, a.created_at DESC + LIMIT 8 + `).all(); + + const recentAudit = db.prepare('SELECT * FROM audit_log ORDER BY created_at DESC LIMIT 8').all(); + + let servicesStatus = {}; + try { servicesStatus = await getServicesStatus(); } catch (e) { servicesStatus = {}; } + + res.json({ + stats, clients, recentEngagements, upcomingTasks, recentInvoices, + engagementByType, engagementByStatus, clientsByStatus, invoicesByStatus, + hoursByMonth, revenueByMonth, activeAdvice, recentAudit, servicesStatus, + settings: { brand_name: getSettings().brand_name, showcase_url: getSettings().showcase_url } + }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +module.exports = router; diff --git a/routes/spa/infra.js b/routes/spa/infra.js new file mode 100644 index 0000000..52263e3 --- /dev/null +++ b/routes/spa/infra.js @@ -0,0 +1,213 @@ +const express = require('express'); +const router = express.Router(); +const { getDb, logAudit } = require('../db'); + +// ============ RACKS ============ + +router.get('/racks', (req, res) => { + const db = getDb(); + res.json(db.prepare(` + SELECT r.*, c.name as client_name, + (SELECT COUNT(*) FROM rack_devices d WHERE d.rack_id = r.id) as device_count + FROM racks r JOIN clients c ON c.id = r.client_id + ORDER BY r.updated_at DESC + `).all()); +}); + +router.post('/racks', (req, res) => { + const db = getDb(); + const { client_id, name, location, datacenter, total_units, notes } = req.body; + if (!client_id || !name || !name.trim()) return res.status(400).json({ error: 'Client en naam zijn verplicht' }); + const result = db.prepare(`INSERT INTO racks (client_id, name, location, datacenter, total_units, notes) + VALUES (?, ?, ?, ?, ?, ?)`).run(client_id, name.trim(), location || null, datacenter || null, total_units || 42, notes || null); + logAudit(req.session.userId, req.session.username, 'rack_created', 'rack', result.lastInsertRowid, `Rack ${name} aangemaakt via SPA`); + res.status(201).json({ id: result.lastInsertRowid }); +}); + +router.get('/racks/:id', (req, res) => { + const db = getDb(); + const rack = db.prepare('SELECT r.*, c.name as client_name FROM racks r JOIN clients c ON c.id = r.client_id WHERE r.id = ?').get(req.params.id); + if (!rack) return res.status(404).json({ error: 'Rack niet gevonden' }); + res.json({ + rack, + devices: db.prepare('SELECT * FROM rack_devices WHERE rack_id = ? ORDER BY position_u').all(req.params.id) + }); +}); + +router.put('/racks/:id', (req, res) => { + const db = getDb(); + const { name, location, datacenter, total_units, notes } = req.body; + if (!name || !name.trim()) return res.status(400).json({ error: 'Naam is verplicht' }); + db.prepare(`UPDATE racks SET name=?, location=?, datacenter=?, total_units=?, notes=?, updated_at=datetime('now') WHERE id=?`) + .run(name, location, datacenter, total_units, notes, req.params.id); + res.json({ ok: true }); +}); + +router.delete('/racks/:id', (req, res) => { + const db = getDb(); + db.prepare('DELETE FROM racks WHERE id = ?').run(req.params.id); + res.json({ ok: true }); +}); + +// ---- Rack devices ---- + +router.post('/racks/:id/devices', (req, res) => { + const db = getDb(); + const { name, device_type, model, manufacturer, position_u, height_u, specs, mgmt_ip, notes, serial, asset_tag, status } = req.body; + if (!name || !name.trim() || !position_u) return res.status(400).json({ error: 'Naam en positie (U) zijn verplicht' }); + const result = db.prepare(`INSERT INTO rack_devices (rack_id, name, device_type, model, manufacturer, position_u, height_u, specs, mgmt_ip, notes, serial, asset_tag, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run(req.params.id, name.trim(), device_type || 'server', model || null, manufacturer || null, position_u, height_u || 1, + specs || null, mgmt_ip || null, notes || null, serial || '', asset_tag || '', status || 'active'); + logAudit(req.session.userId, req.session.username, 'rack_device_created', 'rack_device', result.lastInsertRowid, `Rack device ${name} aangemaakt via SPA`); + res.status(201).json({ id: result.lastInsertRowid }); +}); + +router.put('/racks/devices/:deviceId', (req, res) => { + const db = getDb(); + const { name, device_type, model, manufacturer, position_u, height_u, specs, mgmt_ip, notes, serial, asset_tag, status } = req.body; + db.prepare(`UPDATE rack_devices SET name=?, device_type=?, model=?, manufacturer=?, position_u=?, height_u=?, specs=?, mgmt_ip=?, notes=?, serial=?, asset_tag=?, status=?, updated_at=datetime('now') WHERE id=?`) + .run(name, device_type, model, manufacturer, position_u, height_u, specs, mgmt_ip, notes, serial, asset_tag, status, req.params.deviceId); + res.json({ ok: true }); +}); + +router.delete('/racks/devices/:deviceId', (req, res) => { + const db = getDb(); + db.prepare('DELETE FROM rack_devices WHERE id = ?').run(req.params.deviceId); + res.json({ ok: true }); +}); + +// ============ NETWORK DEVICES ============ + +router.get('/networking/devices', (req, res) => { + const db = getDb(); + res.json(db.prepare(` + SELECT nd.*, c.name as client_name + FROM network_devices nd JOIN clients c ON c.id = nd.client_id + ORDER BY nd.updated_at DESC + `).all()); +}); + +router.post('/networking/devices', (req, res) => { + const db = getDb(); + const { client_id, name, device_type, model, manufacturer, ip_address, mgmt_ip, ports_count, os_version, notes, status } = req.body; + if (!client_id || !name || !name.trim() || !device_type) return res.status(400).json({ error: 'Client, naam en type zijn verplicht' }); + const result = db.prepare(`INSERT INTO network_devices (client_id, name, device_type, model, manufacturer, ip_address, mgmt_ip, ports_count, os_version, notes, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run(client_id, name.trim(), device_type, model || null, manufacturer || null, ip_address || null, mgmt_ip || null, + ports_count || 24, os_version || null, notes || null, status || 'active'); + logAudit(req.session.userId, req.session.username, 'network_device_created', 'network_device', result.lastInsertRowid, `Netwerkdevice ${name} aangemaakt via SPA`); + res.status(201).json({ id: result.lastInsertRowid }); +}); + +router.get('/networking/devices/:id', (req, res) => { + const db = getDb(); + const device = db.prepare('SELECT nd.*, c.name as client_name FROM network_devices nd JOIN clients c ON c.id = nd.client_id WHERE nd.id = ?').get(req.params.id); + if (!device) return res.status(404).json({ error: 'Netwerkdevice niet gevonden' }); + res.json({ + device, + connections: db.prepare(`SELECT * FROM network_connections + WHERE (from_type = 'network_device' AND from_id = ?) OR (to_type = 'network_device' AND to_id = ?) + ORDER BY created_at DESC`).all(req.params.id, req.params.id) + }); +}); + +router.put('/networking/devices/:id', (req, res) => { + const db = getDb(); + const { name, device_type, model, manufacturer, ip_address, mgmt_ip, ports_count, os_version, notes, status } = req.body; + db.prepare(`UPDATE network_devices SET name=?, device_type=?, model=?, manufacturer=?, ip_address=?, mgmt_ip=?, ports_count=?, os_version=?, notes=?, status=?, updated_at=datetime('now') WHERE id=?`) + .run(name, device_type, model, manufacturer, ip_address, mgmt_ip, ports_count, os_version, notes, status, req.params.id); + res.json({ ok: true }); +}); + +router.delete('/networking/devices/:id', (req, res) => { + const db = getDb(); + db.prepare('DELETE FROM network_devices WHERE id = ?').run(req.params.id); + res.json({ ok: true }); +}); + +// ============ NETWORK CONNECTIONS ============ + +router.get('/networking/connections', (req, res) => { + const db = getDb(); + res.json(db.prepare(` + SELECT nc.*, c.name as client_name + FROM network_connections nc JOIN clients c ON c.id = nc.client_id + ORDER BY nc.created_at DESC + `).all()); +}); + +router.post('/networking/connections', (req, res) => { + const db = getDb(); + const { client_id, name, from_type, from_id, from_port, to_type, to_id, to_port, media_type, speed, vlan, status, notes } = req.body; + if (!client_id || !from_type || !to_type) return res.status(400).json({ error: 'Client, bron- en doeltype zijn verplicht' }); + const result = db.prepare(`INSERT INTO network_connections (client_id, name, from_type, from_id, from_port, to_type, to_id, to_port, media_type, speed, vlan, status, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run(client_id, name || null, from_type, from_id || null, from_port || null, to_type, to_id || null, to_port || null, + media_type || 'copper', speed || '1GbE', vlan || null, status || 'active', notes || null); + logAudit(req.session.userId, req.session.username, 'network_connection_created', 'network_connection', result.lastInsertRowid, 'Netwerkverbinding aangemaakt via SPA'); + res.status(201).json({ id: result.lastInsertRowid }); +}); + +router.put('/networking/connections/:id', (req, res) => { + const db = getDb(); + const { name, from_type, from_id, from_port, to_type, to_id, to_port, media_type, speed, vlan, status, notes } = req.body; + db.prepare(`UPDATE network_connections SET name=?, from_type=?, from_id=?, from_port=?, to_type=?, to_id=?, to_port=?, media_type=?, speed=?, vlan=?, status=?, notes=? WHERE id=?`) + .run(name, from_type, from_id, from_port, to_type, to_id, to_port, media_type, speed, vlan, status, notes, req.params.id); + res.json({ ok: true }); +}); + +router.delete('/networking/connections/:id', (req, res) => { + const db = getDb(); + db.prepare('DELETE FROM network_connections WHERE id = ?').run(req.params.id); + res.json({ ok: true }); +}); + +// ============ DIAGRAMS ============ + +router.get('/diagrams', (req, res) => { + const db = getDb(); + res.json(db.prepare(` + SELECT d.id, d.client_id, d.name, d.description, d.diagram_type, d.engagement_id, d.rack_id, d.created_at, d.updated_at, c.name as client_name + FROM data_diagrams d JOIN clients c ON c.id = d.client_id + ORDER BY d.updated_at DESC + `).all()); +}); + +router.get('/diagrams/:id', (req, res) => { + const db = getDb(); + const diagram = db.prepare('SELECT * FROM data_diagrams WHERE id = ?').get(req.params.id); + if (!diagram) return res.status(404).json({ error: 'Diagram niet gevonden' }); + let nodes = [], edges = []; + try { nodes = JSON.parse(diagram.nodes || '[]'); } catch (e) { nodes = []; } + try { edges = JSON.parse(diagram.edges || '[]'); } catch (e) { edges = []; } + res.json({ ...diagram, nodes, edges }); +}); + +router.post('/diagrams', (req, res) => { + const db = getDb(); + const { client_id, name, description, diagram_type, nodes, edges, engagement_id, rack_id } = req.body; + if (!client_id || !name || !name.trim()) return res.status(400).json({ error: 'Client en naam zijn verplicht' }); + const result = db.prepare(`INSERT INTO data_diagrams (client_id, name, description, diagram_type, nodes, edges, engagement_id, rack_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`) + .run(client_id, name.trim(), description || null, diagram_type || 'data_architecture', + JSON.stringify(nodes || []), JSON.stringify(edges || []), engagement_id || null, rack_id || null); + logAudit(req.session.userId, req.session.username, 'diagram_created', 'diagram', result.lastInsertRowid, `Diagram ${name} aangemaakt via SPA`); + res.status(201).json({ id: result.lastInsertRowid }); +}); + +router.put('/diagrams/:id', (req, res) => { + const db = getDb(); + const { name, description, diagram_type, nodes, edges, engagement_id, rack_id } = req.body; + db.prepare(`UPDATE data_diagrams SET name=?, description=?, diagram_type=?, nodes=?, edges=?, engagement_id=?, rack_id=?, updated_at=datetime('now') WHERE id=?`) + .run(name, description, diagram_type, JSON.stringify(nodes || []), JSON.stringify(edges || []), engagement_id || null, rack_id || null, req.params.id); + res.json({ ok: true }); +}); + +router.delete('/diagrams/:id', (req, res) => { + const db = getDb(); + db.prepare('DELETE FROM data_diagrams WHERE id = ?').run(req.params.id); + res.json({ ok: true }); +}); + +module.exports = router; diff --git a/routes/spa/misc.js b/routes/spa/misc.js new file mode 100644 index 0000000..ab60858 --- /dev/null +++ b/routes/spa/misc.js @@ -0,0 +1,536 @@ +const express = require('express'); +const router = express.Router(); +const bcrypt = require('bcryptjs'); +// Let op: vanuit routes/spa/ is het juiste pad '../../db' (er is geen routes/db.js) +const { getDb, logAudit, getSettings, updateSetting } = require('../../db'); +const { PROVIDER_PRESETS, getProviders, getProvider, saveProvider, deleteProvider, chatCompletion } = require('../../lib/ai'); +const { createBackup, listBackups, deleteBackup } = require('../../lib/backup'); +const emailService = require('../../email'); + +// Lazy CREATE TABLE (kopie uit routes/projects.js) zodat de tabellen zeker bestaan +try { + const db = getDb(); + db.exec(` + CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + client_id INTEGER, + start_date TEXT, + end_date TEXT, + status TEXT DEFAULT 'planning', + priority TEXT DEFAULT 'medium', + budget REAL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (client_id) REFERENCES clients(id) + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS project_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + title TEXT NOT NULL, + description TEXT, + status TEXT DEFAULT 'todo', + priority TEXT DEFAULT 'medium', + assignee_id INTEGER, + start_date TEXT, + due_date TEXT, + progress INTEGER DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (assignee_id) REFERENCES users(id) + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS project_milestones ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + name TEXT NOT NULL, + target_date TEXT, + status TEXT DEFAULT 'pending', + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (project_id) REFERENCES projects(id) + ) + `); +} catch (e) { /* tabellen bestaan al of db nog niet klaar */ } + +function requireAdmin(req, res, next) { + const db = getDb(); + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.session.userId); + if (!user || user.role !== 'admin') return res.status(403).json({ error: 'Alleen voor admins' }); + next(); +} + +// ============ CALENDAR ============ + +router.get('/calendar', (req, res) => { + const db = getDb(); + const events = db.prepare(` + SELECT e.*, c.name as client_name + FROM calendar_events e + LEFT JOIN clients c ON c.id = e.client_id + ORDER BY e.start_time DESC + `).all(); + res.json(events); +}); + +router.post('/calendar', (req, res) => { + const db = getDb(); + const { title, description, client_id, engagement_id, event_type, start_time, end_time, all_day, color } = req.body; + if (!title || !start_time || !end_time) return res.status(400).json({ error: 'Titel, start- en eindtijd zijn verplicht' }); + const result = db.prepare('INSERT INTO calendar_events (user_id, client_id, engagement_id, title, description, event_type, start_time, end_time, all_day, color) VALUES (?,?,?,?,?,?,?,?,?,?)') + .run(req.session.userId, client_id || null, engagement_id || null, title, description || '', event_type || 'meeting', start_time, end_time, (all_day === true || all_day === 1 || all_day === '1') ? 1 : 0, color || '#58a6ff'); + logAudit(req.session.userId, req.session.username, 'event_created', 'calendar_event', result.lastInsertRowid, `Event "${title}" aangemaakt via SPA`); + res.status(201).json({ id: result.lastInsertRowid }); +}); + +router.put('/calendar/:id', (req, res) => { + const db = getDb(); + const { title, description, client_id, engagement_id, event_type, start_time, end_time, all_day, color } = req.body; + if (!title) return res.status(400).json({ error: 'Titel is verplicht' }); + db.prepare("UPDATE calendar_events SET title=?, description=?, client_id=?, engagement_id=?, event_type=?, start_time=?, end_time=?, all_day=?, color=?, updated_at=datetime('now') WHERE id=?") + .run(title, description, client_id || null, engagement_id || null, event_type, start_time, end_time, (all_day === true || all_day === 1 || all_day === '1') ? 1 : 0, color, req.params.id); + res.json({ ok: true }); +}); + +router.delete('/calendar/:id', (req, res) => { + const db = getDb(); + db.prepare('DELETE FROM calendar_events WHERE id = ?').run(req.params.id); + res.json({ ok: true }); +}); + +// ============ PROJECTS ============ + +router.get('/projects', (req, res) => { + const db = getDb(); + const projects = db.prepare(` + SELECT p.*, c.name as client_name, + (SELECT COUNT(*) FROM project_tasks t WHERE t.project_id=p.id) as task_count, + (SELECT COUNT(*) FROM project_tasks t WHERE t.project_id=p.id AND t.status='done') as done_count + FROM projects p + LEFT JOIN clients c ON c.id=p.client_id + ORDER BY p.updated_at DESC + `).all(); + res.json(projects); +}); + +router.post('/projects', (req, res) => { + const db = getDb(); + const { name, description, client_id, start_date, end_date, status, priority, budget } = req.body; + if (!name || !name.trim()) return res.status(400).json({ error: 'Projectnaam is verplicht' }); + const result = db.prepare('INSERT INTO projects (name, description, client_id, start_date, end_date, status, priority, budget) VALUES (?,?,?,?,?,?,?,?)') + .run(name, description || '', client_id || null, start_date || null, end_date || null, status || 'planning', priority || 'medium', budget || null); + logAudit(req.session.userId, req.session.username, 'project_created', 'project', result.lastInsertRowid, `Project ${name} aangemaakt via SPA`); + res.status(201).json({ id: result.lastInsertRowid }); +}); + +router.get('/projects/:id', (req, res) => { + const db = getDb(); + const project = db.prepare(` + SELECT p.*, c.name as client_name + FROM projects p + LEFT JOIN clients c ON c.id = p.client_id + WHERE p.id = ? + `).get(req.params.id); + if (!project) return res.status(404).json({ error: 'Project niet gevonden' }); + res.json({ + project, + tasks: db.prepare(` + SELECT pt.*, u.username as assignee_name + FROM project_tasks pt + LEFT JOIN users u ON u.id = pt.assignee_id + WHERE pt.project_id = ? + ORDER BY pt.due_date ASC + `).all(req.params.id), + milestones: db.prepare('SELECT * FROM project_milestones WHERE project_id = ? ORDER BY target_date ASC').all(req.params.id) + }); +}); + +router.put('/projects/:id', (req, res) => { + const db = getDb(); + const { name, description, client_id, start_date, end_date, status, priority, budget } = req.body; + if (!name || !name.trim()) return res.status(400).json({ error: 'Projectnaam is verplicht' }); + db.prepare('UPDATE projects SET name=?, description=?, client_id=?, start_date=?, end_date=?, status=?, priority=?, budget=?, updated_at=CURRENT_TIMESTAMP WHERE id=?') + .run(name, description || '', client_id || null, start_date || null, end_date || null, status, priority, budget || null, req.params.id); + res.json({ ok: true }); +}); + +router.delete('/projects/:id', (req, res) => { + const db = getDb(); + db.prepare('DELETE FROM project_tasks WHERE project_id = ?').run(req.params.id); + db.prepare('DELETE FROM project_milestones WHERE project_id = ?').run(req.params.id); + db.prepare('DELETE FROM projects WHERE id = ?').run(req.params.id); + res.json({ ok: true }); +}); + +router.post('/projects/:id/tasks', (req, res) => { + const db = getDb(); + const { title, description, assignee_id, status, priority, progress, due_date } = req.body; + if (!title || !title.trim()) return res.status(400).json({ error: 'Titel is verplicht' }); + const result = db.prepare('INSERT INTO project_tasks (project_id, title, description, assignee_id, status, priority, progress, due_date) VALUES (?,?,?,?,?,?,?,?)') + .run(req.params.id, title, description || '', assignee_id || null, status || 'todo', priority || 'medium', progress || 0, due_date || null); + res.status(201).json({ id: result.lastInsertRowid }); +}); + +router.post('/projects/tasks/:taskId/status', (req, res) => { + const db = getDb(); + const { status } = req.body; + const progress = status === 'done' ? 100 : status === 'in_progress' ? 50 : 0; + db.prepare('UPDATE project_tasks SET status = ?, progress = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?') + .run(status, progress, req.params.taskId); + res.json({ ok: true }); +}); + +router.post('/projects/tasks/:taskId/progress', (req, res) => { + const db = getDb(); + db.prepare('UPDATE project_tasks SET progress = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?') + .run(req.body.progress, req.params.taskId); + res.json({ ok: true }); +}); + +router.delete('/projects/tasks/:taskId', (req, res) => { + const db = getDb(); + db.prepare('DELETE FROM project_tasks WHERE id = ?').run(req.params.taskId); + res.json({ ok: true }); +}); + +// ============ NOTIFICATIONS ============ + +router.get('/notifications', (req, res) => { + const db = getDb(); + const notifications = db.prepare('SELECT * FROM notifications WHERE user_id = ? OR user_id IS NULL ORDER BY created_at DESC LIMIT 100').all(req.session.userId); + const unread = notifications.filter(n => !n.read).length; + res.json({ notifications, unread }); +}); + +router.post('/notifications/:id/read', (req, res) => { + const db = getDb(); + db.prepare('UPDATE notifications SET read=1 WHERE id=? AND (user_id=? OR user_id IS NULL)').run(req.params.id, req.session.userId); + res.json({ ok: true }); +}); + +router.post('/notifications/read-all', (req, res) => { + const db = getDb(); + db.prepare('UPDATE notifications SET read=1 WHERE user_id=? AND read=0').run(req.session.userId); + res.json({ ok: true }); +}); + +// ============ SEARCH ============ + +router.get('/search', (req, res) => { + const db = getDb(); + const q = (req.query.q || '').trim(); + if (!q) return res.json({ clients: [], engagements: [], tasks: [], diagrams: [], notes: [] }); + const like = `%${q}%`; + + const clients = db.prepare('SELECT id, name, industry, status FROM clients WHERE name LIKE ? OR industry LIKE ? OR contact_name LIKE ? LIMIT 10').all(like, like, like); + + const engagements = db.prepare('SELECT e.id, e.title, e.type, e.status, c.name as client_name FROM engagements e JOIN clients c ON c.id = e.client_id WHERE e.title LIKE ? OR e.description LIKE ? LIMIT 10').all(like, like); + + const tasks = db.prepare('SELECT t.id, t.title, t.status, t.priority, e.title as engagement_title, c.name as client_name FROM tasks t JOIN engagements e ON e.id = t.engagement_id JOIN clients c ON c.id = e.client_id WHERE t.title LIKE ? LIMIT 10').all(like); + + const diagrams = db.prepare('SELECT d.id, d.name, d.diagram_type, c.name as client_name FROM data_diagrams d JOIN clients c ON c.id = d.client_id WHERE d.name LIKE ? LIMIT 5').all(like); + + const notes = db.prepare('SELECT n.id, n.content, n.created_at, c.name as client_name, c.id as client_id FROM client_notes n JOIN clients c ON c.id = n.client_id WHERE n.content LIKE ? LIMIT 5').all(like); + + res.json({ clients, engagements, tasks, diagrams, notes }); +}); + +// ============ SETTINGS ============ + +const ALLOWED_SETTING_KEYS = ['brand_name', 'language', 'theme', 'default_page', 'auto_refresh', 'items_per_page', 'showcase_url', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'slack_webhook']; + +router.get('/settings', (req, res) => { + const settings = getSettings(); + const masked = {}; + for (const [k, v] of Object.entries(settings)) { + masked[k] = /pass|secret|key/i.test(k) ? (v ? '••••••••' : '') : v; + } + res.json(masked); +}); + +router.post('/settings', (req, res) => { + const body = req.body || {}; + for (const key of ALLOWED_SETTING_KEYS) { + if (!(key in body)) continue; + const value = body[key]; + if (value === '••••••••') continue; // gemaskeerde waarde niet opslaan + updateSetting(key, value); + } + res.json({ ok: true }); +}); + +// ============ USERS (admin-only) ============ + +router.get('/users', requireAdmin, (req, res) => { + const db = getDb(); + res.json(db.prepare('SELECT id, username, email, role, created_at FROM users ORDER BY username').all()); +}); + +router.post('/users', requireAdmin, (req, res) => { + const db = getDb(); + const { username, password, email, role } = req.body; + if (!username || !password) return res.status(400).json({ error: 'Gebruikersnaam en wachtwoord zijn verplicht' }); + const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username); + if (existing) return res.status(400).json({ error: 'Gebruikersnaam bestaat al' }); + const hash = bcrypt.hashSync(password, 10); + const result = db.prepare('INSERT INTO users (username, password_hash, email, role) VALUES (?, ?, ?, ?)').run(username, hash, email || '', role || 'viewer'); + db.prepare('INSERT INTO user_settings (user_id) VALUES (?)').run(result.lastInsertRowid); + logAudit(req.session.userId, req.session.username, 'user_created', 'user', result.lastInsertRowid, `Gebruiker ${username} aangemaakt via SPA`); + res.status(201).json({ id: result.lastInsertRowid }); +}); + +router.put('/users/:id', requireAdmin, (req, res) => { + const db = getDb(); + const { email, role, password } = req.body; + const editUser = db.prepare('SELECT id, username, email, role FROM users WHERE id = ?').get(req.params.id); + if (!editUser) return res.status(404).json({ error: 'Gebruiker niet gevonden' }); + if (password && password.trim()) { + const hash = bcrypt.hashSync(password, 10); + db.prepare('UPDATE users SET password_hash=?, email=?, role=? WHERE id=?').run(hash, email || '', role, req.params.id); + } else { + db.prepare('UPDATE users SET email=?, role=? WHERE id=?').run(email || '', role, req.params.id); + } + logAudit(req.session.userId, req.session.username, 'user_updated', 'user', req.params.id, `Gebruiker ${editUser.username} bijgewerkt via SPA`); + res.json({ ok: true }); +}); + +router.delete('/users/:id', requireAdmin, (req, res) => { + const db = getDb(); + if (Number(req.params.id) === Number(req.session.userId)) return res.status(400).json({ error: 'Je kunt jezelf niet verwijderen' }); + db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id); + res.json({ ok: true }); +}); + +// ============ AUDIT ============ + +router.get('/audit', (req, res) => { + const db = getDb(); + const page = parseInt(req.query.page) || 1; + const limit = 50; + const offset = (page - 1) * limit; + const total = db.prepare('SELECT COUNT(*) as cnt FROM audit_log').get().cnt; + const entries = db.prepare('SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ? OFFSET ?').all(limit, offset); + res.json({ entries, page, totalPages: Math.ceil(total / limit), total }); +}); + +// ============ BACKUP (admin-only) ============ + +router.get('/backup', requireAdmin, (req, res) => { + res.json(listBackups()); +}); + +router.post('/backup/create', requireAdmin, (req, res) => { + try { + const backup = createBackup(); + logAudit(req.session.userId, req.session.username, 'backup_create', 'system', 0, backup.filename); + res.status(201).json({ filename: backup.filename }); + } catch (e) { + res.status(500).json({ error: 'Backup mislukt: ' + e.message }); + } +}); + +router.post('/backup/delete/:filename', requireAdmin, (req, res) => { + if (!deleteBackup(req.params.filename)) return res.status(404).json({ error: 'Backup niet gevonden' }); + logAudit(req.session.userId, req.session.username, 'backup_delete', 'system', 0, req.params.filename); + res.json({ ok: true }); +}); + +// ============ MARKET ============ + +router.get('/market/datacenters', (req, res) => { + const db = getDb(); + const { search, vendor, region, type, sort } = req.query; + let sql = 'SELECT * FROM datacenters WHERE 1=1'; + const params = []; + if (search) { sql += ' AND (name LIKE ? OR operator LIKE ? OR city LIKE ? OR network_vendors LIKE ? OR server_vendors LIKE ? OR storage_vendors LIKE ?)'; const s = '%'+search+'%'; params.push(s,s,s,s,s,s); } + if (vendor) { sql += ' AND (network_vendors LIKE ? OR server_vendors LIKE ? OR storage_vendors LIKE ?)'; const v = '%'+vendor+'%'; params.push(v,v,v); } + if (region) { sql += ' AND region LIKE ?'; params.push('%'+region+'%'); } + if (type) { sql += ' AND type = ?'; params.push(type); } + sql += ' ORDER BY ' + (sort === 'name' ? 'name' : 'power_mw DESC'); + res.json(db.prepare(sql).all(...params)); +}); + +router.get('/market/companies', (req, res) => { + const db = getDb(); + const { search, industry, data_warehouse, city, sort } = req.query; + let sql = 'SELECT * FROM tech_companies WHERE 1=1'; + const params = []; + if (search) { sql += ' AND (name LIKE ? OR industry LIKE ? OR city LIKE ? OR data_warehouse LIKE ? OR bi_tools LIKE ? OR cloud_provider LIKE ? OR ai_ml LIKE ? OR database_vendor LIKE ?)'; const s = '%'+search+'%'; params.push(s,s,s,s,s,s,s,s); } + if (industry) { sql += ' AND industry LIKE ?'; params.push('%'+industry+'%'); } + if (data_warehouse) { sql += ' AND data_warehouse LIKE ?'; params.push('%'+data_warehouse+'%'); } + if (city) { sql += ' AND city LIKE ?'; params.push('%'+city+'%'); } + sql += ' ORDER BY ' + (sort === 'name' ? 'name' : sort === 'employees' ? 'employees DESC' : 'lead_score DESC'); + res.json(db.prepare(sql).all(...params)); +}); + +router.get('/market/filters', (req, res) => { + const db = getDb(); + const allDcs = db.prepare('SELECT * FROM datacenters').all(); + const allCompanies = db.prepare('SELECT * FROM tech_companies').all(); + res.json({ + regions: [...new Set(allDcs.map(d => d.region).filter(Boolean))].sort(), + types: [...new Set(allDcs.map(d => d.type).filter(Boolean))].sort(), + vendors: [...new Set(allDcs.flatMap(d => ((d.network_vendors||'')+','+(d.server_vendors||'')+','+(d.storage_vendors||'')).split(',').map(s=>s.trim()).filter(Boolean)))].sort(), + industries: [...new Set(allCompanies.map(c => c.industry).filter(Boolean))].sort(), + cities: [...new Set(allCompanies.map(c => c.city).filter(Boolean))].sort(), + data_warehouses: [...new Set(allCompanies.flatMap(c => (c.data_warehouse||'').split(',').map(s=>s.trim()).filter(Boolean)))].sort() + }); +}); + +// ============ AI ============ + +function maskApiKey(key) { + if (!key) return ''; + return '...' + key.slice(-4); +} + +router.get('/ai/providers', (req, res) => { + const providers = getProviders().map(p => ({ + ...p, + api_key: maskApiKey(getProvider(p.id)?.api_key) + })); + res.json(providers); +}); + +router.post('/ai/providers', (req, res) => { + const { id, name, provider_type, api_key, base_url, default_model, is_active, is_default } = req.body; + const preset = PROVIDER_PRESETS[provider_type] || PROVIDER_PRESETS.custom; + let key = api_key || ''; + if (id && (!key || key.includes('...'))) { + const existing = getProvider(id); + key = existing ? (existing.api_key || '') : ''; + } + const savedId = saveProvider({ + id: id ? parseInt(id, 10) : null, + name: name || preset.label, + provider_type: provider_type || 'openrouter', + api_key: key, + base_url: base_url || preset.base_url, + default_model: default_model || preset.models[0] || '', + is_active: is_active === true || is_active === 1 || is_active === '1', + is_default: is_default === true || is_default === 1 || is_default === '1' + }); + logAudit(req.session.userId, req.session.username, 'ai_provider_saved', 'ai_provider', savedId, name || preset.label); + if (id) return res.json({ ok: true, id: savedId }); + res.status(201).json({ id: savedId }); +}); + +router.delete('/ai/providers/:id', (req, res) => { + deleteProvider(req.params.id); + logAudit(req.session.userId, req.session.username, 'ai_provider_deleted', 'ai_provider', req.params.id, ''); + res.json({ ok: true }); +}); + +router.post('/ai/chat', async (req, res) => { + try { + const { message, client_id, provider_id } = req.body; + if (!message || !message.trim()) return res.status(400).json({ error: 'Bericht is verplicht' }); + const result = await chatCompletion([{ role: 'user', content: message }], { + providerId: provider_id ? parseInt(provider_id, 10) : null + }); + const content = result.choices?.[0]?.message?.content || ''; + if (client_id) { + getDb().prepare('INSERT INTO client_notes (client_id, content, created_by) VALUES (?, ?, ?)') + .run(client_id, `[AI] ${content.slice(0, 2000)}`, req.session.username || 'ai'); + } + res.json({ content, model: result.model }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +// ============ EMAIL ============ + +function getActiveAccount(userId) { + const db = getDb(); + return db.prepare('SELECT * FROM email_accounts WHERE user_id = ? AND active = 1 LIMIT 1').get(userId); +} + +router.get('/email/messages', (req, res) => { + const db = getDb(); + const messages = db.prepare(` + SELECT m.*, a.email as account_email + FROM email_messages m + JOIN email_accounts a ON a.id = m.account_id + WHERE a.user_id = ? + ORDER BY m.date DESC LIMIT 50 + `).all(req.session.userId); + res.json(messages); +}); + +router.get('/email/messages/:id', (req, res) => { + const db = getDb(); + const msg = db.prepare(` + SELECT m.*, a.email as account_email + FROM email_messages m + JOIN email_accounts a ON a.id = m.account_id + WHERE m.id = ? AND a.user_id = ? + `).get(req.params.id, req.session.userId); + if (!msg) return res.status(404).json({ error: 'Bericht niet gevonden' }); + db.prepare('UPDATE email_messages SET seen = 1 WHERE id = ?').run(req.params.id); + msg.seen = 1; + res.json(msg); +}); + +router.post('/email/sync', async (req, res) => { + const account = getActiveAccount(req.session.userId); + if (!account) return res.status(400).json({ error: 'Geen actief email account geconfigureerd' }); + try { + await emailService.fetchAndStoreMessages(account.id, 'INBOX', 50); + logAudit(req.session.userId, req.session.username, 'email_sync', 'email', account.id, 'Email inbox gesynchroniseerd via SPA'); + res.json({ ok: true }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +router.post('/email/send', async (req, res) => { + const account = getActiveAccount(req.session.userId); + if (!account) return res.status(400).json({ error: 'Geen actief email account geconfigureerd' }); + const { to, cc, bcc, subject, text, html } = req.body; + if (!to || !subject) return res.status(400).json({ error: 'Ontvanger en onderwerp zijn verplicht' }); + try { + await emailService.sendEmail(account.id, to, subject, text, html, cc, bcc); + logAudit(req.session.userId, req.session.username, 'email_sent', 'email', account.id, `Email naar ${to}: ${subject}`); + res.json({ ok: true }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +router.get('/email/accounts', (req, res) => { + const db = getDb(); + const accounts = db.prepare('SELECT id, email, name, imap_host, imap_port, imap_secure, imap_user, smtp_host, smtp_port, smtp_secure, smtp_user, active, created_at FROM email_accounts WHERE user_id = ?').all(req.session.userId); + res.json(accounts); +}); + +router.post('/email/accounts', (req, res) => { + const db = getDb(); + const { id, email, name, imap_host, imap_port, imap_secure, imap_user, imap_pass, smtp_host, smtp_port, smtp_secure, smtp_user, smtp_pass } = req.body; + if (!email || !imap_host || !imap_user) return res.status(400).json({ error: 'Email, IMAP host en gebruiker zijn verplicht' }); + const imapSecure = (imap_secure === true || imap_secure === 1 || imap_secure === '1') ? 1 : 0; + const smtpSecure = (smtp_secure === true || smtp_secure === 1 || smtp_secure === '1') ? 1 : 0; + if (id) { + const existing = db.prepare('SELECT * FROM email_accounts WHERE id = ? AND user_id = ?').get(id, req.session.userId); + if (!existing) return res.status(404).json({ error: 'Account niet gevonden' }); + db.prepare(`UPDATE email_accounts SET email=?, name=?, imap_host=?, imap_port=?, imap_secure=?, imap_user=?, imap_pass=?, smtp_host=?, smtp_port=?, smtp_secure=?, smtp_user=?, smtp_pass=? WHERE id=? AND user_id=?`) + .run(email, name || '', imap_host, parseInt(imap_port) || 993, imapSecure, imap_user, + imap_pass || existing.imap_pass, smtp_host || imap_host, parseInt(smtp_port) || 587, smtpSecure, + smtp_user || imap_user, smtp_pass || existing.smtp_pass, id, req.session.userId); + return res.json({ ok: true, id: Number(id) }); + } + if (!imap_pass) return res.status(400).json({ error: 'IMAP wachtwoord is verplicht' }); + const result = db.prepare(`INSERT INTO email_accounts (user_id, email, name, imap_host, imap_port, imap_secure, imap_user, imap_pass, smtp_host, smtp_port, smtp_secure, smtp_user, smtp_pass) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`).run( + req.session.userId, email, name || '', imap_host, parseInt(imap_port) || 993, imapSecure, + imap_user, imap_pass, smtp_host || imap_host, parseInt(smtp_port) || 587, smtpSecure, + smtp_user || imap_user, smtp_pass || imap_pass + ); + res.status(201).json({ id: result.lastInsertRowid }); +}); + +module.exports = router; diff --git a/server.js b/server.js index 43fabfa..aba1416 100644 --- a/server.js +++ b/server.js @@ -50,6 +50,10 @@ app.use((req, res, next) => { const publicPaths = ['/login', '/auth/login', '/css/dynamic.css', '/js/theme.js', '/health']; const publicPrefixes = ['/wachtwoord-vergeten', '/wachtwoord-reset/', '/css/', '/js/', '/health']; if (!publicPaths.includes(req.path) && !publicPrefixes.some(p => req.path.startsWith(p)) && !req.session.userId) { + // API-aanroepen krijgen 401 JSON i.p.v. een HTML-redirect (o.a. voor de React SPA) + if (req.path.startsWith('/api/')) { + return res.status(401).json({ error: 'Niet ingelogd' }); + } return res.redirect('/login'); } res.locals.user = req.session.userId ? { username: req.session.username } : null; @@ -172,6 +176,20 @@ app.use('/reports', require('./routes/reports')); app.use('/integrations', require('./routes/integrations')); app.use('/ai', require('./routes/ai')); app.use('/api/v1', require('./routes/api')); +app.use('/api/spa', require('./routes/spa')); + +// ---- React SPA (production build in public/spa) ---- +const spaDir = path.join(__dirname, 'public', 'spa'); +if (require('fs').existsSync(spaDir)) { + app.use('/app', express.static(spaDir)); + // History-API fallback: alle niet-bestand paden onder /app -> index.html + app.get('/app/*', (req, res) => { + res.sendFile(path.join(spaDir, 'index.html')); + }); + app.get('/app', (req, res) => { + res.sendFile(path.join(spaDir, 'index.html')); + }); +} app.get('/health', async (req, res) => { try {