"use strict"; const { buildAlertRules, inferAlertOn, normalizeAlertOn, parseTargetPrice, } = require("./alertRules"); const { TelegramNotifier } = require("./notifier"); const { createHttpError, parseBoolean } = require("./dashboardUtils"); const { extractFlightSearchRequest } = require("./llmParameterExtractor"); function readInput(body) { const input = typeof body.input === "string" ? body.input.trim() : ""; if (!input) { throw createHttpError(400, "input 문자열이 필요합니다."); } return input; } function hasOwnProperty(source, key) { return Object.prototype.hasOwnProperty.call(source, key); } function requireUser(context = {}) { const user = context.user; if (!user || typeof user.username !== "string" || !user.username.trim()) { throw createHttpError(401, "Unauthorized"); } return { username: user.username.trim(), isAdmin: user.isAdmin === true, }; } function canAccessWatch(user, watch) { if (!watch) return false; if (user.isAdmin) return true; return watch.ownerId === user.username; } function filterWatchesForUser(user, watches) { if (user.isAdmin) return watches; return watches.filter((watch) => watch.ownerId === user.username); } function toOptionalString(value) { if (value === undefined || value === null) return null; const normalized = String(value).trim(); return normalized ? normalized : null; } function maskSecret(value) { const secret = toOptionalString(value); if (!secret) return ""; if (secret.length <= 6) return `${secret.slice(0, 1)}***`; return `${secret.slice(0, 3)}***${secret.slice(-2)}`; } async function ensureUserProfile(store, username) { if (!store || typeof store.getUserProfile !== "function") { throw createHttpError(500, "store.getUserProfile is not available"); } return store.getUserProfile(username); } async function updateUserProfile(store, username, patch) { if (!store || typeof store.upsertUserProfile !== "function") { throw createHttpError(500, "store.upsertUserProfile is not available"); } return store.upsertUserProfile(username, patch); } function createDashboardApi({ watcher, store }) { async function parseInput(body = {}, context = {}) { requireUser(context); const input = readInput(body); return extractFlightSearchRequest(input, { preferRuleParser: parseBoolean(body.useLlm, true) === false, }); } async function createWatch(body = {}, context = {}) { const user = requireUser(context); const extracted = await parseInput(body, context); const input = readInput(body); // 필수 데이터 누락 시 조기 차단 (IP 차단 방지) const missing = extracted.params.missingFields || []; if (missing.includes("segments")) { throw createHttpError(400, "출발지 또는 도착지 정보가 파악되지 않았습니다. 문장을 다시 작성해주세요."); } if (missing.includes("departureDateWindow")) { throw createHttpError(400, "출발 날짜 정보가 파악되지 않았습니다. (예: '11월 25일에 출발') 문장을 구체적으로 적어주세요."); } if (extracted.params.tripType === "round_trip" && missing.includes("stayDurationDays")) { throw createHttpError(400, "왕복 여정입니다만, 체류 기간(며칠 동안 여행하는지)이 파악되지 않았습니다. (예: '12일 체류')"); } if (body.sameFlight !== undefined && extracted.params && extracted.params.constraints) { extracted.params.constraints.sameFlightForAllPassengers = parseBoolean(body.sameFlight, true); } if (body.provider && typeof body.provider === "string") { extracted.params.provider = body.provider.trim(); } const alertRules = buildAlertRules({ targetPrice: body.targetPrice, alertOn: body.alertOn || "both", }); const paramVariations = []; const baseParams = extracted.params; const window = baseParams.departureDateWindow; const stay = baseParams.stayDurationDays; let startDates = []; if (window && window.from && window.to) { const fromDate = new Date(window.from); const toDate = new Date(window.to); if (fromDate <= toDate) { const current = new Date(fromDate); while (current <= toDate) { startDates.push(current.toISOString().split("T")[0]); current.setDate(current.getDate() + 1); } } } if (startDates.length === 0 && window && window.from) { startDates.push(window.from); } let stayDays = []; if (stay && stay.minDays && stay.maxDays) { for (let i = stay.minDays; i <= stay.maxDays; i += 1) { stayDays.push(i); } } else if (stay && stay.minDays) { stayDays.push(stay.minDays); } if (startDates.length === 0) { paramVariations.push(baseParams); } else if (stayDays.length === 0) { for (const d of startDates) { const copy = JSON.parse(JSON.stringify(baseParams)); copy.departureDateWindow = { from: d, to: d }; paramVariations.push(copy); } } else { for (const d of startDates) { for (const sd of stayDays) { const copy = JSON.parse(JSON.stringify(baseParams)); copy.departureDateWindow = { from: d, to: d }; copy.stayDurationDays = { minDays: sd, maxDays: sd }; paramVariations.push(copy); } } } let firstWatchId = null; for (const params of paramVariations) { let suffix = ""; if (params.departureDateWindow?.from) suffix += params.departureDateWindow.from; if (params.stayDurationDays?.minDays) suffix += ` (체류 ${params.stayDurationDays.minDays}일)`; const watchInput = paramVariations.length > 1 && suffix ? `[${suffix}] ${input}` : input; const watchId = watcher.addWatch({ ownerId: user.username, rawInput: watchInput, searchParams: params, alertRules, pollingEnabled: parseBoolean(body.pollingEnabled, true), alertsEnabled: parseBoolean(body.alertsEnabled, true), }); const created = watcher.getWatch(watchId); await store.saveWatch(created); if (!firstWatchId) firstWatchId = watchId; } // 신규 추가 후 즉시 순차 크롤링을 백그라운드에서 트리거합니다. watcher.pollAll().catch((error) => { // eslint-disable-next-line no-console console.error("[DashboardAPI] Immediate poll failed:", error); }); return { watch: watcher.getWatch(firstWatchId), parserSource: extracted.source, createdCount: paramVariations.length, }; } async function updateSystem(body = {}, context = {}) { const user = requireUser(context); if (!user.isAdmin) { throw createHttpError(403, "관리자 계정만 시스템 전역 설정을 변경할 수 있습니다."); } const controls = watcher.setGlobalControls({ crawlingEnabled: parseBoolean(body.crawlingEnabled, watcher.getGlobalControls().crawlingEnabled), alertsEnabled: parseBoolean(body.alertsEnabled, watcher.getGlobalControls().alertsEnabled), }); await store.setGlobalControls(controls); return { controls }; } function listWatches(context = {}) { const user = requireUser(context); const allWatches = watcher.listWatches(); return { controls: watcher.getGlobalControls(), watches: filterWatchesForUser(user, allWatches), }; } async function listEvents(rawLimit, context = {}) { const user = requireUser(context); const limit = Number(rawLimit); const safeLimit = Number.isFinite(limit) ? limit : 50; const events = await store.listEvents( safeLimit, user.isAdmin ? {} : { ownerId: user.username } ); return { events }; } async function updateWatch(watchId, body = {}, context = {}) { const user = requireUser(context); const existing = watcher.getWatch(watchId); if (!existing || !canAccessWatch(user, existing)) { throw createHttpError(404, `watch를 찾을 수 없습니다: ${watchId}`); } const patch = {}; if (hasOwnProperty(body, "pollingEnabled")) { patch.pollingEnabled = parseBoolean(body.pollingEnabled, existing.pollingEnabled); } if (hasOwnProperty(body, "alertsEnabled")) { patch.alertsEnabled = parseBoolean(body.alertsEnabled, existing.alertsEnabled); } const hasAlertModePatch = hasOwnProperty(body, "alertOn") || hasOwnProperty(body, "targetPrice"); if (hasAlertModePatch) { const targetPrice = hasOwnProperty(body, "targetPrice") ? parseTargetPrice(body.targetPrice) : existing.alertRules.targetPrice; const alertOn = hasOwnProperty(body, "alertOn") ? normalizeAlertOn(body.alertOn) : inferAlertOn(existing.alertRules); patch.alertRules = buildAlertRules({ targetPrice, alertOn, }); } const updated = watcher.updateWatch(watchId, patch); await store.saveWatch(updated); return { watch: updated }; } async function deleteWatch(watchId, context = {}) { const user = requireUser(context); const existing = watcher.getWatch(watchId); if (!existing || !canAccessWatch(user, existing)) { throw createHttpError(404, `watch를 찾을 수 없습니다: ${watchId}`); } const existed = watcher.removeWatch(watchId); await store.deleteWatch(watchId); return { deleted: existed }; } async function getMe(context = {}) { const user = requireUser(context); const profile = await ensureUserProfile(store, user.username); return { user, settings: { telegramEnabled: profile.telegramEnabled === true, telegramChatId: profile.telegramChatId || "", telegramApiBase: profile.telegramApiBase || process.env.TELEGRAM_API_BASE || "", hasTelegramBotToken: Boolean(profile.telegramBotToken), telegramBotTokenMasked: maskSecret(profile.telegramBotToken), }, }; } async function updateMyTelegram(body = {}, context = {}) { const user = requireUser(context); const previous = await ensureUserProfile(store, user.username); const patch = {}; if (hasOwnProperty(body, "telegramEnabled")) { patch.telegramEnabled = parseBoolean(body.telegramEnabled, previous.telegramEnabled); } if (hasOwnProperty(body, "telegramChatId")) { patch.telegramChatId = toOptionalString(body.telegramChatId); } if (hasOwnProperty(body, "telegramBotToken")) { patch.telegramBotToken = toOptionalString(body.telegramBotToken); } if (hasOwnProperty(body, "telegramApiBase")) { patch.telegramApiBase = toOptionalString(body.telegramApiBase); } const merged = { ...previous, ...patch, }; if (merged.telegramEnabled === true && !merged.telegramChatId) { throw createHttpError(400, "telegramEnabled=true이면 telegramChatId가 필요합니다."); } const updated = await updateUserProfile(store, user.username, patch); return { user, settings: { telegramEnabled: updated.telegramEnabled === true, telegramChatId: updated.telegramChatId || "", telegramApiBase: updated.telegramApiBase || process.env.TELEGRAM_API_BASE || "", hasTelegramBotToken: Boolean(updated.telegramBotToken), telegramBotTokenMasked: maskSecret(updated.telegramBotToken), }, }; } async function sendMyTelegramTest(context = {}) { const user = requireUser(context); const profile = await ensureUserProfile(store, user.username); const botToken = profile.telegramBotToken || process.env.TELEGRAM_BOT_TOKEN || process.env.NOTIFY_TELEGRAM_BOT_TOKEN; const chatId = profile.telegramChatId; if (!botToken || !chatId) { throw createHttpError( 400, "테스트 전송에 필요한 값이 부족합니다. telegramBotToken(또는 전역 TELEGRAM_BOT_TOKEN)과 telegramChatId를 설정하세요." ); } const notifier = new TelegramNotifier({ botToken, chatId, apiBase: profile.telegramApiBase || process.env.TELEGRAM_API_BASE, }); await notifier.notify({ watchId: "telegram-setup-test", eventType: "test", currentBestPrice: 0, previousBestPrice: null, threshold: null, currency: "KRW", rawInput: `[${user.username}] 텔레그램 설정 테스트`, observedAt: new Date().toISOString(), bestOffer: { provider: "setup", }, }); return { ok: true, message: "텔레그램 테스트 메시지를 전송했습니다.", }; } return { createWatch, deleteWatch, getMe, listEvents, listWatches, parseInput, sendMyTelegramTest, updateMyTelegram, updateSystem, updateWatch, }; } module.exports = { createDashboardApi, };