396 lines
13 KiB
JavaScript
396 lines
13 KiB
JavaScript
"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,
|
|
};
|