Files
pick-lunch/template/backend/server.js

175 lines
5.2 KiB
JavaScript

require('dotenv').config();
const express = require('express');
const cors = require('cors');
const { createStore } = require('./storage');
const PORT = Number(process.env.PORT || 4000);
const CATEGORY_SET = new Set(['KOREAN', 'CHINESE', 'JAPANESE', 'WESTERN', 'OTHER']);
async function main() {
const { mode, store } = await createStore();
const app = express();
app.use(cors());
app.use(express.json());
app.get('/v1/health', (_req, res) => {
res.json({ status: 'ok', mode, time: new Date().toISOString() });
});
app.get('/v1/restaurants/nearby', async (req, res) => {
try {
const userExternalId = getUserId(req);
const lat = Number(req.query.lat);
const lng = Number(req.query.lng);
const radiusMeters = Number(req.query.radiusMeters || 1000);
const category = typeof req.query.category === 'string' ? req.query.category : undefined;
const sort = normalizeSort(req.query.sort);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
return res.status(400).json({ code: 'INVALID_INPUT', message: 'lat/lng required' });
}
if (category && !CATEGORY_SET.has(category)) {
return res.status(400).json({ code: 'INVALID_INPUT', message: 'invalid category' });
}
const items = await store.getNearby({
userExternalId,
lat,
lng,
radiusMeters,
category,
sort,
});
return res.json({ items, nextCursor: null });
} catch (error) {
return handleError(res, error);
}
});
app.get('/v1/restaurants/:restaurantId', async (req, res) => {
try {
const userExternalId = getUserId(req);
const restaurant = await store.getRestaurantById(
req.params.restaurantId,
userExternalId,
);
if (!restaurant) {
return res.status(404).json({ code: 'NOT_FOUND', message: 'restaurant not found' });
}
return res.json(restaurant);
} catch (error) {
return handleError(res, error);
}
});
app.get('/v1/restaurants/:restaurantId/reviews', async (req, res) => {
try {
const userExternalId = getUserId(req);
const items = await store.getReviews(req.params.restaurantId, userExternalId);
return res.json({ items, nextCursor: null });
} catch (error) {
return handleError(res, error);
}
});
app.post('/v1/restaurants/:restaurantId/reviews', async (req, res) => {
try {
const userExternalId = getUserId(req);
const restaurantId = req.params.restaurantId;
const rating = Number(req.body?.rating);
const content = String(req.body?.content || '').trim();
if (!Number.isInteger(rating) || rating < 1 || rating > 5 || !content) {
return res.status(400).json({
code: 'INVALID_INPUT',
message: 'rating must be 1~5 and content required',
});
}
const result = await store.upsertReview(restaurantId, userExternalId, rating, content);
if (!result) {
return res.status(404).json({ code: 'NOT_FOUND', message: 'restaurant not found' });
}
return res.json(result);
} catch (error) {
return handleError(res, error);
}
});
app.delete('/v1/reviews/:reviewId', async (req, res) => {
try {
const userExternalId = getUserId(req);
const result = await store.deleteReview(req.params.reviewId, userExternalId);
if (!result.deleted && result.reason === 'NOT_FOUND') {
return res.status(404).json({ code: 'NOT_FOUND', message: 'review not found' });
}
if (!result.deleted && result.reason === 'FORBIDDEN') {
return res.status(403).json({ code: 'FORBIDDEN', message: 'not your review' });
}
return res.status(204).send();
} catch (error) {
return handleError(res, error);
}
});
app.post('/v1/restaurants/:restaurantId/like', async (req, res) => {
try {
const userExternalId = getUserId(req);
const result = await store.toggleLike(req.params.restaurantId, userExternalId);
if (!result) {
return res.status(404).json({ code: 'NOT_FOUND', message: 'restaurant not found' });
}
return res.json(result);
} catch (error) {
return handleError(res, error);
}
});
app.get('/v1/users/me/reviews', async (req, res) => {
try {
const userExternalId = getUserId(req);
const items = await store.getMyReviews(userExternalId);
return res.json({ items, nextCursor: null });
} catch (error) {
return handleError(res, error);
}
});
app.listen(PORT, () => {
console.log(`[lunch-picker-backend] mode=${mode} listening on http://localhost:${PORT}`);
});
}
main().catch((error) => {
console.error('[lunch-picker-backend] failed to start', error);
process.exit(1);
});
function getUserId(req) {
const value = req.header('x-user-id');
return value && value.trim() ? value.trim() : 'demo-user-1';
}
function normalizeSort(input) {
if (typeof input !== 'string') return 'distance';
if (input === 'rating' || input === 'likes') return input;
return 'distance';
}
function handleError(res, error) {
console.error('[api-error]', error);
return res.status(500).json({ code: 'INTERNAL_ERROR', message: 'unexpected error' });
}