Files
BrewJournal/server/index.js
2026-06-06 09:49:43 +05:30

272 lines
8.3 KiB
JavaScript

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}`);
});