chore: 현재 작업 중간 커밋
This commit is contained in:
@@ -6,6 +6,7 @@ const {
|
||||
normalizeAlertOn,
|
||||
parseTargetPrice,
|
||||
} = require("./alertRules");
|
||||
const { TelegramNotifier } = require("./notifier");
|
||||
const { createHttpError, parseBoolean } = require("./dashboardUtils");
|
||||
const { extractFlightSearchRequest } = require("./llmParameterExtractor");
|
||||
|
||||
@@ -21,8 +22,58 @@ 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 = {}) {
|
||||
async function parseInput(body = {}, context = {}) {
|
||||
requireUser(context);
|
||||
const input = readInput(body);
|
||||
|
||||
return extractFlightSearchRequest(input, {
|
||||
@@ -30,37 +81,129 @@ function createDashboardApi({ watcher, store }) {
|
||||
});
|
||||
}
|
||||
|
||||
async function createWatch(body = {}) {
|
||||
const extracted = await parseInput(body);
|
||||
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 watchId = watcher.addWatch({
|
||||
rawInput: input,
|
||||
searchParams: extracted.params,
|
||||
alertRules,
|
||||
pollingEnabled: parseBoolean(body.pollingEnabled, true),
|
||||
alertsEnabled: parseBoolean(body.alertsEnabled, true),
|
||||
});
|
||||
const paramVariations = [];
|
||||
const baseParams = extracted.params;
|
||||
const window = baseParams.departureDateWindow;
|
||||
const stay = baseParams.stayDurationDays;
|
||||
|
||||
const created = watcher.getWatch(watchId);
|
||||
await store.saveWatch(created);
|
||||
|
||||
if (parseBoolean(body.pollNow, false)) {
|
||||
await watcher.pollWatch(watchId);
|
||||
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(watchId),
|
||||
watch: watcher.getWatch(firstWatchId),
|
||||
parserSource: extracted.source,
|
||||
createdCount: paramVariations.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function updateSystem(body = {}) {
|
||||
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),
|
||||
@@ -69,35 +212,30 @@ function createDashboardApi({ watcher, store }) {
|
||||
return { controls };
|
||||
}
|
||||
|
||||
function listWatches() {
|
||||
function listWatches(context = {}) {
|
||||
const user = requireUser(context);
|
||||
const allWatches = watcher.listWatches();
|
||||
return {
|
||||
controls: watcher.getGlobalControls(),
|
||||
watches: watcher.listWatches(),
|
||||
watches: filterWatchesForUser(user, allWatches),
|
||||
};
|
||||
}
|
||||
|
||||
async function listEvents(rawLimit) {
|
||||
async function listEvents(rawLimit, context = {}) {
|
||||
const user = requireUser(context);
|
||||
const limit = Number(rawLimit);
|
||||
const events = await store.listEvents(Number.isFinite(limit) ? limit : 50);
|
||||
const safeLimit = Number.isFinite(limit) ? limit : 50;
|
||||
const events = await store.listEvents(
|
||||
safeLimit,
|
||||
user.isAdmin ? {} : { ownerId: user.username }
|
||||
);
|
||||
return { events };
|
||||
}
|
||||
|
||||
async function pollWatch(watchId) {
|
||||
async function updateWatch(watchId, body = {}, context = {}) {
|
||||
const user = requireUser(context);
|
||||
const existing = watcher.getWatch(watchId);
|
||||
if (!existing) {
|
||||
throw createHttpError(404, `watch를 찾을 수 없습니다: ${watchId}`);
|
||||
}
|
||||
|
||||
const pollResult = await watcher.pollWatch(watchId);
|
||||
return {
|
||||
watch: watcher.getWatch(watchId),
|
||||
pollResult,
|
||||
};
|
||||
}
|
||||
|
||||
async function updateWatch(watchId, body = {}) {
|
||||
const existing = watcher.getWatch(watchId);
|
||||
if (!existing) {
|
||||
if (!existing || !canAccessWatch(user, existing)) {
|
||||
throw createHttpError(404, `watch를 찾을 수 없습니다: ${watchId}`);
|
||||
}
|
||||
|
||||
@@ -129,19 +267,124 @@ function createDashboardApi({ watcher, store }) {
|
||||
return { watch: updated };
|
||||
}
|
||||
|
||||
async function deleteWatch(watchId) {
|
||||
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,
|
||||
pollWatch,
|
||||
sendMyTelegramTest,
|
||||
updateMyTelegram,
|
||||
updateSystem,
|
||||
updateWatch,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user