Files
car/backend/server.js

240 lines
8.9 KiB
JavaScript

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 'ノーマル'");
}
if (!carCols.includes('sort_order')) {
db.exec('ALTER TABLE cars ADD COLUMN sort_order INTEGER DEFAULT 0');
db.exec('UPDATE cars SET sort_order = id');
}
db.prepare("UPDATE cars SET tire_type = 'スタッドレス' WHERE tire_type = 'スタットレス'").run();
// Migrate: add period fields to reservations if they don't exist yet
const resCols = db.prepare("PRAGMA table_info(reservations)").all().map((c) => c.name);
if (!resCols.includes('start_period')) {
db.exec("ALTER TABLE reservations ADD COLUMN start_period TEXT DEFAULT ''");
}
if (!resCols.includes('end_period')) {
db.exec("ALTER TABLE reservations ADD COLUMN end_period TEXT DEFAULT ''");
}
// 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, sort_order) VALUES (?, ?, ?)');
insertCar.run('代車 A', '', 1);
insertCar.run('代車 B', '', 2);
insertCar.run('代車 C', '', 3);
}
// --- 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 sort_order, 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 maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM cars').get().m ?? 0;
const result = db.prepare(
'INSERT INTO cars (name, description, inspection_expiry, has_etc, tire_type, sort_order) VALUES (?, ?, ?, ?, ?, ?)'
).run(name.trim(), description, inspection_expiry, has_etc ? 1 : 0, tire_type, maxOrder + 1);
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/reorder', (req, res) => {
const { ids } = req.body;
if (!Array.isArray(ids) || ids.length === 0) {
return res.status(400).json({ error: 'ids は配列で指定してください' });
}
const updateOrder = db.prepare('UPDATE cars SET sort_order = ? WHERE id = ?');
const transaction = db.transaction((orderedIds) => {
orderedIds.forEach((id, index) => {
updateOrder.run(index + 1, id);
});
});
transaction(ids);
broadcast({ type: 'data_changed', entity: 'cars' });
res.json({ success: true });
});
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 = '', start_period = '', end_period = '' } = 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, start_period, end_period) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(car_id, start_date, end_date, customer_name, notes, start_period, end_period);
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, start_period, end_period } = 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 = ?, start_period = ?, end_period = ? WHERE id = ?'
).run(car_id, start_date, end_date, customer_name ?? '', notes ?? '', start_period ?? '', end_period ?? '', 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}`);
});