const express = require('express'); const cors = require('cors'); const { Pool } = require('pg'); const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const { v4: uuidv4 } = require('uuid'); const path = require('path'); const app = express(); const PORT = process.env.PORT || 3000; const JWT_SECRET = process.env.JWT_SECRET || 'airewit-secret-key-change-in-prod'; const pool = new Pool({ connectionString: process.env.DATABASE_URL || 'postgresql://postgres:postgres@postgres:5432/airewit', }); app.use(cors()); app.use(express.json()); app.use(express.static(path.join(__dirname, 'client/dist'))); // Initialize DB tables async function initDB() { const client = await pool.connect(); try { await client.query(` CREATE EXTENSION IF NOT EXISTS "pgcrypto"; CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email VARCHAR(255) UNIQUE NOT NULL, name VARCHAR(100) NOT NULL, password_hash VARCHAR(255) NOT NULL, created_at TIMESTAMP DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS events ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES users(id) ON DELETE CASCADE, title VARCHAR(255) NOT NULL, description TEXT, event_date TIMESTAMP NOT NULL, location VARCHAR(255), max_participants INTEGER, budget DECIMAL(10,2) DEFAULT 0, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS participants ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), event_id UUID REFERENCES events(id) ON DELETE CASCADE, name VARCHAR(100) NOT NULL, email VARCHAR(255) NOT NULL, phone VARCHAR(50), status VARCHAR(50) DEFAULT 'invited', created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS budget_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), event_id UUID REFERENCES events(id) ON DELETE CASCADE, title VARCHAR(255) NOT NULL, type VARCHAR(20) NOT NULL CHECK (type IN ('income', 'expense')), amount DECIMAL(10,2) NOT NULL, description TEXT, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_events_user_id ON events(user_id); CREATE INDEX IF NOT EXISTS idx_participants_event_id ON participants(event_id); CREATE INDEX IF NOT EXISTS idx_budget_items_event_id ON budget_items(event_id); `); console.log('Database initialized'); } catch (err) { console.error('DB init error:', err.message); } finally { client.release(); } } // Auth middleware function authMiddleware(req, res, next) { const token = req.headers.authorization?.replace('Bearer ', ''); if (!token) return res.status(401).json({ error: 'Unauthorized' }); try { req.user = jwt.verify(token, JWT_SECRET); next(); } catch { res.status(401).json({ error: 'Invalid token' }); } } // Health check app.get('/health', async (req, res) => { let dbOk = false; try { await pool.query('SELECT 1'); dbOk = true; } catch {} res.json({ status: 'ok', commit: process.env.GIT_COMMIT || 'unknown', db: dbOk ? 'connected' : 'error', timestamp: new Date().toISOString() }); }); // ========== AUTH ========== app.post('/api/auth/register', async (req, res) => { const { email, name, password } = req.body; if (!email || !name || !password) return res.status(400).json({ error: 'Missing fields' }); try { const hash = await bcrypt.hash(password, 10); const result = await pool.query( 'INSERT INTO users (email, name, password_hash) VALUES ($1, $2, $3) RETURNING id, email, name, created_at', [email, name, hash] ); const user = result.rows[0]; const token = jwt.sign({ userId: user.id, email: user.email }, JWT_SECRET, { expiresIn: '7d' }); res.status(201).json({ user, token }); } catch (err) { if (err.code === '23505') return res.status(409).json({ error: 'Email already exists' }); res.status(500).json({ error: 'Server error' }); } }); app.post('/api/auth/login', async (req, res) => { const { email, password } = req.body; if (!email || !password) return res.status(400).json({ error: 'Missing fields' }); try { const result = await pool.query('SELECT * FROM users WHERE email = $1', [email]); const user = result.rows[0]; if (!user) return res.status(401).json({ error: 'Invalid credentials' }); const valid = await bcrypt.compare(password, user.password_hash); if (!valid) return res.status(401).json({ error: 'Invalid credentials' }); const token = jwt.sign({ userId: user.id, email: user.email }, JWT_SECRET, { expiresIn: '7d' }); res.json({ user: { id: user.id, email: user.email, name: user.name }, token }); } catch { res.status(500).json({ error: 'Server error' }); } }); app.get('/api/auth/me', authMiddleware, async (req, res) => { try { const result = await pool.query('SELECT id, email, name, created_at FROM users WHERE id = $1', [req.user.userId]); if (!result.rows[0]) return res.status(404).json({ error: 'User not found' }); res.json(result.rows[0]); } catch { res.status(500).json({ error: 'Server error' }); } }); // ========== EVENTS ========== app.get('/api/events', authMiddleware, async (req, res) => { try { const result = await pool.query( `SELECT e.*, COUNT(DISTINCT p.id) AS participant_count, COALESCE(SUM(CASE WHEN bi.type='income' THEN bi.amount ELSE 0 END), 0) AS total_income, COALESCE(SUM(CASE WHEN bi.type='expense' THEN bi.amount ELSE 0 END), 0) AS total_expenses FROM events e LEFT JOIN participants p ON p.event_id = e.id LEFT JOIN budget_items bi ON bi.event_id = e.id WHERE e.user_id = $1 GROUP BY e.id ORDER BY e.event_date ASC`, [req.user.userId] ); res.json(result.rows); } catch (err) { res.status(500).json({ error: 'Server error' }); } }); app.get('/api/events/:id', authMiddleware, async (req, res) => { try { const result = await pool.query( 'SELECT * FROM events WHERE id = $1 AND user_id = $2', [req.params.id, req.user.userId] ); if (!result.rows[0]) return res.status(404).json({ error: 'Event not found' }); res.json(result.rows[0]); } catch { res.status(500).json({ error: 'Server error' }); } }); app.post('/api/events', authMiddleware, async (req, res) => { const { title, description, event_date, location, max_participants, budget } = req.body; if (!title || !event_date) return res.status(400).json({ error: 'Title and event_date are required' }); try { const result = await pool.query( `INSERT INTO events (user_id, title, description, event_date, location, max_participants, budget) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, [req.user.userId, title, description, event_date, location, max_participants || null, budget || 0] ); res.status(201).json(result.rows[0]); } catch { res.status(500).json({ error: 'Server error' }); } }); app.put('/api/events/:id', authMiddleware, async (req, res) => { const { title, description, event_date, location, max_participants, budget } = req.body; if (!title || !event_date) return res.status(400).json({ error: 'Title and event_date are required' }); try { const result = await pool.query( `UPDATE events SET title=$1, description=$2, event_date=$3, location=$4, max_participants=$5, budget=$6, updated_at=NOW() WHERE id=$7 AND user_id=$8 RETURNING *`, [title, description, event_date, location, max_participants || null, budget || 0, req.params.id, req.user.userId] ); if (!result.rows[0]) return res.status(404).json({ error: 'Event not found' }); res.json(result.rows[0]); } catch { res.status(500).json({ error: 'Server error' }); } }); app.delete('/api/events/:id', authMiddleware, async (req, res) => { try { const result = await pool.query( 'DELETE FROM events WHERE id=$1 AND user_id=$2 RETURNING id', [req.params.id, req.user.userId] ); if (!result.rows[0]) return res.status(404).json({ error: 'Event not found' }); res.json({ success: true }); } catch { res.status(500).json({ error: 'Server error' }); } }); // ========== PARTICIPANTS ========== app.get('/api/events/:eventId/participants', authMiddleware, async (req, res) => { try { // Verify event belongs to user const ev = await pool.query('SELECT id FROM events WHERE id=$1 AND user_id=$2', [req.params.eventId, req.user.userId]); if (!ev.rows[0]) return res.status(404).json({ error: 'Event not found' }); const result = await pool.query( 'SELECT * FROM participants WHERE event_id=$1 ORDER BY created_at ASC', [req.params.eventId] ); res.json(result.rows); } catch { res.status(500).json({ error: 'Server error' }); } }); app.post('/api/events/:eventId/participants', authMiddleware, async (req, res) => { const { name, email, phone, status } = req.body; if (!name || !email) return res.status(400).json({ error: 'Name and email are required' }); try { const ev = await pool.query('SELECT id FROM events WHERE id=$1 AND user_id=$2', [req.params.eventId, req.user.userId]); if (!ev.rows[0]) return res.status(404).json({ error: 'Event not found' }); const result = await pool.query( 'INSERT INTO participants (event_id, name, email, phone, status) VALUES ($1,$2,$3,$4,$5) RETURNING *', [req.params.eventId, name, email, phone || null, status || 'invited'] ); res.status(201).json(result.rows[0]); } catch { res.status(500).json({ error: 'Server error' }); } }); app.put('/api/events/:eventId/participants/:id', authMiddleware, async (req, res) => { const { name, email, phone, status } = req.body; if (!name || !email) return res.status(400).json({ error: 'Name and email are required' }); try { const ev = await pool.query('SELECT id FROM events WHERE id=$1 AND user_id=$2', [req.params.eventId, req.user.userId]); if (!ev.rows[0]) return res.status(404).json({ error: 'Event not found' }); const result = await pool.query( `UPDATE participants SET name=$1, email=$2, phone=$3, status=$4, updated_at=NOW() WHERE id=$5 AND event_id=$6 RETURNING *`, [name, email, phone || null, status || 'invited', req.params.id, req.params.eventId] ); if (!result.rows[0]) return res.status(404).json({ error: 'Participant not found' }); res.json(result.rows[0]); } catch { res.status(500).json({ error: 'Server error' }); } }); app.delete('/api/events/:eventId/participants/:id', authMiddleware, async (req, res) => { try { const ev = await pool.query('SELECT id FROM events WHERE id=$1 AND user_id=$2', [req.params.eventId, req.user.userId]); if (!ev.rows[0]) return res.status(404).json({ error: 'Event not found' }); const result = await pool.query( 'DELETE FROM participants WHERE id=$1 AND event_id=$2 RETURNING id', [req.params.id, req.params.eventId] ); if (!result.rows[0]) return res.status(404).json({ error: 'Participant not found' }); res.json({ success: true }); } catch { res.status(500).json({ error: 'Server error' }); } }); // ========== BUDGET ITEMS ========== app.get('/api/events/:eventId/budget', authMiddleware, async (req, res) => { try { const ev = await pool.query('SELECT id FROM events WHERE id=$1 AND user_id=$2', [req.params.eventId, req.user.userId]); if (!ev.rows[0]) return res.status(404).json({ error: 'Event not found' }); const result = await pool.query( 'SELECT * FROM budget_items WHERE event_id=$1 ORDER BY created_at ASC', [req.params.eventId] ); res.json(result.rows); } catch { res.status(500).json({ error: 'Server error' }); } }); app.post('/api/events/:eventId/budget', authMiddleware, async (req, res) => { const { title, type, amount, description } = req.body; if (!title || !type || !amount) return res.status(400).json({ error: 'Title, type, and amount are required' }); if (!['income', 'expense'].includes(type)) return res.status(400).json({ error: 'Type must be income or expense' }); try { const ev = await pool.query('SELECT id FROM events WHERE id=$1 AND user_id=$2', [req.params.eventId, req.user.userId]); if (!ev.rows[0]) return res.status(404).json({ error: 'Event not found' }); const result = await pool.query( 'INSERT INTO budget_items (event_id, title, type, amount, description) VALUES ($1,$2,$3,$4,$5) RETURNING *', [req.params.eventId, title, type, amount, description || null] ); res.status(201).json(result.rows[0]); } catch { res.status(500).json({ error: 'Server error' }); } }); app.put('/api/events/:eventId/budget/:id', authMiddleware, async (req, res) => { const { title, type, amount, description } = req.body; if (!title || !type || !amount) return res.status(400).json({ error: 'Title, type, and amount are required' }); if (!['income', 'expense'].includes(type)) return res.status(400).json({ error: 'Type must be income or expense' }); try { const ev = await pool.query('SELECT id FROM events WHERE id=$1 AND user_id=$2', [req.params.eventId, req.user.userId]); if (!ev.rows[0]) return res.status(404).json({ error: 'Event not found' }); const result = await pool.query( `UPDATE budget_items SET title=$1, type=$2, amount=$3, description=$4, updated_at=NOW() WHERE id=$5 AND event_id=$6 RETURNING *`, [title, type, amount, description || null, req.params.id, req.params.eventId] ); if (!result.rows[0]) return res.status(404).json({ error: 'Budget item not found' }); res.json(result.rows[0]); } catch { res.status(500).json({ error: 'Server error' }); } }); app.delete('/api/events/:eventId/budget/:id', authMiddleware, async (req, res) => { try { const ev = await pool.query('SELECT id FROM events WHERE id=$1 AND user_id=$2', [req.params.eventId, req.user.userId]); if (!ev.rows[0]) return res.status(404).json({ error: 'Event not found' }); const result = await pool.query( 'DELETE FROM budget_items WHERE id=$1 AND event_id=$2 RETURNING id', [req.params.id, req.params.eventId] ); if (!result.rows[0]) return res.status(404).json({ error: 'Budget item not found' }); res.json({ success: true }); } catch { res.status(500).json({ error: 'Server error' }); } }); // SPA fallback app.get('*', (req, res) => { res.sendFile(path.join(__dirname, 'client/dist/index.html')); }); app.listen(PORT, async () => { console.log(`Server running on port ${PORT}`); await initDB(); });