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

734 lines
21 KiB
JavaScript

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,
};