Files
shokuninmarche/server.js
Fullstack Developer d2d7ee27d5 feat: initial scaffold with Event Management, Participant Management, and Budget Management
- 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>
2026-02-21 18:55:40 +00:00

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