const express = require('express'); const { Pool } = require('pg'); const cors = require('cors'); const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); require('dotenv').config(); const app = express(); const PORT = process.env.PORT || 80; // PostgreSQL connection const pool = new Pool({ connectionString: process.env.DATABASE_URL, ssl: process.env.DATABASE_URL?.includes('amazonaws.com') ? { rejectUnauthorized: false } : false }); // Middleware app.use(cors({ origin: true, // Allow all origins in development credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'], exposedHeaders: ['Content-Length', 'Content-Type'], maxAge: 86400 })); app.use(express.json()); // Handle preflight requests app.options('*', cors()); // Health check route app.get('/', (req, res) => { res.json({ status: 'ok', service: 'Filamenteka API' }); }); // JWT middleware const authenticateToken = (req, res, next) => { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; if (!token) { return res.status(401).json({ error: 'Unauthorized' }); } jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key', (err, user) => { if (err) { console.error('JWT verification error:', err); return res.status(403).json({ error: 'Invalid token' }); } req.user = user; next(); }); }; // Auth endpoints app.post('/api/login', async (req, res) => { const { username, password } = req.body; // For now, simple hardcoded admin check if (username === 'admin' && password === process.env.ADMIN_PASSWORD) { const token = jwt.sign({ username }, process.env.JWT_SECRET || 'your-secret-key', { expiresIn: '24h' }); res.json({ token }); } else { res.status(401).json({ error: 'Invalid credentials' }); } }); // Colors endpoints app.get('/api/colors', async (req, res) => { try { const result = await pool.query('SELECT * FROM colors ORDER BY name'); res.json(result.rows); } catch (error) { console.error('Error fetching colors:', error); res.status(500).json({ error: 'Failed to fetch colors' }); } }); app.post('/api/colors', authenticateToken, async (req, res) => { const { name, hex, cena_refill, cena_spulna } = req.body; try { const result = await pool.query( 'INSERT INTO colors (name, hex, cena_refill, cena_spulna) VALUES ($1, $2, $3, $4) RETURNING *', [name, hex, cena_refill || 3499, cena_spulna || 3999] ); res.json(result.rows[0]); } catch (error) { console.error('Error creating color:', error); res.status(500).json({ error: 'Failed to create color' }); } }); app.put('/api/colors/:id', authenticateToken, async (req, res) => { const { id } = req.params; const { name, hex, cena_refill, cena_spulna } = req.body; try { const result = await pool.query( 'UPDATE colors SET name = $1, hex = $2, cena_refill = $3, cena_spulna = $4, updated_at = CURRENT_TIMESTAMP WHERE id = $5 RETURNING *', [name, hex, cena_refill || 3499, cena_spulna || 3999, id] ); res.json(result.rows[0]); } catch (error) { console.error('Error updating color:', error); res.status(500).json({ error: 'Failed to update color' }); } }); app.delete('/api/colors/:id', authenticateToken, async (req, res) => { const { id } = req.params; try { await pool.query('DELETE FROM colors WHERE id = $1', [id]); res.json({ success: true }); } catch (error) { console.error('Error deleting color:', error); res.status(500).json({ error: 'Failed to delete color' }); } }); // Filaments endpoints (PUBLIC - no auth required) app.get('/api/filaments', async (req, res) => { try { const result = await pool.query('SELECT * FROM filaments ORDER BY created_at DESC'); res.json(result.rows); } catch (error) { console.error('Error fetching filaments:', error); res.status(500).json({ error: 'Failed to fetch filaments' }); } }); app.post('/api/filaments', authenticateToken, async (req, res) => { const { tip, finish, boja, boja_hex, refill, spulna, cena } = req.body; try { // Ensure refill and spulna are numbers const refillNum = parseInt(refill) || 0; const spulnaNum = parseInt(spulna) || 0; const kolicina = refillNum + spulnaNum; const result = await pool.query( `INSERT INTO filaments (tip, finish, boja, boja_hex, refill, spulna, kolicina, cena) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, [tip, finish, boja, boja_hex, refillNum, spulnaNum, kolicina, cena] ); res.json(result.rows[0]); } catch (error) { console.error('Error creating filament:', error); res.status(500).json({ error: 'Failed to create filament' }); } }); app.put('/api/filaments/:id', authenticateToken, async (req, res) => { const { id } = req.params; const { tip, finish, boja, boja_hex, refill, spulna, cena, sale_percentage, sale_active, sale_start_date, sale_end_date } = req.body; try { // Ensure refill and spulna are numbers const refillNum = parseInt(refill) || 0; const spulnaNum = parseInt(spulna) || 0; const kolicina = refillNum + spulnaNum; // Check if sale fields are provided in the request const hasSaleFields = 'sale_percentage' in req.body || 'sale_active' in req.body || 'sale_start_date' in req.body || 'sale_end_date' in req.body; let result; if (hasSaleFields) { // Update with sale fields if they are provided result = await pool.query( `UPDATE filaments SET tip = $1, finish = $2, boja = $3, boja_hex = $4, refill = $5, spulna = $6, kolicina = $7, cena = $8, sale_percentage = $9, sale_active = $10, sale_start_date = $11, sale_end_date = $12, updated_at = CURRENT_TIMESTAMP WHERE id = $13 RETURNING *`, [tip, finish, boja, boja_hex, refillNum, spulnaNum, kolicina, cena, sale_percentage || 0, sale_active || false, sale_start_date, sale_end_date, id] ); } else { // Update without touching sale fields if they are not provided result = await pool.query( `UPDATE filaments SET tip = $1, finish = $2, boja = $3, boja_hex = $4, refill = $5, spulna = $6, kolicina = $7, cena = $8, updated_at = CURRENT_TIMESTAMP WHERE id = $9 RETURNING *`, [tip, finish, boja, boja_hex, refillNum, spulnaNum, kolicina, cena, id] ); } res.json(result.rows[0]); } catch (error) { console.error('Error updating filament:', error); res.status(500).json({ error: 'Failed to update filament' }); } }); app.delete('/api/filaments/:id', authenticateToken, async (req, res) => { const { id } = req.params; try { await pool.query('DELETE FROM filaments WHERE id = $1', [id]); res.json({ success: true }); } catch (error) { console.error('Error deleting filament:', error); res.status(500).json({ error: 'Failed to delete filament' }); } }); // Bulk sale update endpoint app.post('/api/filaments/sale/bulk', authenticateToken, async (req, res) => { const { filamentIds, salePercentage, saleStartDate, saleEndDate, enableSale } = req.body; try { let query; let params; if (filamentIds && filamentIds.length > 0) { // Update specific filaments query = ` UPDATE filaments SET sale_percentage = $1, sale_active = $2, sale_start_date = $3, sale_end_date = $4, updated_at = CURRENT_TIMESTAMP WHERE id = ANY($5) RETURNING *`; params = [salePercentage || 0, enableSale || false, saleStartDate, saleEndDate, filamentIds]; } else { // Update all filaments query = ` UPDATE filaments SET sale_percentage = $1, sale_active = $2, sale_start_date = $3, sale_end_date = $4, updated_at = CURRENT_TIMESTAMP RETURNING *`; params = [salePercentage || 0, enableSale || false, saleStartDate, saleEndDate]; } const result = await pool.query(query, params); res.json({ success: true, updatedCount: result.rowCount, updatedFilaments: result.rows }); } catch (error) { console.error('Error updating sale:', error); res.status(500).json({ error: 'Failed to update sale' }); } }); // Color request endpoints // Get all color requests (admin only) app.get('/api/color-requests', authenticateToken, async (req, res) => { try { const result = await pool.query( 'SELECT * FROM color_requests ORDER BY created_at DESC' ); res.json(result.rows); } catch (error) { console.error('Error fetching color requests:', error); res.status(500).json({ error: 'Failed to fetch color requests' }); } }); // Submit a new color request (public) app.post('/api/color-requests', async (req, res) => { try { const { color_name, material_type, finish_type, user_email, user_phone, user_name, description, reference_url } = req.body; // Validate required fields if (!color_name || !material_type || !user_email || !user_phone) { return res.status(400).json({ error: 'Color name, material type, email, and phone are required' }); } // Check if similar request already exists const existingRequest = await pool.query( `SELECT id, request_count FROM color_requests WHERE LOWER(color_name) = LOWER($1) AND material_type = $2 AND (finish_type = $3 OR (finish_type IS NULL AND $3 IS NULL)) AND status = 'pending'`, [color_name, material_type, finish_type] ); if (existingRequest.rows.length > 0) { // Increment request count for existing request const result = await pool.query( `UPDATE color_requests SET request_count = request_count + 1, updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING *`, [existingRequest.rows[0].id] ); res.json({ message: 'Your request has been added to an existing request for this color', request: result.rows[0] }); } else { // Create new request const result = await pool.query( `INSERT INTO color_requests (color_name, material_type, finish_type, user_email, user_phone, user_name, description, reference_url) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, [color_name, material_type, finish_type, user_email, user_phone, user_name, description, reference_url] ); res.json({ message: 'Color request submitted successfully', request: result.rows[0] }); } } catch (error) { console.error('Error creating color request:', error); res.status(500).json({ error: 'Failed to submit color request' }); } }); // Update color request status (admin only) app.put('/api/color-requests/:id', authenticateToken, async (req, res) => { try { const { id } = req.params; const { status, admin_notes } = req.body; const result = await pool.query( `UPDATE color_requests SET status = $1, admin_notes = $2, processed_at = CURRENT_TIMESTAMP, processed_by = $3, updated_at = CURRENT_TIMESTAMP WHERE id = $4 RETURNING *`, [status, admin_notes, req.user.username, id] ); if (result.rows.length === 0) { return res.status(404).json({ error: 'Color request not found' }); } res.json(result.rows[0]); } catch (error) { console.error('Error updating color request:', error); res.status(500).json({ error: 'Failed to update color request' }); } }); // Delete color request (admin only) app.delete('/api/color-requests/:id', authenticateToken, async (req, res) => { try { const { id } = req.params; const result = await pool.query( 'DELETE FROM color_requests WHERE id = $1 RETURNING *', [id] ); if (result.rows.length === 0) { return res.status(404).json({ error: 'Color request not found' }); } res.json({ message: 'Color request deleted successfully' }); } catch (error) { console.error('Error deleting color request:', error); res.status(500).json({ error: 'Failed to delete color request' }); } }); // Slug generation helper const generateSlug = (name) => { const base = name .toLowerCase() .replace(/[^a-z0-9\s-]/g, '') .replace(/\s+/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, ''); const suffix = Math.random().toString(36).substring(2, 8); return `${base}-${suffix}`; }; // Products endpoints // List/filter products (PUBLIC) app.get('/api/products', async (req, res) => { try { const { category, condition, printer_model, in_stock, search } = req.query; const conditions = []; const params = []; let paramIndex = 1; if (category) { conditions.push(`p.category = $${paramIndex++}`); params.push(category); } if (condition) { conditions.push(`p.condition = $${paramIndex++}`); params.push(condition); } if (printer_model) { conditions.push(`EXISTS ( SELECT 1 FROM product_printer_compatibility ppc JOIN printer_models pm ON pm.id = ppc.printer_model_id WHERE ppc.product_id = p.id AND pm.name = $${paramIndex++} )`); params.push(printer_model); } if (in_stock === 'true') { conditions.push(`p.quantity > 0`); } else if (in_stock === 'false') { conditions.push(`p.quantity = 0`); } if (search) { conditions.push(`(p.name ILIKE $${paramIndex} OR p.description ILIKE $${paramIndex})`); params.push(`%${search}%`); paramIndex++; } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const result = await pool.query( `SELECT p.*, COALESCE( json_agg( json_build_object('id', pm.id, 'name', pm.name, 'series', pm.series) ) FILTER (WHERE pm.id IS NOT NULL), '[]' ) AS compatible_printers FROM products p LEFT JOIN product_printer_compatibility ppc ON ppc.product_id = p.id LEFT JOIN printer_models pm ON pm.id = ppc.printer_model_id ${whereClause} GROUP BY p.id ORDER BY p.created_at DESC`, params ); res.json(result.rows); } catch (error) { console.error('Error fetching products:', error); res.status(500).json({ error: 'Failed to fetch products' }); } }); // Get single product (PUBLIC) app.get('/api/products/:id', async (req, res) => { const { id } = req.params; try { const result = await pool.query( `SELECT p.*, COALESCE( json_agg( json_build_object('id', pm.id, 'name', pm.name, 'series', pm.series) ) FILTER (WHERE pm.id IS NOT NULL), '[]' ) AS compatible_printers FROM products p LEFT JOIN product_printer_compatibility ppc ON ppc.product_id = p.id LEFT JOIN printer_models pm ON pm.id = ppc.printer_model_id WHERE p.id = $1 GROUP BY p.id`, [id] ); if (result.rows.length === 0) { return res.status(404).json({ error: 'Product not found' }); } res.json(result.rows[0]); } catch (error) { console.error('Error fetching product:', error); res.status(500).json({ error: 'Failed to fetch product' }); } }); // Create product (auth required) app.post('/api/products', authenticateToken, async (req, res) => { const { name, description, category, condition, price, quantity, image_url, printer_model_ids } = req.body; try { const slug = generateSlug(name); const result = await pool.query( `INSERT INTO products (name, slug, description, category, condition, price, quantity, image_url) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, [name, slug, description, category, condition || 'new', price, parseInt(quantity) || 0, image_url] ); const product = result.rows[0]; // Insert printer compatibility entries if provided if (printer_model_ids && printer_model_ids.length > 0) { const compatValues = printer_model_ids .map((_, i) => `($1, $${i + 2})`) .join(', '); await pool.query( `INSERT INTO product_printer_compatibility (product_id, printer_model_id) VALUES ${compatValues}`, [product.id, ...printer_model_ids] ); } res.json(product); } catch (error) { console.error('Error creating product:', error); res.status(500).json({ error: 'Failed to create product' }); } }); // Update product (auth required) app.put('/api/products/:id', authenticateToken, async (req, res) => { const { id } = req.params; const { name, description, category, condition, price, quantity, image_url, sale_percentage, sale_active, sale_start_date, sale_end_date, printer_model_ids } = req.body; try { const hasSaleFields = 'sale_percentage' in req.body || 'sale_active' in req.body || 'sale_start_date' in req.body || 'sale_end_date' in req.body; let result; if (hasSaleFields) { result = await pool.query( `UPDATE products SET name = $1, description = $2, category = $3, condition = $4, price = $5, quantity = $6, image_url = $7, sale_percentage = $8, sale_active = $9, sale_start_date = $10, sale_end_date = $11, updated_at = CURRENT_TIMESTAMP WHERE id = $12 RETURNING *`, [name, description, category, condition, price, parseInt(quantity) || 0, image_url, sale_percentage || 0, sale_active || false, sale_start_date, sale_end_date, id] ); } else { result = await pool.query( `UPDATE products SET name = $1, description = $2, category = $3, condition = $4, price = $5, quantity = $6, image_url = $7, updated_at = CURRENT_TIMESTAMP WHERE id = $8 RETURNING *`, [name, description, category, condition, price, parseInt(quantity) || 0, image_url, id] ); } if (result.rows.length === 0) { return res.status(404).json({ error: 'Product not found' }); } // Update printer compatibility if provided if (printer_model_ids !== undefined) { await pool.query('DELETE FROM product_printer_compatibility WHERE product_id = $1', [id]); if (printer_model_ids && printer_model_ids.length > 0) { const compatValues = printer_model_ids .map((_, i) => `($1, $${i + 2})`) .join(', '); await pool.query( `INSERT INTO product_printer_compatibility (product_id, printer_model_id) VALUES ${compatValues}`, [id, ...printer_model_ids] ); } } res.json(result.rows[0]); } catch (error) { console.error('Error updating product:', error); res.status(500).json({ error: 'Failed to update product' }); } }); // Delete product (auth required) app.delete('/api/products/:id', authenticateToken, async (req, res) => { const { id } = req.params; try { await pool.query('DELETE FROM product_printer_compatibility WHERE product_id = $1', [id]); const result = await pool.query('DELETE FROM products WHERE id = $1 RETURNING *', [id]); if (result.rows.length === 0) { return res.status(404).json({ error: 'Product not found' }); } res.json({ success: true }); } catch (error) { console.error('Error deleting product:', error); res.status(500).json({ error: 'Failed to delete product' }); } }); // Bulk sale update for products (auth required) app.post('/api/products/sale/bulk', authenticateToken, async (req, res) => { const { productIds, salePercentage, saleStartDate, saleEndDate, enableSale } = req.body; try { let query; let params; if (productIds && productIds.length > 0) { query = ` UPDATE products SET sale_percentage = $1, sale_active = $2, sale_start_date = $3, sale_end_date = $4, updated_at = CURRENT_TIMESTAMP WHERE id = ANY($5) RETURNING *`; params = [salePercentage || 0, enableSale || false, saleStartDate, saleEndDate, productIds]; } else { query = ` UPDATE products SET sale_percentage = $1, sale_active = $2, sale_start_date = $3, sale_end_date = $4, updated_at = CURRENT_TIMESTAMP RETURNING *`; params = [salePercentage || 0, enableSale || false, saleStartDate, saleEndDate]; } const result = await pool.query(query, params); res.json({ success: true, updatedCount: result.rowCount, updatedProducts: result.rows }); } catch (error) { console.error('Error updating product sale:', error); res.status(500).json({ error: 'Failed to update product sale' }); } }); // Printer models endpoints // List all printer models (PUBLIC) app.get('/api/printer-models', async (req, res) => { try { const result = await pool.query('SELECT * FROM printer_models ORDER BY series, name'); res.json(result.rows); } catch (error) { console.error('Error fetching printer models:', error); res.status(500).json({ error: 'Failed to fetch printer models' }); } }); // Analytics endpoints // Aggregated inventory stats (auth required) app.get('/api/analytics/inventory', authenticateToken, async (req, res) => { try { const filamentStats = await pool.query(` SELECT COUNT(*) AS total_skus, COALESCE(SUM(kolicina), 0) AS total_units, COALESCE(SUM(refill), 0) AS total_refills, COALESCE(SUM(spulna), 0) AS total_spools, COUNT(*) FILTER (WHERE kolicina = 0) AS out_of_stock_skus, COALESCE(SUM(kolicina * cena), 0) AS total_inventory_value FROM filaments `); const productStats = await pool.query(` SELECT COUNT(*) AS total_skus, COALESCE(SUM(quantity), 0) AS total_units, COUNT(*) FILTER (WHERE quantity = 0) AS out_of_stock_skus, COALESCE(SUM(quantity * price), 0) AS total_inventory_value, COUNT(*) FILTER (WHERE category = 'printer') AS printers, COUNT(*) FILTER (WHERE category = 'build_plate') AS build_plates, COUNT(*) FILTER (WHERE category = 'nozzle') AS nozzles, COUNT(*) FILTER (WHERE category = 'spare_part') AS spare_parts, COUNT(*) FILTER (WHERE category = 'accessory') AS accessories FROM products `); const filament = filamentStats.rows[0]; const product = productStats.rows[0]; res.json({ filaments: { total_skus: parseInt(filament.total_skus), total_units: parseInt(filament.total_units), total_refills: parseInt(filament.total_refills), total_spools: parseInt(filament.total_spools), out_of_stock_skus: parseInt(filament.out_of_stock_skus), total_inventory_value: parseInt(filament.total_inventory_value) }, products: { total_skus: parseInt(product.total_skus), total_units: parseInt(product.total_units), out_of_stock_skus: parseInt(product.out_of_stock_skus), total_inventory_value: parseInt(product.total_inventory_value), by_category: { printers: parseInt(product.printers), build_plates: parseInt(product.build_plates), nozzles: parseInt(product.nozzles), spare_parts: parseInt(product.spare_parts), accessories: parseInt(product.accessories) } }, combined: { total_skus: parseInt(filament.total_skus) + parseInt(product.total_skus), total_inventory_value: parseInt(filament.total_inventory_value) + parseInt(product.total_inventory_value), out_of_stock_skus: parseInt(filament.out_of_stock_skus) + parseInt(product.out_of_stock_skus) } }); } catch (error) { console.error('Error fetching inventory analytics:', error); res.status(500).json({ error: 'Failed to fetch inventory analytics' }); } }); // Active sales overview (auth required) app.get('/api/analytics/sales', authenticateToken, async (req, res) => { try { const filamentSales = await pool.query(` SELECT id, tip, finish, boja, cena, sale_percentage, sale_active, sale_start_date, sale_end_date, ROUND(cena * (1 - sale_percentage / 100.0)) AS sale_price FROM filaments WHERE sale_active = true ORDER BY sale_percentage DESC `); const productSales = await pool.query(` SELECT id, name, category, price, sale_percentage, sale_active, sale_start_date, sale_end_date, ROUND(price * (1 - sale_percentage / 100.0)) AS sale_price FROM products WHERE sale_active = true ORDER BY sale_percentage DESC `); res.json({ filaments: { count: filamentSales.rowCount, items: filamentSales.rows }, products: { count: productSales.rowCount, items: productSales.rows }, total_active_sales: filamentSales.rowCount + productSales.rowCount }); } catch (error) { console.error('Error fetching sales analytics:', error); res.status(500).json({ error: 'Failed to fetch sales analytics' }); } }); app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); });