if (process.stdout._handle && typeof process.stdout._handle.setBlocking === 'function') { process.stdout._handle.setBlocking(true); } if (process.stderr._handle && typeof process.stderr._handle.setBlocking === 'function') { process.stderr._handle.setBlocking(true); } const express = require('express'); const cors = require('cors'); const bcrypt = require('bcrypt'); const jwt = require('jsonwebtoken'); const pool = require('./db'); require('dotenv').config(); const app = express(); const PORT = process.env.PORT || 5000; app.use(cors()); app.use(express.json()); // Request logger middleware app.use((req, res, next) => { console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`); if (req.body && Object.keys(req.body).length > 0) { const logBody = { ...req.body }; if (logBody.password) logBody.password = '[REDACTED]'; console.log(' Body:', JSON.stringify(logBody)); } next(); }); // Middleware to verify JWT token const authenticateToken = (req, res, next) => { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; if (!token) return res.status(401).json({ error: 'No token provided' }); jwt.verify(token, process.env.JWT_SECRET || 'fallback_secret', (err, user) => { if (err) return res.status(403).json({ error: 'Invalid token' }); req.user = user; next(); }); }; // Registration app.post('/api/register', async (req, res) => { try { const { username, email, password } = req.body; if (!username || !email || !password) { return res.status(400).json({ error: 'All fields are required' }); } // Check if user exists const userExists = await pool.query('SELECT * FROM users WHERE email = $1 OR username = $2', [email, username]); if (userExists.rows.length > 0) { return res.status(400).json({ error: 'User already exists' }); } // Hash password const salt = await bcrypt.genSalt(10); const passwordHash = await bcrypt.hash(password, salt); // Save to DB const newUser = await pool.query( 'INSERT INTO users (username, email, password_hash) VALUES ($1, $2, $3) RETURNING id, username, email', [username, email, passwordHash] ); res.status(201).json({ message: 'Registration successful', user: newUser.rows[0] }); } catch (err) { console.error(err); res.status(500).json({ error: 'Server error' }); } }); // Login app.post('/api/login', async (req, res) => { try { const { email, password } = req.body; if (!email || !password) { return res.status(400).json({ error: 'Email and password are required' }); } // Find user const user = await pool.query('SELECT * FROM users WHERE email = $1', [email]); if (user.rows.length === 0) { return res.status(400).json({ error: 'Invalid credentials' }); } // Check password const validPassword = await bcrypt.compare(password, user.rows[0].password_hash); if (!validPassword) { return res.status(400).json({ error: 'Invalid credentials' }); } // Create token const token = jwt.sign( { id: user.rows[0].id, username: user.rows[0].username }, process.env.JWT_SECRET || 'fallback_secret', { expiresIn: '24h' } ); res.json({ token, user: { id: user.rows[0].id, username: user.rows[0].username, email: user.rows[0].email } }); } catch (err) { console.error(err); res.status(500).json({ error: 'Server error' }); } }); // Get user profile (protected route) app.get('/api/profile', authenticateToken, async (req, res) => { try { const user = await pool.query('SELECT id, username, email, created_at FROM users WHERE id = $1', [req.user.id]); if (user.rows.length === 0) { return res.status(404).json({ error: 'User not found' }); } res.json(user.rows[0]); } catch (err) { console.error(err); res.status(500).json({ error: 'Server error' }); } }); // Verify token app.post('/api/verify-token', authenticateToken, (req, res) => { res.json({ valid: true, user: req.user }); }); // Sync data (beans and brew logs) app.post('/api/sync', authenticateToken, async (req, res) => { const userId = req.user.id; const { lastSyncedAt, beans = [], brewLogs = [] } = req.body; const serverTime = new Date().toISOString(); const client = await pool.connect(); try { await client.query('BEGIN'); // 1. Process incoming beans for (const bean of beans) { await client.query(` INSERT INTO beans (id, user_id, name, roastery, tasting_notes, roast_type, roast_date, image, updated_at, is_deleted) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, roastery = EXCLUDED.roastery, tasting_notes = EXCLUDED.tasting_notes, roast_type = EXCLUDED.roast_type, roast_date = EXCLUDED.roast_date, image = EXCLUDED.image, updated_at = EXCLUDED.updated_at, is_deleted = EXCLUDED.is_deleted WHERE EXCLUDED.updated_at > beans.updated_at OR beans.user_id IS NULL `, [ bean.id, userId, bean.name || '', bean.roastery || '', bean.tastingNotes || '', bean.roastType || '', bean.roastDate || '', bean.image || '', bean.updatedAt ? new Date(bean.updatedAt) : new Date(), bean.isDeleted || false ]); } // 2. Process incoming brew logs for (const log of brewLogs) { await client.query(` INSERT INTO brew_logs (id, user_id, bean_id, method, grind, water_temp, ratio, yield, time, notes, rating, created_at, updated_at, is_deleted) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) ON CONFLICT (id) DO UPDATE SET bean_id = EXCLUDED.bean_id, method = EXCLUDED.method, grind = EXCLUDED.grind, water_temp = EXCLUDED.water_temp, ratio = EXCLUDED.ratio, yield = EXCLUDED.yield, time = EXCLUDED.time, notes = EXCLUDED.notes, rating = EXCLUDED.rating, created_at = EXCLUDED.created_at, updated_at = EXCLUDED.updated_at, is_deleted = EXCLUDED.is_deleted WHERE EXCLUDED.updated_at > brew_logs.updated_at OR brew_logs.user_id IS NULL `, [ log.id, userId, log.beanId, log.method || '', log.grind || '', log.waterTemp || '', log.ratio || '', log.yield || '', log.time || '', log.notes || '', log.rating || 0, log.createdAt ? BigInt(log.createdAt) : BigInt(Date.now()), log.updatedAt ? new Date(log.updatedAt) : new Date(), log.isDeleted || false ]); } // 3. Fetch server changes since lastSyncedAt let beansQuery = `SELECT * FROM beans WHERE user_id = $1`; let logsQuery = `SELECT * FROM brew_logs WHERE user_id = $1`; const params = [userId]; if (lastSyncedAt) { beansQuery += ` AND updated_at > $2`; logsQuery += ` AND updated_at > $2`; params.push(new Date(lastSyncedAt)); } const serverBeans = await client.query(beansQuery, params); const serverLogs = await client.query(logsQuery, params); await client.query('COMMIT'); // Map DB fields back to camelCase expected by the frontend const mappedBeans = serverBeans.rows.map(b => ({ id: b.id, name: b.name, roastery: b.roastery, tastingNotes: b.tasting_notes, roastType: b.roast_type, roastDate: b.roast_date, image: b.image, updatedAt: b.updated_at.toISOString(), isDeleted: b.is_deleted })); const mappedLogs = serverLogs.rows.map(l => ({ id: l.id, beanId: l.bean_id, method: l.method, grind: l.grind, waterTemp: l.water_temp, ratio: l.ratio, yield: l.yield, time: l.time, notes: l.notes, rating: l.rating, createdAt: Number(l.created_at), updatedAt: l.updated_at.toISOString(), isDeleted: l.is_deleted })); res.json({ serverTime, beans: mappedBeans, brewLogs: mappedLogs }); } catch (err) { await client.query('ROLLBACK'); console.error('Sync failed', err); res.status(500).json({ error: 'Sync failed' }); } finally { client.release(); } }); app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); });