const express = require('express'); const cors = require('cors'); const rateLimit = require('express-rate-limit'); const Database = require('better-sqlite3'); const path = require('path'); const http = require('http'); const { WebSocketServer } = require('ws'); const app = express(); const PORT = process.env.PORT || 3001; const server = http.createServer(app); // Trust the first proxy (nginx) so that express-rate-limit can correctly // identify clients by their real IP from the X-Forwarded-For header. app.set('trust proxy', 1); app.use(cors()); app.use(express.json()); // Serve the built frontend (production mode) const distPath = path.join(__dirname, '..', 'frontend', 'dist'); app.use(express.static(distPath)); // Apply rate limiting to all API routes const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 300, // limit each IP to 300 requests per windowMs standardHeaders: true, legacyHeaders: false, message: { error: 'リクエストが多すぎます。しばらくしてから再試行してください。' }, }); app.use('/api', apiLimiter); const dbPath = path.join(__dirname, 'data.db'); const db = new Database(dbPath); // Initialize database schema db.exec(` CREATE TABLE IF NOT EXISTS cars ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, description TEXT DEFAULT '' ); CREATE TABLE IF NOT EXISTS reservations ( id INTEGER PRIMARY KEY AUTOINCREMENT, car_id INTEGER NOT NULL, start_date TEXT NOT NULL, end_date TEXT NOT NULL, customer_name TEXT DEFAULT '', notes TEXT DEFAULT '', FOREIGN KEY (car_id) REFERENCES cars(id) ON DELETE CASCADE ); `); // Migrate: add new car fields if they don't exist yet const carCols = db.prepare("PRAGMA table_info(cars)").all().map((c) => c.name); if (!carCols.includes('inspection_expiry')) { db.exec("ALTER TABLE cars ADD COLUMN inspection_expiry TEXT DEFAULT ''"); } if (!carCols.includes('has_etc')) { db.exec('ALTER TABLE cars ADD COLUMN has_etc INTEGER DEFAULT 0'); } if (!carCols.includes('tire_type')) { db.exec("ALTER TABLE cars ADD COLUMN tire_type TEXT DEFAULT 'ノーマル'"); } db.prepare("UPDATE cars SET tire_type = 'スタッドレス' WHERE tire_type = 'スタットレス'").run(); // Seed some initial cars if none exist const carCount = db.prepare('SELECT COUNT(*) as cnt FROM cars').get(); if (carCount.cnt === 0) { const insertCar = db.prepare('INSERT INTO cars (name, description) VALUES (?, ?)'); insertCar.run('代車 A', ''); insertCar.run('代車 B', ''); insertCar.run('代車 C', ''); } // --- WebSocket Server --- const wss = new WebSocketServer({ server, path: '/api/ws' }); function broadcast(message) { const data = JSON.stringify(message); wss.clients.forEach((client) => { if (client.readyState === client.OPEN) { client.send(data); } }); } function normalizeTireType(value) { return value === 'スタットレス' ? 'スタッドレス' : value; } function normalizeCar(car) { if (!car) { return car; } return { ...car, tire_type: normalizeTireType(car.tire_type), }; } wss.on('connection', (ws) => { ws.on('error', () => {}); // suppress unhandled error events }); // --- Cars API --- app.get('/api/cars', (req, res) => { const cars = db.prepare('SELECT * FROM cars ORDER BY id').all().map(normalizeCar); res.json(cars); }); app.post('/api/cars', (req, res) => { const { name, description = '', inspection_expiry = '', has_etc = 0, tire_type = 'ノーマル' } = req.body; if (!name || !name.trim()) { return res.status(400).json({ error: '車名は必須です' }); } const result = db.prepare( 'INSERT INTO cars (name, description, inspection_expiry, has_etc, tire_type) VALUES (?, ?, ?, ?, ?)' ).run(name.trim(), description, inspection_expiry, has_etc ? 1 : 0, tire_type); const car = normalizeCar(db.prepare('SELECT * FROM cars WHERE id = ?').get(result.lastInsertRowid)); broadcast({ type: 'data_changed', entity: 'cars' }); res.status(201).json(car); }); app.put('/api/cars/:id', (req, res) => { const { name, description, inspection_expiry, has_etc, tire_type } = req.body; if (!name || !name.trim()) { return res.status(400).json({ error: '車名は必須です' }); } const result = db.prepare( 'UPDATE cars SET name = ?, description = ?, inspection_expiry = ?, has_etc = ?, tire_type = ? WHERE id = ?' ).run(name.trim(), description ?? '', inspection_expiry ?? '', has_etc ? 1 : 0, tire_type ?? 'ノーマル', req.params.id); if (result.changes === 0) { return res.status(404).json({ error: '車が見つかりません' }); } const car = normalizeCar(db.prepare('SELECT * FROM cars WHERE id = ?').get(req.params.id)); broadcast({ type: 'data_changed', entity: 'cars' }); res.json(car); }); app.delete('/api/cars/:id', (req, res) => { const result = db.prepare('DELETE FROM cars WHERE id = ?').run(req.params.id); if (result.changes === 0) { return res.status(404).json({ error: '車が見つかりません' }); } broadcast({ type: 'data_changed', entity: 'all' }); res.json({ success: true }); }); // --- Reservations API --- app.get('/api/reservations', (req, res) => { const reservations = db.prepare('SELECT * FROM reservations ORDER BY start_date').all(); res.json(reservations); }); app.post('/api/reservations', (req, res) => { const { car_id, start_date, end_date, customer_name = '', notes = '' } = req.body; if (!car_id || !start_date || !end_date) { return res.status(400).json({ error: 'car_id, start_date, end_date は必須です' }); } if (start_date > end_date) { return res.status(400).json({ error: '開始日は終了日以前である必要があります' }); } const result = db.prepare( 'INSERT INTO reservations (car_id, start_date, end_date, customer_name, notes) VALUES (?, ?, ?, ?, ?)' ).run(car_id, start_date, end_date, customer_name, notes); const reservation = db.prepare('SELECT * FROM reservations WHERE id = ?').get(result.lastInsertRowid); broadcast({ type: 'data_changed', entity: 'reservations' }); res.status(201).json(reservation); }); app.put('/api/reservations/:id', (req, res) => { const { car_id, start_date, end_date, customer_name, notes } = req.body; if (!car_id || !start_date || !end_date) { return res.status(400).json({ error: 'car_id, start_date, end_date は必須です' }); } if (start_date > end_date) { return res.status(400).json({ error: '開始日は終了日以前である必要があります' }); } const result = db.prepare( 'UPDATE reservations SET car_id = ?, start_date = ?, end_date = ?, customer_name = ?, notes = ? WHERE id = ?' ).run(car_id, start_date, end_date, customer_name ?? '', notes ?? '', req.params.id); if (result.changes === 0) { return res.status(404).json({ error: '予約が見つかりません' }); } const reservation = db.prepare('SELECT * FROM reservations WHERE id = ?').get(req.params.id); broadcast({ type: 'data_changed', entity: 'reservations' }); res.json(reservation); }); app.delete('/api/reservations/:id', (req, res) => { const result = db.prepare('DELETE FROM reservations WHERE id = ?').run(req.params.id); if (result.changes === 0) { return res.status(404).json({ error: '予約が見つかりません' }); } broadcast({ type: 'data_changed', entity: 'reservations' }); res.json({ success: true }); }); // SPA fallback: serve index.html for any non-API route app.get('/*path', apiLimiter, (req, res) => { res.sendFile(path.join(distPath, 'index.html')); }); server.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); });