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' }); } }); // ========================================== // Customer endpoints (admin-only) // ========================================== app.get('/api/customers', authenticateToken, async (req, res) => { try { const result = await pool.query('SELECT * FROM customers ORDER BY name'); res.json(result.rows); } catch (error) { console.error('Error fetching customers:', error); res.status(500).json({ error: 'Failed to fetch customers' }); } }); app.get('/api/customers/search', authenticateToken, async (req, res) => { try { const { q } = req.query; if (!q) return res.json([]); const result = await pool.query( `SELECT * FROM customers WHERE name ILIKE $1 OR phone ILIKE $1 ORDER BY name LIMIT 10`, [`%${q}%`] ); res.json(result.rows); } catch (error) { console.error('Error searching customers:', error); res.status(500).json({ error: 'Failed to search customers' }); } }); app.get('/api/customers/:id', authenticateToken, async (req, res) => { try { const { id } = req.params; const customerResult = await pool.query('SELECT * FROM customers WHERE id = $1', [id]); if (customerResult.rows.length === 0) { return res.status(404).json({ error: 'Customer not found' }); } const salesResult = await pool.query( `SELECT s.*, (SELECT COUNT(*) FROM sale_items si WHERE si.sale_id = s.id) as item_count FROM sales s WHERE s.customer_id = $1 ORDER BY s.created_at DESC`, [id] ); res.json({ ...customerResult.rows[0], sales: salesResult.rows }); } catch (error) { console.error('Error fetching customer:', error); res.status(500).json({ error: 'Failed to fetch customer' }); } }); app.post('/api/customers', authenticateToken, async (req, res) => { const { name, phone, city, notes } = req.body; if (!name) return res.status(400).json({ error: 'Name is required' }); try { const result = await pool.query( 'INSERT INTO customers (name, phone, city, notes) VALUES ($1, $2, $3, $4) RETURNING *', [name, phone || null, city || null, notes || null] ); res.json(result.rows[0]); } catch (error) { if (error.code === '23505') { return res.status(409).json({ error: 'Customer with this phone already exists' }); } console.error('Error creating customer:', error); res.status(500).json({ error: 'Failed to create customer' }); } }); app.put('/api/customers/:id', authenticateToken, async (req, res) => { const { id } = req.params; const { name, phone, city, notes } = req.body; try { const result = await pool.query( `UPDATE customers SET name = $1, phone = $2, city = $3, notes = $4, updated_at = CURRENT_TIMESTAMP WHERE id = $5 RETURNING *`, [name, phone || null, city || null, notes || null, id] ); if (result.rows.length === 0) return res.status(404).json({ error: 'Customer not found' }); res.json(result.rows[0]); } catch (error) { console.error('Error updating customer:', error); res.status(500).json({ error: 'Failed to update customer' }); } }); // ========================================== // Sale endpoints (admin-only) // ========================================== app.post('/api/sales', authenticateToken, async (req, res) => { const client = await pool.connect(); try { await client.query('BEGIN'); const { customer, items, notes } = req.body; if (!customer || !customer.name) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Customer name is required' }); } if (!items || items.length === 0) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'At least one item is required' }); } // Find-or-create customer by phone let customerId; if (customer.phone) { const existing = await client.query( 'SELECT id FROM customers WHERE phone = $1', [customer.phone] ); if (existing.rows.length > 0) { customerId = existing.rows[0].id; // Update name/city if provided await client.query( `UPDATE customers SET name = $1, city = COALESCE($2, city), notes = COALESCE($3, notes), updated_at = CURRENT_TIMESTAMP WHERE id = $4`, [customer.name, customer.city, customer.notes, customerId] ); } } if (!customerId) { const newCust = await client.query( 'INSERT INTO customers (name, phone, city, notes) VALUES ($1, $2, $3, $4) RETURNING id', [customer.name, customer.phone || null, customer.city || null, customer.notes || null] ); customerId = newCust.rows[0].id; } // Process items let totalAmount = 0; const saleItems = []; for (const item of items) { const { filament_id, item_type, quantity } = item; if (!['refill', 'spulna'].includes(item_type)) { await client.query('ROLLBACK'); return res.status(400).json({ error: `Invalid item_type: ${item_type}` }); } // Get filament with stock check const filamentResult = await client.query( 'SELECT f.*, c.cena_refill, c.cena_spulna FROM filaments f JOIN colors c ON f.boja = c.name WHERE f.id = $1 FOR UPDATE', [filament_id] ); if (filamentResult.rows.length === 0) { await client.query('ROLLBACK'); return res.status(400).json({ error: `Filament ${filament_id} not found` }); } const filament = filamentResult.rows[0]; if (filament[item_type] < quantity) { await client.query('ROLLBACK'); return res.status(400).json({ error: `Insufficient stock for ${filament.boja} ${item_type}: have ${filament[item_type]}, need ${quantity}` }); } // Determine price (apply sale discount if active) let unitPrice = item_type === 'refill' ? (filament.cena_refill || 3499) : (filament.cena_spulna || 3999); if (filament.sale_active && filament.sale_percentage > 0) { unitPrice = Math.round(unitPrice * (1 - filament.sale_percentage / 100)); } // Decrement inventory const updateField = item_type === 'refill' ? 'refill' : 'spulna'; await client.query( `UPDATE filaments SET ${updateField} = ${updateField} - $1, kolicina = kolicina - $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`, [quantity, filament_id] ); totalAmount += unitPrice * quantity; saleItems.push({ filament_id, item_type, quantity, unit_price: unitPrice }); } // Insert sale const saleResult = await client.query( 'INSERT INTO sales (customer_id, total_amount, notes) VALUES ($1, $2, $3) RETURNING *', [customerId, totalAmount, notes || null] ); const sale = saleResult.rows[0]; // Insert sale items for (const si of saleItems) { await client.query( 'INSERT INTO sale_items (sale_id, filament_id, item_type, quantity, unit_price) VALUES ($1, $2, $3, $4, $5)', [sale.id, si.filament_id, si.item_type, si.quantity, si.unit_price] ); } await client.query('COMMIT'); // Fetch full sale with items const fullSale = await pool.query( `SELECT s.*, c.name as customer_name, c.phone as customer_phone FROM sales s LEFT JOIN customers c ON s.customer_id = c.id WHERE s.id = $1`, [sale.id] ); const fullItems = await pool.query( `SELECT si.*, f.tip as filament_tip, f.finish as filament_finish, f.boja as filament_boja FROM sale_items si JOIN filaments f ON si.filament_id = f.id WHERE si.sale_id = $1`, [sale.id] ); res.json({ ...fullSale.rows[0], items: fullItems.rows }); } catch (error) { await client.query('ROLLBACK'); console.error('Error creating sale:', error); res.status(500).json({ error: 'Failed to create sale' }); } finally { client.release(); } }); app.get('/api/sales', authenticateToken, async (req, res) => { try { const page = parseInt(req.query.page) || 1; const limit = parseInt(req.query.limit) || 50; const offset = (page - 1) * limit; const countResult = await pool.query('SELECT COUNT(*) FROM sales'); const total = parseInt(countResult.rows[0].count); const result = await pool.query( `SELECT s.*, c.name as customer_name, c.phone as customer_phone, (SELECT COUNT(*) FROM sale_items si WHERE si.sale_id = s.id) as item_count FROM sales s LEFT JOIN customers c ON s.customer_id = c.id ORDER BY s.created_at DESC LIMIT $1 OFFSET $2`, [limit, offset] ); res.json({ sales: result.rows, total }); } catch (error) { console.error('Error fetching sales:', error); res.status(500).json({ error: 'Failed to fetch sales' }); } }); app.get('/api/sales/:id', authenticateToken, async (req, res) => { try { const { id } = req.params; const saleResult = await pool.query( `SELECT s.*, c.name as customer_name, c.phone as customer_phone, c.city as customer_city FROM sales s LEFT JOIN customers c ON s.customer_id = c.id WHERE s.id = $1`, [id] ); if (saleResult.rows.length === 0) return res.status(404).json({ error: 'Sale not found' }); const itemsResult = await pool.query( `SELECT si.*, f.tip as filament_tip, f.finish as filament_finish, f.boja as filament_boja FROM sale_items si JOIN filaments f ON si.filament_id = f.id WHERE si.sale_id = $1 ORDER BY si.created_at`, [id] ); res.json({ ...saleResult.rows[0], items: itemsResult.rows }); } catch (error) { console.error('Error fetching sale:', error); res.status(500).json({ error: 'Failed to fetch sale' }); } }); app.delete('/api/sales/:id', authenticateToken, async (req, res) => { const client = await pool.connect(); try { await client.query('BEGIN'); const { id } = req.params; // Get sale items to restore inventory const itemsResult = await client.query('SELECT * FROM sale_items WHERE sale_id = $1', [id]); if (itemsResult.rows.length === 0) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Sale not found' }); } // Restore inventory for each item for (const item of itemsResult.rows) { const updateField = item.item_type === 'refill' ? 'refill' : 'spulna'; await client.query( `UPDATE filaments SET ${updateField} = ${updateField} + $1, kolicina = kolicina + $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`, [item.quantity, item.filament_id] ); } // Delete sale (cascade deletes sale_items) await client.query('DELETE FROM sales WHERE id = $1', [id]); await client.query('COMMIT'); res.json({ success: true }); } catch (error) { await client.query('ROLLBACK'); console.error('Error deleting sale:', error); res.status(500).json({ error: 'Failed to delete sale' }); } finally { client.release(); } }); // ========================================== // Analytics endpoints (admin-only) // ========================================== function getPeriodInterval(period) { const map = { '7d': '7 days', '30d': '30 days', '90d': '90 days', '6m': '6 months', '1y': '1 year', 'all': '100 years' }; return map[period] || '30 days'; } app.get('/api/analytics/overview', authenticateToken, async (req, res) => { try { const interval = getPeriodInterval(req.query.period); const result = await pool.query( `SELECT COALESCE(SUM(total_amount), 0) as revenue, COUNT(*) as sales_count, COALESCE(ROUND(AVG(total_amount)), 0) as avg_order_value, COUNT(DISTINCT customer_id) as unique_customers FROM sales WHERE created_at >= NOW() - $1::interval`, [interval] ); res.json(result.rows[0]); } catch (error) { console.error('Error fetching analytics overview:', error); res.status(500).json({ error: 'Failed to fetch analytics' }); } }); app.get('/api/analytics/top-sellers', authenticateToken, async (req, res) => { try { const interval = getPeriodInterval(req.query.period); const result = await pool.query( `SELECT f.boja, f.tip, f.finish, SUM(si.quantity) as total_qty, SUM(si.quantity * si.unit_price) as total_revenue FROM sale_items si JOIN filaments f ON si.filament_id = f.id JOIN sales s ON si.sale_id = s.id WHERE s.created_at >= NOW() - $1::interval GROUP BY f.boja, f.tip, f.finish ORDER BY total_qty DESC LIMIT 10`, [interval] ); res.json(result.rows); } catch (error) { console.error('Error fetching top sellers:', error); res.status(500).json({ error: 'Failed to fetch top sellers' }); } }); app.get('/api/analytics/inventory-alerts', authenticateToken, async (req, res) => { try { const result = await pool.query( `SELECT f.id, f.boja, f.tip, f.finish, f.refill, f.spulna, f.kolicina, COALESCE( (SELECT SUM(si.quantity)::float / GREATEST(EXTRACT(EPOCH FROM (NOW() - MIN(s.created_at))) / 86400, 1) FROM sale_items si JOIN sales s ON si.sale_id = s.id WHERE si.filament_id = f.id AND s.created_at >= NOW() - INTERVAL '90 days'), 0 ) as avg_daily_sales FROM filaments f WHERE f.kolicina <= 5 ORDER BY f.kolicina ASC, f.boja` ); const rows = result.rows.map(r => ({ ...r, avg_daily_sales: parseFloat(r.avg_daily_sales) || 0, days_until_stockout: r.avg_daily_sales > 0 ? Math.round(r.kolicina / r.avg_daily_sales) : null })); res.json(rows); } catch (error) { console.error('Error fetching inventory alerts:', error); res.status(500).json({ error: 'Failed to fetch inventory alerts' }); } }); app.get('/api/analytics/revenue-chart', authenticateToken, async (req, res) => { try { const interval = getPeriodInterval(req.query.period || '6m'); const group = req.query.group || 'month'; const truncTo = group === 'day' ? 'day' : group === 'week' ? 'week' : 'month'; const result = await pool.query( `SELECT DATE_TRUNC($1, created_at) as period, SUM(total_amount) as revenue, COUNT(*) as count FROM sales WHERE created_at >= NOW() - $2::interval GROUP BY DATE_TRUNC($1, created_at) ORDER BY period`, [truncTo, interval] ); res.json(result.rows); } catch (error) { console.error('Error fetching revenue chart:', error); res.status(500).json({ error: 'Failed to fetch revenue chart' }); } }); app.get('/api/analytics/type-breakdown', authenticateToken, async (req, res) => { try { const interval = getPeriodInterval(req.query.period); const result = await pool.query( `SELECT si.item_type, SUM(si.quantity) as total_qty, SUM(si.quantity * si.unit_price) as total_revenue FROM sale_items si JOIN sales s ON si.sale_id = s.id WHERE s.created_at >= NOW() - $1::interval GROUP BY si.item_type`, [interval] ); res.json(result.rows); } catch (error) { console.error('Error fetching type breakdown:', error); res.status(500).json({ error: 'Failed to fetch type breakdown' }); } }); app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); });