- Express backend with JWT auth, PostgreSQL, full CRUD APIs - React + Vite + TailwindCSS frontend with RTL Hebrew UI - Event Creation & Management (create/edit/delete/list events) - Participant Management (add/edit/delete/status tracking per event) - Budget Management (income/expense tracking with balance summary) - Docker Compose setup with PostgreSQL - /health endpoint with commit-id and DB status Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
379 lines
14 KiB
JavaScript
379 lines
14 KiB
JavaScript
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();
|
|
});
|