feat: bootstrap lunch picker miniapp with backend, docs, and branding assets
This commit is contained in:
15
template/backend/.env.example
Normal file
15
template/backend/.env.example
Normal file
@@ -0,0 +1,15 @@
|
||||
# Backend port
|
||||
PORT=4000
|
||||
|
||||
# MySQL mode is enabled when MYSQL_HOST or MYSQL_URL is set.
|
||||
# If neither is set, backend runs in memory mode.
|
||||
|
||||
# Option A: full URL
|
||||
# MYSQL_URL=mysql://user:password@127.0.0.1:3306/lunch_picker
|
||||
|
||||
# Option B: split fields
|
||||
MYSQL_HOST=127.0.0.1
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_USER=root
|
||||
MYSQL_PASSWORD=secret
|
||||
MYSQL_DATABASE=lunch_picker
|
||||
63
template/backend/README.md
Normal file
63
template/backend/README.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Lunch Picker Backend
|
||||
|
||||
Express API server for Lunch Picker miniapp.
|
||||
|
||||
## Modes
|
||||
|
||||
- Memory mode: run without MySQL env vars (quick local demo)
|
||||
- MySQL mode: set `MYSQL_HOST` (or `MYSQL_URL`) and run with real DB
|
||||
|
||||
## 1) Install
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
## 2) Start
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
Server: `http://localhost:4000`
|
||||
|
||||
## 3) MySQL setup
|
||||
|
||||
1. Create DB (example)
|
||||
|
||||
```sql
|
||||
create database lunch_picker character set utf8mb4 collate utf8mb4_0900_ai_ci;
|
||||
```
|
||||
|
||||
2. Apply schema
|
||||
|
||||
```bash
|
||||
mysql -u root -p lunch_picker < ../docs/db/schema.sql
|
||||
```
|
||||
|
||||
3. Set `.env` values (`MYSQL_HOST`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE`)
|
||||
|
||||
When MySQL mode starts, seed data is inserted automatically only when `restaurant` table is empty.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `GET /v1/health`
|
||||
- `GET /v1/restaurants/nearby`
|
||||
- `GET /v1/restaurants/:restaurantId`
|
||||
- `GET /v1/restaurants/:restaurantId/reviews`
|
||||
- `POST /v1/restaurants/:restaurantId/reviews`
|
||||
- `DELETE /v1/reviews/:reviewId`
|
||||
- `POST /v1/restaurants/:restaurantId/like`
|
||||
- `GET /v1/users/me/reviews`
|
||||
|
||||
## Auth Simulation
|
||||
|
||||
Set `x-user-id` header to simulate user identity.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
curl -H "x-user-id: demo-user-1" "http://localhost:4000/v1/restaurants/nearby?lat=37.501&lng=127.037&radiusMeters=1000&sort=distance"
|
||||
```
|
||||
1018
template/backend/package-lock.json
generated
Normal file
1018
template/backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
template/backend/package.json
Normal file
15
template/backend/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "lunch-picker-backend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.6.1",
|
||||
"express": "^4.21.2",
|
||||
"mysql2": "^3.15.3",
|
||||
"uuid": "^11.1.0"
|
||||
}
|
||||
}
|
||||
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' });
|
||||
}
|
||||
733
template/backend/storage.js
Normal file
733
template/backend/storage.js
Normal file
@@ -0,0 +1,733 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
const CATEGORY_SET = new Set(['KOREAN', 'CHINESE', 'JAPANESE', 'WESTERN', 'OTHER']);
|
||||
|
||||
const seedRestaurants = [
|
||||
{
|
||||
id: 'a1f5d3a5-1c39-4c7d-a0fb-2f8f8f5f7a11',
|
||||
name: '강남스시마루',
|
||||
category: 'JAPANESE',
|
||||
address: '서울 강남구 테헤란로 123',
|
||||
phone: '02-1234-5678',
|
||||
lat: 37.5009,
|
||||
lng: 127.0368,
|
||||
},
|
||||
{
|
||||
id: '2f9b0c77-dcf7-44c5-8fcb-aefb4a16c222',
|
||||
name: '북경반점 선릉점',
|
||||
category: 'CHINESE',
|
||||
address: '서울 강남구 선릉로 88',
|
||||
phone: '02-7890-1234',
|
||||
lat: 37.5043,
|
||||
lng: 127.0496,
|
||||
},
|
||||
{
|
||||
id: '3be86df0-8b69-4f1b-9f0f-c08726f78333',
|
||||
name: '파스타하우스 역삼',
|
||||
category: 'WESTERN',
|
||||
address: '서울 강남구 역삼로 201',
|
||||
phone: '02-2222-3333',
|
||||
lat: 37.4982,
|
||||
lng: 127.0397,
|
||||
},
|
||||
{
|
||||
id: '4d645f7f-0f08-4cb2-b218-0dd07a838444',
|
||||
name: '한그릇집 역삼점',
|
||||
category: 'KOREAN',
|
||||
address: '서울 강남구 논현로 401',
|
||||
phone: '02-5566-7788',
|
||||
lat: 37.5012,
|
||||
lng: 127.0301,
|
||||
},
|
||||
];
|
||||
|
||||
const seedUsers = [
|
||||
{ id: '77a341ce-68b4-4fde-9f23-b64fa87cb111', providerUserId: 'demo-user-1', nickName: '나' },
|
||||
{ id: 'a8d1cd2d-f4b7-4f4f-b6b6-1be63e2f8222', providerUserId: 'user-a', nickName: '점심러버' },
|
||||
{ id: 'f1f4ea2a-07e9-4cb8-bd8d-a16792b96333', providerUserId: 'user-b', nickName: '직장인A' },
|
||||
{ id: '4f659ac3-35f1-4907-91c7-11fe2e87a444', providerUserId: 'user-c', nickName: '직장인C' },
|
||||
{ id: '555f6868-c2b5-4025-b0cb-f2f65f1bc555', providerUserId: 'user-d', nickName: '직장인D' },
|
||||
];
|
||||
|
||||
const seedReviews = [
|
||||
{
|
||||
id: 'r-101',
|
||||
restaurantId: 'a1f5d3a5-1c39-4c7d-a0fb-2f8f8f5f7a11',
|
||||
providerUserId: 'user-a',
|
||||
userNickname: '점심러버',
|
||||
rating: 5,
|
||||
content: '초밥 신선하고 점심 세트 가성비 좋아요.',
|
||||
createdAt: '2026-04-10T03:10:00Z',
|
||||
},
|
||||
{
|
||||
id: 'r-102',
|
||||
restaurantId: 'a1f5d3a5-1c39-4c7d-a0fb-2f8f8f5f7a11',
|
||||
providerUserId: 'demo-user-1',
|
||||
userNickname: '나',
|
||||
rating: 4,
|
||||
content: '웨이팅만 짧으면 자주 올 듯.',
|
||||
createdAt: '2026-04-12T02:20:00Z',
|
||||
},
|
||||
{
|
||||
id: 'r-201',
|
||||
restaurantId: '2f9b0c77-dcf7-44c5-8fcb-aefb4a16c222',
|
||||
providerUserId: 'user-b',
|
||||
userNickname: '직장인A',
|
||||
rating: 4,
|
||||
content: '짬뽕 국물이 진해서 해장 점심으로 좋아요.',
|
||||
createdAt: '2026-04-08T04:10:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const seedLikes = [
|
||||
{ restaurantId: '2f9b0c77-dcf7-44c5-8fcb-aefb4a16c222', providerUserId: 'demo-user-1' },
|
||||
{ restaurantId: 'a1f5d3a5-1c39-4c7d-a0fb-2f8f8f5f7a11', providerUserId: 'user-a' },
|
||||
{ restaurantId: '3be86df0-8b69-4f1b-9f0f-c08726f78333', providerUserId: 'user-c' },
|
||||
{ restaurantId: '3be86df0-8b69-4f1b-9f0f-c08726f78333', providerUserId: 'user-d' },
|
||||
];
|
||||
|
||||
class MemoryStore {
|
||||
constructor() {
|
||||
this.restaurants = [...seedRestaurants];
|
||||
this.users = seedUsers.map((u) => ({ ...u }));
|
||||
this.reviews = seedReviews.map((r) => ({ ...r }));
|
||||
this.likes = seedLikes.map((l) => ({ ...l }));
|
||||
}
|
||||
|
||||
async getNearby({ userExternalId, lat, lng, radiusMeters, category, sort }) {
|
||||
let items = this.restaurants
|
||||
.map((restaurant) => this.#toRestaurantListItem(restaurant, userExternalId, lat, lng))
|
||||
.filter((item) => item.distanceMeters <= radiusMeters);
|
||||
|
||||
if (category && CATEGORY_SET.has(category)) {
|
||||
items = items.filter((item) => item.category === category);
|
||||
}
|
||||
|
||||
return sortItems(items, sort);
|
||||
}
|
||||
|
||||
async getRestaurantById(restaurantId, userExternalId) {
|
||||
const restaurant = this.restaurants.find((item) => item.id === restaurantId);
|
||||
if (!restaurant) return null;
|
||||
|
||||
return this.#toRestaurantListItem(
|
||||
restaurant,
|
||||
userExternalId,
|
||||
restaurant.lat,
|
||||
restaurant.lng,
|
||||
);
|
||||
}
|
||||
|
||||
async getReviews(restaurantId, userExternalId) {
|
||||
return this.reviews
|
||||
.filter((review) => review.restaurantId === restaurantId)
|
||||
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))
|
||||
.map((review) => ({
|
||||
id: review.id,
|
||||
restaurantId: review.restaurantId,
|
||||
userExternalId: review.providerUserId,
|
||||
nickName: review.userNickname,
|
||||
rating: review.rating,
|
||||
content: review.content,
|
||||
createdAt: review.createdAt,
|
||||
updatedAt: review.createdAt,
|
||||
mine: review.providerUserId === userExternalId,
|
||||
}));
|
||||
}
|
||||
|
||||
async upsertReview(restaurantId, userExternalId, rating, content) {
|
||||
const restaurant = this.restaurants.find((item) => item.id === restaurantId);
|
||||
if (!restaurant) return null;
|
||||
|
||||
const user = this.#getOrCreateUser(userExternalId);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const existing = this.reviews.find(
|
||||
(review) => review.restaurantId === restaurantId && review.providerUserId === userExternalId,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
existing.rating = rating;
|
||||
existing.content = content;
|
||||
existing.createdAt = now;
|
||||
existing.userNickname = user.nickName;
|
||||
|
||||
return {
|
||||
id: existing.id,
|
||||
restaurantId,
|
||||
userExternalId,
|
||||
nickName: user.nickName,
|
||||
rating,
|
||||
content,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
mine: true,
|
||||
};
|
||||
}
|
||||
|
||||
const created = {
|
||||
id: uuidv4(),
|
||||
restaurantId,
|
||||
providerUserId: userExternalId,
|
||||
userNickname: user.nickName,
|
||||
rating,
|
||||
content,
|
||||
createdAt: now,
|
||||
};
|
||||
this.reviews.push(created);
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
restaurantId,
|
||||
userExternalId,
|
||||
nickName: user.nickName,
|
||||
rating,
|
||||
content,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
mine: true,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteReview(reviewId, userExternalId) {
|
||||
const index = this.reviews.findIndex((item) => item.id === reviewId);
|
||||
if (index < 0) return { deleted: false, reason: 'NOT_FOUND' };
|
||||
|
||||
if (this.reviews[index].providerUserId !== userExternalId) {
|
||||
return { deleted: false, reason: 'FORBIDDEN' };
|
||||
}
|
||||
|
||||
this.reviews.splice(index, 1);
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
async toggleLike(restaurantId, userExternalId) {
|
||||
const restaurant = this.restaurants.find((item) => item.id === restaurantId);
|
||||
if (!restaurant) return null;
|
||||
|
||||
const index = this.likes.findIndex(
|
||||
(item) => item.restaurantId === restaurantId && item.providerUserId === userExternalId,
|
||||
);
|
||||
|
||||
let liked = false;
|
||||
if (index >= 0) {
|
||||
this.likes.splice(index, 1);
|
||||
} else {
|
||||
this.likes.push({ restaurantId, providerUserId: userExternalId });
|
||||
liked = true;
|
||||
}
|
||||
|
||||
const likeCount = this.likes.filter((item) => item.restaurantId === restaurantId).length;
|
||||
return { liked, likeCount };
|
||||
}
|
||||
|
||||
async getMyReviews(userExternalId) {
|
||||
return this.reviews
|
||||
.filter((item) => item.providerUserId === userExternalId)
|
||||
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
restaurantId: item.restaurantId,
|
||||
userExternalId,
|
||||
nickName: item.userNickname,
|
||||
rating: item.rating,
|
||||
content: item.content,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.createdAt,
|
||||
mine: true,
|
||||
}));
|
||||
}
|
||||
|
||||
#getOrCreateUser(userExternalId) {
|
||||
const found = this.users.find((u) => u.providerUserId === userExternalId);
|
||||
if (found) return found;
|
||||
|
||||
const created = {
|
||||
id: uuidv4(),
|
||||
providerUserId: userExternalId,
|
||||
nickName: userExternalId === 'demo-user-1' ? '나' : '사용자',
|
||||
};
|
||||
this.users.push(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
#toRestaurantListItem(restaurant, userExternalId, lat, lng) {
|
||||
const restaurantReviews = this.reviews.filter((item) => item.restaurantId === restaurant.id);
|
||||
const reviewCount = restaurantReviews.length;
|
||||
const ratingSum = restaurantReviews.reduce((sum, item) => sum + item.rating, 0);
|
||||
const averageRating = reviewCount === 0 ? 0 : roundTo1(ratingSum / reviewCount);
|
||||
const likeCount = this.likes.filter((item) => item.restaurantId === restaurant.id).length;
|
||||
|
||||
return {
|
||||
id: restaurant.id,
|
||||
name: restaurant.name,
|
||||
category: restaurant.category,
|
||||
distanceMeters: Math.round(haversineMeters(lat, lng, restaurant.lat, restaurant.lng)),
|
||||
averageRating,
|
||||
reviewCount,
|
||||
likeCount,
|
||||
likedByMe: this.likes.some(
|
||||
(item) => item.restaurantId === restaurant.id && item.providerUserId === userExternalId,
|
||||
),
|
||||
address: restaurant.address,
|
||||
phone: restaurant.phone,
|
||||
lat: restaurant.lat,
|
||||
lng: restaurant.lng,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class MySQLStore {
|
||||
constructor(pool) {
|
||||
this.pool = pool;
|
||||
}
|
||||
|
||||
async bootstrap() {
|
||||
const [rows] = await this.pool.query('select count(*) as cnt from restaurant');
|
||||
if ((rows[0]?.cnt ?? 0) > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const user of seedUsers) {
|
||||
await this.pool.query(
|
||||
`insert into app_user (id, provider, provider_user_id, nick_name)
|
||||
values (?, 'toss', ?, ?)
|
||||
on duplicate key update nick_name = values(nick_name)`,
|
||||
[user.id, user.providerUserId, user.nickName],
|
||||
);
|
||||
}
|
||||
|
||||
for (const restaurant of seedRestaurants) {
|
||||
await this.pool.query(
|
||||
`insert into restaurant (id, external_place_id, name, category, address, phone, lat, lng, is_active)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, 1)
|
||||
on duplicate key update
|
||||
name = values(name),
|
||||
category = values(category),
|
||||
address = values(address),
|
||||
phone = values(phone),
|
||||
lat = values(lat),
|
||||
lng = values(lng),
|
||||
is_active = 1`,
|
||||
[
|
||||
restaurant.id,
|
||||
`seed-${restaurant.id}`,
|
||||
restaurant.name,
|
||||
restaurant.category,
|
||||
restaurant.address,
|
||||
restaurant.phone,
|
||||
restaurant.lat,
|
||||
restaurant.lng,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
for (const review of seedReviews) {
|
||||
const user = seedUsers.find((u) => u.providerUserId === review.providerUserId);
|
||||
if (!user) continue;
|
||||
|
||||
await this.pool.query(
|
||||
`insert into restaurant_review (id, restaurant_id, user_id, rating, content, created_at, updated_at, deleted_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, null)
|
||||
on duplicate key update
|
||||
rating = values(rating),
|
||||
content = values(content),
|
||||
updated_at = values(updated_at),
|
||||
deleted_at = null`,
|
||||
[
|
||||
review.id,
|
||||
review.restaurantId,
|
||||
user.id,
|
||||
review.rating,
|
||||
review.content,
|
||||
review.createdAt,
|
||||
review.createdAt,
|
||||
],
|
||||
);
|
||||
await this.#refreshStat(review.restaurantId);
|
||||
}
|
||||
|
||||
for (const like of seedLikes) {
|
||||
const user = seedUsers.find((u) => u.providerUserId === like.providerUserId);
|
||||
if (!user) continue;
|
||||
await this.pool.query(
|
||||
`insert ignore into restaurant_like (restaurant_id, user_id) values (?, ?)`,
|
||||
[like.restaurantId, user.id],
|
||||
);
|
||||
await this.#refreshStat(like.restaurantId);
|
||||
}
|
||||
}
|
||||
|
||||
async getNearby({ userExternalId, lat, lng, radiusMeters, category, sort }) {
|
||||
const user = await this.#getOrCreateUser(userExternalId);
|
||||
const params = [user.id];
|
||||
let whereClause = 'where r.is_active = 1';
|
||||
|
||||
if (category && CATEGORY_SET.has(category)) {
|
||||
whereClause += ' and r.category = ?';
|
||||
params.push(category);
|
||||
}
|
||||
|
||||
const [rows] = await this.pool.query(
|
||||
`select
|
||||
r.id,
|
||||
r.name,
|
||||
r.category,
|
||||
r.address,
|
||||
r.phone,
|
||||
r.lat,
|
||||
r.lng,
|
||||
coalesce(s.review_count, 0) as reviewCount,
|
||||
coalesce(s.like_count, 0) as likeCount,
|
||||
coalesce(s.average_rating, 0.00) as averageRating,
|
||||
case when ul.user_id is null then 0 else 1 end as likedByMe
|
||||
from restaurant r
|
||||
left join restaurant_stat s on s.restaurant_id = r.id
|
||||
left join restaurant_like ul on ul.restaurant_id = r.id and ul.user_id = ?
|
||||
${whereClause}`,
|
||||
params,
|
||||
);
|
||||
|
||||
let items = rows
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
category: row.category,
|
||||
address: row.address,
|
||||
phone: row.phone,
|
||||
lat: Number(row.lat),
|
||||
lng: Number(row.lng),
|
||||
distanceMeters: Math.round(haversineMeters(lat, lng, Number(row.lat), Number(row.lng))),
|
||||
averageRating: Number(row.averageRating),
|
||||
reviewCount: Number(row.reviewCount),
|
||||
likeCount: Number(row.likeCount),
|
||||
likedByMe: Number(row.likedByMe) === 1,
|
||||
}))
|
||||
.filter((item) => item.distanceMeters <= radiusMeters);
|
||||
|
||||
return sortItems(items, sort);
|
||||
}
|
||||
|
||||
async getRestaurantById(restaurantId, userExternalId) {
|
||||
const user = await this.#getOrCreateUser(userExternalId);
|
||||
const [rows] = await this.pool.query(
|
||||
`select
|
||||
r.id,
|
||||
r.name,
|
||||
r.category,
|
||||
r.address,
|
||||
r.phone,
|
||||
r.lat,
|
||||
r.lng,
|
||||
coalesce(s.review_count, 0) as reviewCount,
|
||||
coalesce(s.like_count, 0) as likeCount,
|
||||
coalesce(s.average_rating, 0.00) as averageRating,
|
||||
case when ul.user_id is null then 0 else 1 end as likedByMe
|
||||
from restaurant r
|
||||
left join restaurant_stat s on s.restaurant_id = r.id
|
||||
left join restaurant_like ul on ul.restaurant_id = r.id and ul.user_id = ?
|
||||
where r.id = ? and r.is_active = 1
|
||||
limit 1`,
|
||||
[user.id, restaurantId],
|
||||
);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const row = rows[0];
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
category: row.category,
|
||||
address: row.address,
|
||||
phone: row.phone,
|
||||
lat: Number(row.lat),
|
||||
lng: Number(row.lng),
|
||||
distanceMeters: 0,
|
||||
averageRating: Number(row.averageRating),
|
||||
reviewCount: Number(row.reviewCount),
|
||||
likeCount: Number(row.likeCount),
|
||||
likedByMe: Number(row.likedByMe) === 1,
|
||||
};
|
||||
}
|
||||
|
||||
async getReviews(restaurantId, userExternalId) {
|
||||
const user = await this.#getOrCreateUser(userExternalId);
|
||||
const [rows] = await this.pool.query(
|
||||
`select
|
||||
rr.id,
|
||||
rr.restaurant_id as restaurantId,
|
||||
rr.rating,
|
||||
rr.content,
|
||||
rr.created_at as createdAt,
|
||||
rr.updated_at as updatedAt,
|
||||
au.provider_user_id as userExternalId,
|
||||
au.nick_name as nickName,
|
||||
case when rr.user_id = ? then 1 else 0 end as mine
|
||||
from restaurant_review rr
|
||||
inner join app_user au on au.id = rr.user_id
|
||||
where rr.restaurant_id = ? and rr.deleted_at is null
|
||||
order by rr.created_at desc`,
|
||||
[user.id, restaurantId],
|
||||
);
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
restaurantId: row.restaurantId,
|
||||
userExternalId: row.userExternalId,
|
||||
nickName: row.nickName,
|
||||
rating: Number(row.rating),
|
||||
content: row.content,
|
||||
createdAt: toIso(row.createdAt),
|
||||
updatedAt: toIso(row.updatedAt),
|
||||
mine: Number(row.mine) === 1,
|
||||
}));
|
||||
}
|
||||
|
||||
async upsertReview(restaurantId, userExternalId, rating, content) {
|
||||
const user = await this.#getOrCreateUser(userExternalId);
|
||||
const [restaurantRows] = await this.pool.query(
|
||||
`select id from restaurant where id = ? and is_active = 1 limit 1`,
|
||||
[restaurantId],
|
||||
);
|
||||
if (restaurantRows.length === 0) return null;
|
||||
|
||||
const reviewId = uuidv4();
|
||||
await this.pool.query(
|
||||
`insert into restaurant_review
|
||||
(id, restaurant_id, user_id, rating, content, created_at, updated_at, deleted_at)
|
||||
values
|
||||
(?, ?, ?, ?, ?, current_timestamp, current_timestamp, null)
|
||||
on duplicate key update
|
||||
rating = values(rating),
|
||||
content = values(content),
|
||||
deleted_at = null,
|
||||
updated_at = current_timestamp`,
|
||||
[reviewId, restaurantId, user.id, rating, content],
|
||||
);
|
||||
|
||||
await this.#refreshStat(restaurantId);
|
||||
|
||||
const [rows] = await this.pool.query(
|
||||
`select rr.id, rr.rating, rr.content, rr.created_at as createdAt, rr.updated_at as updatedAt
|
||||
from restaurant_review rr
|
||||
where rr.restaurant_id = ? and rr.user_id = ? and rr.deleted_at is null
|
||||
limit 1`,
|
||||
[restaurantId, user.id],
|
||||
);
|
||||
|
||||
const row = rows[0];
|
||||
return {
|
||||
id: row.id,
|
||||
restaurantId,
|
||||
userExternalId,
|
||||
nickName: user.nickName,
|
||||
rating: Number(row.rating),
|
||||
content: row.content,
|
||||
createdAt: toIso(row.createdAt),
|
||||
updatedAt: toIso(row.updatedAt),
|
||||
mine: true,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteReview(reviewId, userExternalId) {
|
||||
const user = await this.#getOrCreateUser(userExternalId);
|
||||
const [targetRows] = await this.pool.query(
|
||||
`select restaurant_id as restaurantId, user_id as userId
|
||||
from restaurant_review
|
||||
where id = ? and deleted_at is null
|
||||
limit 1`,
|
||||
[reviewId],
|
||||
);
|
||||
|
||||
if (targetRows.length === 0) {
|
||||
return { deleted: false, reason: 'NOT_FOUND' };
|
||||
}
|
||||
|
||||
const target = targetRows[0];
|
||||
if (target.userId !== user.id) {
|
||||
return { deleted: false, reason: 'FORBIDDEN' };
|
||||
}
|
||||
|
||||
await this.pool.query(`delete from restaurant_review where id = ?`, [reviewId]);
|
||||
await this.#refreshStat(target.restaurantId);
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
async toggleLike(restaurantId, userExternalId) {
|
||||
const user = await this.#getOrCreateUser(userExternalId);
|
||||
|
||||
const [restaurantRows] = await this.pool.query(
|
||||
`select id from restaurant where id = ? and is_active = 1 limit 1`,
|
||||
[restaurantId],
|
||||
);
|
||||
if (restaurantRows.length === 0) return null;
|
||||
|
||||
const [insertResult] = await this.pool.query(
|
||||
`insert ignore into restaurant_like (restaurant_id, user_id) values (?, ?)`,
|
||||
[restaurantId, user.id],
|
||||
);
|
||||
|
||||
let liked = true;
|
||||
if ((insertResult.affectedRows ?? 0) === 0) {
|
||||
await this.pool.query(
|
||||
`delete from restaurant_like where restaurant_id = ? and user_id = ?`,
|
||||
[restaurantId, user.id],
|
||||
);
|
||||
liked = false;
|
||||
}
|
||||
|
||||
await this.#refreshStat(restaurantId);
|
||||
|
||||
const [rows] = await this.pool.query(
|
||||
`select count(*) as cnt from restaurant_like where restaurant_id = ?`,
|
||||
[restaurantId],
|
||||
);
|
||||
|
||||
return {
|
||||
liked,
|
||||
likeCount: Number(rows[0]?.cnt ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async getMyReviews(userExternalId) {
|
||||
const user = await this.#getOrCreateUser(userExternalId);
|
||||
const [rows] = await this.pool.query(
|
||||
`select
|
||||
rr.id,
|
||||
rr.restaurant_id as restaurantId,
|
||||
rr.rating,
|
||||
rr.content,
|
||||
rr.created_at as createdAt,
|
||||
rr.updated_at as updatedAt,
|
||||
au.nick_name as nickName
|
||||
from restaurant_review rr
|
||||
inner join app_user au on au.id = rr.user_id
|
||||
where rr.user_id = ? and rr.deleted_at is null
|
||||
order by rr.created_at desc`,
|
||||
[user.id],
|
||||
);
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
restaurantId: row.restaurantId,
|
||||
userExternalId,
|
||||
nickName: row.nickName,
|
||||
rating: Number(row.rating),
|
||||
content: row.content,
|
||||
createdAt: toIso(row.createdAt),
|
||||
updatedAt: toIso(row.updatedAt),
|
||||
mine: true,
|
||||
}));
|
||||
}
|
||||
|
||||
async #getOrCreateUser(userExternalId) {
|
||||
const [rows] = await this.pool.query(
|
||||
`select id, nick_name as nickName
|
||||
from app_user
|
||||
where provider = 'toss' and provider_user_id = ?
|
||||
limit 1`,
|
||||
[userExternalId],
|
||||
);
|
||||
|
||||
if (rows.length > 0) {
|
||||
return { id: rows[0].id, nickName: rows[0].nickName };
|
||||
}
|
||||
|
||||
const createdId = uuidv4();
|
||||
const nickName = userExternalId === 'demo-user-1' ? '나' : '사용자';
|
||||
await this.pool.query(
|
||||
`insert into app_user (id, provider, provider_user_id, nick_name)
|
||||
values (?, 'toss', ?, ?)`,
|
||||
[createdId, userExternalId, nickName],
|
||||
);
|
||||
|
||||
return { id: createdId, nickName };
|
||||
}
|
||||
|
||||
async #refreshStat(restaurantId) {
|
||||
await this.pool.query(`call refresh_restaurant_stat(?)`, [restaurantId]);
|
||||
}
|
||||
}
|
||||
|
||||
async function createStore() {
|
||||
const mysqlEnabled = Boolean(process.env.MYSQL_HOST || process.env.MYSQL_URL);
|
||||
if (!mysqlEnabled) {
|
||||
return {
|
||||
mode: 'memory',
|
||||
store: new MemoryStore(),
|
||||
};
|
||||
}
|
||||
|
||||
const pool = process.env.MYSQL_URL
|
||||
? mysql.createPool(process.env.MYSQL_URL)
|
||||
: mysql.createPool({
|
||||
host: process.env.MYSQL_HOST,
|
||||
port: Number(process.env.MYSQL_PORT || 3306),
|
||||
user: process.env.MYSQL_USER,
|
||||
password: process.env.MYSQL_PASSWORD,
|
||||
database: process.env.MYSQL_DATABASE,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
});
|
||||
|
||||
const store = new MySQLStore(pool);
|
||||
await store.bootstrap();
|
||||
|
||||
return {
|
||||
mode: 'mysql',
|
||||
store,
|
||||
};
|
||||
}
|
||||
|
||||
function sortItems(items, sort) {
|
||||
const copied = [...items];
|
||||
|
||||
if (sort === 'rating') {
|
||||
return copied.sort((a, b) => {
|
||||
if (b.averageRating === a.averageRating) {
|
||||
return b.reviewCount - a.reviewCount;
|
||||
}
|
||||
return b.averageRating - a.averageRating;
|
||||
});
|
||||
}
|
||||
|
||||
if (sort === 'likes') {
|
||||
return copied.sort((a, b) => {
|
||||
if (b.likeCount === a.likeCount) {
|
||||
return a.distanceMeters - b.distanceMeters;
|
||||
}
|
||||
return b.likeCount - a.likeCount;
|
||||
});
|
||||
}
|
||||
|
||||
return copied.sort((a, b) => a.distanceMeters - b.distanceMeters);
|
||||
}
|
||||
|
||||
function roundTo1(value) {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
function haversineMeters(lat1, lng1, lat2, lng2) {
|
||||
const toRad = (value) => (value * Math.PI) / 180;
|
||||
const earthRadius = 6371000;
|
||||
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLng = toRad(lng2 - lng1);
|
||||
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
|
||||
Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
||||
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return earthRadius * c;
|
||||
}
|
||||
|
||||
function toIso(dateLike) {
|
||||
return new Date(dateLike).toISOString();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createStore,
|
||||
};
|
||||
Reference in New Issue
Block a user