Files
air-watcher/src/dashboardApi.js
2026-03-05 11:00:45 +09:00

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