175 lines
5.2 KiB
JavaScript
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' });
|
|
}
|