const express = require('express'); const cookieParser = require('cookie-parser'); const https = require('https'); const http = require('http'); const app = express(); app.use(express.json()); app.use(cookieParser()); const PORT = process.env.PORT || 3000; const PASSWORD = process.env.STOCK_PASSWORD || 'delvarre'; const NOCODB_URL = process.env.NOCODB_URL || 'https://nocodb.hub.delvarre.net'; const NOCODB_TOKEN = process.env.NOCODB_TOKEN || ''; const TABLE_CATALOGUE = process.env.TABLE_CATALOGUE || 'maybayq2akegbs1'; const TABLE_MOUVEMENTS = process.env.TABLE_MOUVEMENTS || 'm88endvsfj5am9n'; const SESSION_SECRET = process.env.SESSION_SECRET || 'stock-dc-2026'; function makeToken(password) { const crypto = require('crypto'); return crypto.createHmac('sha256', SESSION_SECRET).update(password).digest('hex'); } const VALID_TOKEN = makeToken(PASSWORD); function requireAuth(req, res, next) { if (req.cookies && req.cookies.stock_auth === VALID_TOKEN) return next(); if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'Non autorisé' }); return res.redirect('/login.html'); } // Login endpoint app.post('/auth/login', (req, res) => { const { password } = req.body; if (password === PASSWORD) { res.cookie('stock_auth', VALID_TOKEN, { httpOnly: true, sameSite: 'lax', maxAge: 30 * 24 * 3600 * 1000 }); return res.json({ ok: true }); } res.status(401).json({ error: 'Mot de passe incorrect' }); }); app.post('/auth/logout', (req, res) => { res.clearCookie('stock_auth'); res.json({ ok: true }); }); // Serve login page without auth app.get('/login.html', (req, res, next) => { if (req.cookies && req.cookies.stock_auth === VALID_TOKEN) return res.redirect('/'); next(); }); // NocoDB proxy function proxyNocoDB(method, path, body) { return new Promise((resolve, reject) => { const url = new URL(path, NOCODB_URL); const options = { hostname: url.hostname, port: url.port || 443, path: url.pathname + url.search, method, headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json', }, }; const proto = url.protocol === 'https:' ? https : http; const req = proto.request(options, (resp) => { const chunks = []; resp.on('data', (chunk) => chunks.push(chunk)); resp.on('end', () => { const data = Buffer.concat(chunks).toString('utf8'); try { resolve(JSON.parse(data)); } catch { resolve(data); } }); }); req.on('error', reject); if (body) req.write(JSON.stringify(body)); req.end(); }); } // API: Get all products app.get('/api/produits', requireAuth, async (req, res) => { try { const data = await proxyNocoDB('GET', `/api/v2/tables/${TABLE_CATALOGUE}/records?limit=50&fields=Id,SKU,Nom,Stock,Image,Statut,Ordre&where=(Statut,eq,Actif)&sort=Ordre`); if (data && data.list) { data.list.sort((a, b) => (a.Ordre ?? 9999) - (b.Ordre ?? 9999)); } res.json(data); } catch (e) { res.status(500).json({ error: e.message }); } }); // API: Get movements (recent) app.get('/api/mouvements', requireAuth, async (req, res) => { try { const limit = req.query.limit || 50; const offset = req.query.offset || 0; // Don't filter fields — we need the nested linked record data let url = `/api/v2/tables/${TABLE_MOUVEMENTS}/records?limit=${limit}&offset=${offset}&sort=-Date`; const data = await proxyNocoDB('GET', url); // Extract SKU from nested linked field if (data && data.list) { for (const m of data.list) { m._sku = '—'; for (const key of Object.keys(m)) { if (key.startsWith('nc_') && Array.isArray(m[key]) && m[key].length > 0) { const linked = m[key][0]; const catalogueKey = Object.keys(linked).find(k => k === 'Catalogue produits'); if (catalogueKey && linked[catalogueKey]) { m._sku = linked[catalogueKey].SKU || linked[catalogueKey].Nom || '—'; } } } } } res.json(data); } catch (e) { res.status(500).json({ error: e.message }); } }); // API: Create movement + update stock app.post('/api/mouvement', requireAuth, async (req, res) => { try { const { productId, sku, type, quantite, source, reference } = req.body; if (!productId || !type || !quantite || quantite <= 0) { return res.status(400).json({ error: 'Données invalides' }); } // 1. Get current stock const product = await proxyNocoDB('GET', `/api/v2/tables/${TABLE_CATALOGUE}/records/${productId}?fields=Stock`); const currentStock = product.Stock || 0; const newStock = type === 'Entrée' ? currentStock + quantite : currentStock - quantite; // 2. Create movement record (link to catalogue via SKU field) const movement = await proxyNocoDB('POST', `/api/v2/tables/${TABLE_MOUVEMENTS}/records`, { Date: new Date().toISOString(), Type: type, 'Quantité': quantite, Source: source || 'Manuel', 'Référence': reference || '', SKU: [{ Id: productId }], }); // Check movement was actually created if (!movement || !movement.Id) { const errMsg = (movement && movement.msg) || 'Mouvement non créé'; return res.status(400).json({ error: errMsg }); } // 3. Update stock on product (only if movement succeeded) await proxyNocoDB('PATCH', `/api/v2/tables/${TABLE_CATALOGUE}/records`, { Id: productId, Stock: newStock, }); res.json({ ok: true, newStock, movement }); } catch (e) { res.status(500).json({ error: e.message }); } }); // API: Update movement note app.patch('/api/mouvement/:id', requireAuth, async (req, res) => { try { const id = parseInt(req.params.id); const { reference } = req.body; if (!id) return res.status(400).json({ error: 'ID invalide' }); await proxyNocoDB('PATCH', `/api/v2/tables/${TABLE_MOUVEMENTS}/records`, { Id: id, 'Référence': reference || '', }); res.json({ ok: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); // API: Bulk inventory (multiple products at once) app.post('/api/inventaire', requireAuth, async (req, res) => { try { const { items } = req.body; // [{ productId, sku, realStock }] const results = []; for (const item of items) { const product = await proxyNocoDB('GET', `/api/v2/tables/${TABLE_CATALOGUE}/records/${item.productId}?fields=Stock,SKU`); const currentStock = product.Stock || 0; const diff = item.realStock - currentStock; if (diff === 0) { results.push({ sku: item.sku, status: 'inchangé' }); continue; } // Create adjustment movement const mvt = await proxyNocoDB('POST', `/api/v2/tables/${TABLE_MOUVEMENTS}/records`, { Date: new Date().toISOString(), Type: diff > 0 ? 'Entrée' : 'Sortie', 'Quantité': Math.abs(diff), Source: 'Inventaire', 'Référence': `Inventaire du ${new Date().toLocaleDateString('fr-FR')}`, SKU: [{ Id: item.productId }], }); if (!mvt || !mvt.Id) { results.push({ sku: item.sku, status: 'erreur', msg: mvt && mvt.msg }); continue; } // Update stock (only if movement succeeded) await proxyNocoDB('PATCH', `/api/v2/tables/${TABLE_CATALOGUE}/records`, { Id: item.productId, Stock: item.realStock, }); results.push({ sku: item.sku, ancien: currentStock, nouveau: item.realStock, diff }); } res.json({ ok: true, results }); } catch (e) { res.status(500).json({ error: e.message }); } }); // Static files (protected except login) app.use('/login.html', express.static('public/login.html')); app.use('/favicon.ico', express.static('public/favicon.ico')); app.use(requireAuth, express.static('public')); app.get('/', requireAuth, (req, res) => { res.sendFile(__dirname + '/public/index.html'); }); app.listen(PORT, () => { console.log(`Gestion de Stock running on port ${PORT}`); });