feat: bootstrap lunch picker miniapp with backend, docs, and branding assets
This commit is contained in:
174
template/backend/server.js
Normal file
174
template/backend/server.js
Normal file
@@ -0,0 +1,174 @@
|
||||
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' });
|
||||
}
|
||||
Reference in New Issue
Block a user