- 생일 카드 컴포넌트 추가 (PC/모바일) - 생일 폭죽(confetti) 애니메이션 적용 (하루에 한 번) - 생일 상세 페이지 추가 (/birthday/멤버이름/년도) - 관리자 일정 페이지에 생일 표시 (수정/삭제 버튼 숨김) - 일정 상세 페이지 404 에러 UI 개선 - 일정 상세 페이지 불필요한 재시도 방지 (retry: false) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
292 lines
8.4 KiB
JavaScript
292 lines
8.4 KiB
JavaScript
import express from "express";
|
|
import pool from "../lib/db.js";
|
|
import { searchSchedules } from "../services/meilisearch.js";
|
|
import { saveSearchQuery, getSuggestions } from "../services/suggestions.js";
|
|
import { getXProfile } from "../services/x-bot.js";
|
|
|
|
const router = express.Router();
|
|
|
|
// 검색어 추천 API (Bi-gram 기반)
|
|
router.get("/suggestions", async (req, res) => {
|
|
try {
|
|
const { q, limit } = req.query;
|
|
|
|
if (!q || q.trim().length === 0) {
|
|
return res.json({ suggestions: [] });
|
|
}
|
|
|
|
const suggestions = await getSuggestions(q, parseInt(limit) || 10);
|
|
res.json({ suggestions });
|
|
} catch (error) {
|
|
console.error("추천 검색어 오류:", error);
|
|
res.status(500).json({ error: "추천 검색어 조회 중 오류가 발생했습니다." });
|
|
}
|
|
});
|
|
|
|
// 공개 일정 목록 조회 (검색 포함)
|
|
router.get("/", async (req, res) => {
|
|
try {
|
|
const { search, startDate, endDate, limit, year, month } = req.query;
|
|
|
|
// 검색어가 있으면 Meilisearch 사용
|
|
if (search && search.trim()) {
|
|
const offset = parseInt(req.query.offset) || 0;
|
|
const pageLimit = parseInt(req.query.limit) || 100;
|
|
|
|
// 첫 페이지 검색 시에만 검색어 저장 (bi-gram 학습)
|
|
if (offset === 0) {
|
|
saveSearchQuery(search.trim()).catch((err) =>
|
|
console.error("검색어 저장 실패:", err.message)
|
|
);
|
|
}
|
|
|
|
// Meilisearch에서 큰 limit으로 검색 (유사도 필터링 후 클라이언트 페이징)
|
|
const results = await searchSchedules(search.trim(), {
|
|
limit: 1000, // 내부적으로 1000개까지 검색
|
|
});
|
|
|
|
// 페이징 적용
|
|
const paginatedHits = results.hits.slice(offset, offset + pageLimit);
|
|
|
|
return res.json({
|
|
schedules: paginatedHits,
|
|
total: results.total,
|
|
offset: offset,
|
|
limit: pageLimit,
|
|
hasMore: offset + paginatedHits.length < results.total,
|
|
});
|
|
}
|
|
|
|
// 날짜 필터 및 제한 조건 구성
|
|
let whereClause = "WHERE 1=1";
|
|
const params = [];
|
|
|
|
// 년/월 필터링 (월별 데이터 로딩용)
|
|
if (year && month) {
|
|
whereClause += " AND YEAR(s.date) = ? AND MONTH(s.date) = ?";
|
|
params.push(parseInt(year), parseInt(month));
|
|
} else if (year) {
|
|
whereClause += " AND YEAR(s.date) = ?";
|
|
params.push(parseInt(year));
|
|
}
|
|
|
|
if (startDate) {
|
|
whereClause += " AND s.date >= ?";
|
|
params.push(startDate);
|
|
}
|
|
if (endDate) {
|
|
whereClause += " AND s.date <= ?";
|
|
params.push(endDate);
|
|
}
|
|
|
|
// limit 파라미터 처리
|
|
const limitClause = limit ? `LIMIT ${parseInt(limit)}` : "";
|
|
|
|
// 검색어 없으면 DB에서 전체 조회
|
|
const [schedules] = await pool.query(
|
|
`
|
|
SELECT
|
|
s.id,
|
|
s.title,
|
|
s.description,
|
|
s.date,
|
|
s.time,
|
|
s.category_id,
|
|
s.source_url,
|
|
s.source_name,
|
|
s.location_name,
|
|
c.name as category_name,
|
|
c.color as category_color,
|
|
GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ',') as member_names
|
|
FROM schedules s
|
|
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
|
LEFT JOIN schedule_members sm ON s.id = sm.schedule_id
|
|
LEFT JOIN members m ON sm.member_id = m.id
|
|
${whereClause}
|
|
GROUP BY s.id
|
|
ORDER BY s.date ASC, s.time ASC
|
|
${limitClause}
|
|
`,
|
|
params
|
|
);
|
|
|
|
// 년/월 필터가 있으면 해당 월의 현재 멤버 생일을 가상 일정으로 추가
|
|
if (year && month) {
|
|
const [birthdays] = await pool.query(
|
|
`SELECT id, name, name_en, birth_date, image_url
|
|
FROM members
|
|
WHERE is_former = 0 AND MONTH(birth_date) = ?`,
|
|
[parseInt(month)]
|
|
);
|
|
|
|
const birthdaySchedules = birthdays.map((member) => {
|
|
const birthDate = new Date(member.birth_date);
|
|
const birthdayThisYear = new Date(
|
|
parseInt(year),
|
|
birthDate.getMonth(),
|
|
birthDate.getDate()
|
|
);
|
|
|
|
return {
|
|
id: `birthday-${member.id}`,
|
|
title: `HAPPY ${member.name_en} DAY`,
|
|
description: null,
|
|
date: birthdayThisYear,
|
|
time: null,
|
|
category_id: 8,
|
|
source_url: null,
|
|
source_name: null,
|
|
location_name: null,
|
|
category_name: "생일",
|
|
category_color: "#f472b6",
|
|
member_names: member.name,
|
|
is_birthday: true,
|
|
member_image: member.image_url,
|
|
};
|
|
});
|
|
|
|
// 일정과 생일을 합쳐서 날짜순 정렬
|
|
const allSchedules = [...schedules, ...birthdaySchedules].sort(
|
|
(a, b) => new Date(a.date) - new Date(b.date)
|
|
);
|
|
|
|
return res.json(allSchedules);
|
|
}
|
|
|
|
res.json(schedules);
|
|
} catch (error) {
|
|
console.error("일정 목록 조회 오류:", error);
|
|
res.status(500).json({ error: "일정 목록 조회 중 오류가 발생했습니다." });
|
|
}
|
|
});
|
|
|
|
// 카테고리 목록 조회
|
|
router.get("/categories", async (req, res) => {
|
|
try {
|
|
const [categories] = await pool.query(`
|
|
SELECT id, name, color, sort_order
|
|
FROM schedule_categories
|
|
ORDER BY sort_order ASC
|
|
`);
|
|
|
|
res.json(categories);
|
|
} catch (error) {
|
|
console.error("카테고리 조회 오류:", error);
|
|
res.status(500).json({ error: "카테고리 조회 중 오류가 발생했습니다." });
|
|
}
|
|
});
|
|
|
|
// 개별 일정 조회
|
|
router.get("/:id", async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
const [schedules] = await pool.query(
|
|
`
|
|
SELECT
|
|
s.*,
|
|
c.name as category_name,
|
|
c.color as category_color
|
|
FROM schedules s
|
|
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
|
WHERE s.id = ?
|
|
`,
|
|
[id]
|
|
);
|
|
|
|
if (schedules.length === 0) {
|
|
return res.status(404).json({ error: "일정을 찾을 수 없습니다." });
|
|
}
|
|
|
|
const schedule = schedules[0];
|
|
|
|
// 이미지 조회
|
|
const [images] = await pool.query(
|
|
`SELECT image_url FROM schedule_images WHERE schedule_id = ? ORDER BY sort_order ASC`,
|
|
[id]
|
|
);
|
|
schedule.images = images.map((img) => img.image_url);
|
|
|
|
// 멤버 조회
|
|
const [members] = await pool.query(
|
|
`SELECT m.id, m.name FROM members m
|
|
JOIN schedule_members sm ON m.id = sm.member_id
|
|
WHERE sm.schedule_id = ?
|
|
ORDER BY m.id`,
|
|
[id]
|
|
);
|
|
schedule.members = members;
|
|
|
|
// 콘서트 카테고리(id=6)인 경우 같은 제목의 관련 일정들도 조회
|
|
if (schedule.category_id === 6) {
|
|
const [relatedSchedules] = await pool.query(
|
|
`
|
|
SELECT id, date, time
|
|
FROM schedules
|
|
WHERE title = ? AND category_id = 6
|
|
ORDER BY date ASC, time ASC
|
|
`,
|
|
[schedule.title]
|
|
);
|
|
schedule.related_dates = relatedSchedules;
|
|
}
|
|
|
|
res.json(schedule);
|
|
} catch (error) {
|
|
console.error("일정 조회 오류:", error);
|
|
res.status(500).json({ error: "일정 조회 중 오류가 발생했습니다." });
|
|
}
|
|
});
|
|
|
|
// Meilisearch 동기화 API
|
|
router.post("/sync-search", async (req, res) => {
|
|
try {
|
|
const { syncAllSchedules } = await import("../services/meilisearch.js");
|
|
|
|
// DB에서 모든 일정 조회
|
|
const [schedules] = await pool.query(`
|
|
SELECT
|
|
s.id,
|
|
s.title,
|
|
s.description,
|
|
s.date,
|
|
s.time,
|
|
s.category_id,
|
|
s.source_url,
|
|
s.source_name,
|
|
c.name as category_name,
|
|
c.color as category_color,
|
|
GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ',') as member_names
|
|
FROM schedules s
|
|
LEFT JOIN schedule_categories c ON s.category_id = c.id
|
|
LEFT JOIN schedule_members sm ON s.id = sm.schedule_id
|
|
LEFT JOIN members m ON sm.member_id = m.id
|
|
GROUP BY s.id
|
|
`);
|
|
|
|
const count = await syncAllSchedules(schedules);
|
|
res.json({ success: true, synced: count });
|
|
} catch (error) {
|
|
console.error("Meilisearch 동기화 오류:", error);
|
|
res.status(500).json({ error: "동기화 중 오류가 발생했습니다." });
|
|
}
|
|
});
|
|
|
|
// X 프로필 정보 조회
|
|
router.get("/x-profile/:username", async (req, res) => {
|
|
try {
|
|
const { username } = req.params;
|
|
const profile = await getXProfile(username);
|
|
|
|
if (!profile) {
|
|
return res.status(404).json({ error: "프로필을 찾을 수 없습니다." });
|
|
}
|
|
|
|
res.json(profile);
|
|
} catch (error) {
|
|
console.error("X 프로필 조회 오류:", error);
|
|
res.status(500).json({ error: "프로필 조회 중 오류가 발생했습니다." });
|
|
}
|
|
});
|
|
|
|
export default router;
|