Files
ai_platform/server.js

4292 lines
159 KiB
JavaScript

require("dotenv").config({ quiet: true });
const express = require("express");
const OpenAI = require("openai").default;
const Anthropic = require("@anthropic-ai/sdk").default;
const { GoogleGenAI } = require("@google/genai");
const path = require("path");
const fs = require("fs/promises");
const fsSync = require("fs");
const { execFile } = require("child_process");
const { promisify } = require("util");
const multer = require("multer");
const cookieParser = require("cookie-parser");
const JSZip = require("jszip");
const { Pool } = require("pg");
const { v4: uuidv4 } = require("uuid");
const meetingMinutesLib = require("./lib/meeting-minutes");
const meetingAiStore = require("./lib/meeting-minutes-store");
const taskChecklistStore = require("./lib/task-checklist-store");
const parseChecklistFromMinutes = require("./lib/parse-checklist-from-minutes");
const { formatMeetingDateOnly } = require("./lib/meeting-date-format");
const {
normalizeOpsState,
isOpsStateDev,
isOpsStateProd,
isOpsStateSuper,
} = require("./lib/ops-state");
const { fetchOpenGraphImageUrl } = require("./lib/link-preview");
const app = express();
const PORT = process.env.PORT || 8030;
/** 로컬 전용으로만 열 때: HOST=127.0.0.1 (기본은 모든 인터페이스) */
const HOST = process.env.HOST || "0.0.0.0";
/** 강의 목록 페이지당 개수. `.env`에 PAGE_SIZE가 있으면 그 값이 우선(미설정 시 9) */
const PAGE_SIZE = Number(process.env.PAGE_SIZE || 9);
const ADMIN_TOKEN = (process.env.ADMIN_TOKEN || "xavis-admin").trim();
const ENABLE_PPT_THUMBNAIL = process.env.ENABLE_PPT_THUMBNAIL !== "0";
const THUMBNAIL_WIDTH = Number(process.env.THUMBNAIL_WIDTH || 1000);
const THUMBNAIL_MAX_RETRY = Number(process.env.THUMBNAIL_MAX_RETRY || 2);
const THUMBNAIL_RETRY_DELAY_MS = Number(process.env.THUMBNAIL_RETRY_DELAY_MS || 5000);
const THUMBNAIL_EVENT_KEEP = Number(process.env.THUMBNAIL_EVENT_KEEP || 200);
const THUMBNAIL_EVENT_PAGE_SIZE = Number(process.env.THUMBNAIL_EVENT_PAGE_SIZE || 50);
const ENABLE_POSTGRES = process.env.ENABLE_POSTGRES !== "0";
/** 회의 음성 업로드 최대 크기(MB). OpenAI 전사는 요청당 약 25MB이므로 그보다 큰 파일은 lib/meeting-minutes에서 ffmpeg 분할 후 전사 */
const MEETING_AUDIO_MAX_MB_RAW = Number(process.env.MEETING_AUDIO_MAX_MB);
const MEETING_AUDIO_MAX_MB = Number.isFinite(MEETING_AUDIO_MAX_MB_RAW) && MEETING_AUDIO_MAX_MB_RAW > 0
? Math.min(500, Math.max(25, MEETING_AUDIO_MAX_MB_RAW))
: 300;
const MEETING_AUDIO_MAX_BYTES = Math.floor(MEETING_AUDIO_MAX_MB * 1024 * 1024);
const OPENAI_API_KEY = (process.env.OPENAI_API_KEY || "").trim();
/** OpenAI Responses API의 내장 `web_search` 도구 사용. `OPENAI_WEB_SEARCH=0`이면 기존 Chat Completions만 사용 */
const OPENAI_WEB_SEARCH = process.env.OPENAI_WEB_SEARCH !== "0";
const CLAUDE_API_KEY = process.env.CLAUDE_API_KEY || "";
const GENAI_API_KEY = process.env.GENAI_API_KEY || "";
const ROOT_DIR = __dirname;
const DATA_DIR = path.join(ROOT_DIR, "data");
const COMPANY_PROMPTS_PATH = path.join(DATA_DIR, "company-prompts.json");
function loadCompanyPrompts() {
try {
return JSON.parse(fsSync.readFileSync(COMPANY_PROMPTS_PATH, "utf8"));
} catch (e) {
console.error("company-prompts.json load failed:", e.message);
return [];
}
}
function loadAiSuccessStoriesMeta() {
try {
const raw = fsSync.readFileSync(AI_SUCCESS_META_PATH, "utf8");
const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr : [];
} catch {
return [];
}
}
function saveAiSuccessStoriesMeta(list) {
fsSync.mkdirSync(path.dirname(AI_SUCCESS_META_PATH), { recursive: true });
fsSync.mkdirSync(AI_SUCCESS_CONTENT_DIR, { recursive: true });
fsSync.writeFileSync(AI_SUCCESS_META_PATH, JSON.stringify(list, null, 2), "utf8");
}
function loadStoryBodyMarkdown(meta) {
if (!meta) return "";
if (typeof meta.bodyMarkdown === "string" && meta.bodyMarkdown.length) return meta.bodyMarkdown;
if (meta.contentFile) {
const safeName = path.basename(meta.contentFile);
try {
return fsSync.readFileSync(path.join(AI_SUCCESS_CONTENT_DIR, safeName), "utf8");
} catch {
return "";
}
}
return "";
}
function enrichAiSuccessStory(meta) {
return { ...meta, bodyMarkdown: loadStoryBodyMarkdown(meta) };
}
/** 본문 md 파일명: 슬러그 + 생성 시각(ms)으로 파일명 충돌 가능성 완화 */
function buildAiSuccessStoryContentFileName(slug) {
const s = String(slug || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9\-]/g, "");
const base = s || "story";
return `${base}-${Date.now()}.md`;
}
function filterAiSuccessStories(list, q, tag) {
let out = [...list];
const qLower = (q || "").trim().toLowerCase();
if (qLower) {
out = out.filter((s) => {
return (
(s.title || "").toLowerCase().includes(qLower) ||
(s.excerpt || "").toLowerCase().includes(qLower) ||
(s.department || "").toLowerCase().includes(qLower) ||
(s.tags || []).some((t) => String(t).toLowerCase().includes(qLower))
);
});
}
const tagTrim = (tag || "").trim();
if (tagTrim) {
out = out.filter((s) => (s.tags || []).includes(tagTrim));
}
out.sort((a, b) => {
const da = new Date(a.publishedAt || a.updatedAt || a.createdAt || 0);
const db = new Date(b.publishedAt || b.updatedAt || b.createdAt || 0);
return db - da;
});
return out;
}
function allAiSuccessStoryTags(list) {
const set = new Set();
list.forEach((s) => (s.tags || []).forEach((t) => set.add(t)));
return Array.from(set).sort();
}
function requireAdminApi(req, res, next) {
if (!res.locals.adminMode) {
res.status(403).json({ error: "관리자만 사용할 수 있습니다." });
return;
}
next();
}
const TMP_DIR = path.join(DATA_DIR, "tmp");
const UPLOAD_DIR = path.join(ROOT_DIR, "uploads");
const THUMBNAIL_DIR = path.join(UPLOAD_DIR, "thumbnails");
const SLIDES_DIR = path.join(UPLOAD_DIR, "slides");
const RESOURCES_LECTURE_DIR = path.join(ROOT_DIR, "resources", "lecture");
const RESOURCES_AX_APPLY_DIR = path.join(ROOT_DIR, "resources", "ax-apply");
const AX_APPLY_UPLOAD_DIR = path.join(UPLOAD_DIR, "ax-apply");
const MEETING_MINUTES_UPLOAD_DIR = path.join(UPLOAD_DIR, "meeting-minutes");
const LECTURE_DB_PATH = path.join(DATA_DIR, "lectures.json");
const AX_ASSIGNMENTS_DB_PATH = path.join(DATA_DIR, "ax-assignments.json");
const THUMBNAIL_JOB_DB_PATH = path.join(DATA_DIR, "thumbnail-jobs.json");
const THUMBNAIL_EVENT_DB_PATH = path.join(DATA_DIR, "thumbnail-events.json");
const thumbnailEventsStore = require("./lib/thumbnail-events-store");
const AI_SUCCESS_META_PATH = path.join(DATA_DIR, "ai-success-stories.json");
const AI_SUCCESS_CONTENT_DIR = path.join(DATA_DIR, "ai-success-stories");
const DB_SCHEMA_PATH = path.join(ROOT_DIR, "db", "schema.sql");
const execFileAsync = promisify(execFile);
const thumbnailQueue = [];
const queuedLectureIds = new Set();
let thumbnailWorkerRunning = false;
let pgPool = null;
const readThumbnailJobDb = async () => {
try {
const raw = await fs.readFile(THUMBNAIL_JOB_DB_PATH, "utf-8");
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
const writeThumbnailJobDb = async () => {
const snapshot = thumbnailQueue.map((job) => ({
lectureId: job.lectureId,
force: job.force === true,
reason: job.reason || "manual",
enqueuedAt: job.enqueuedAt || Date.now(),
}));
await fs.writeFile(THUMBNAIL_JOB_DB_PATH, JSON.stringify(snapshot, null, 2), "utf-8");
};
const readThumbnailEventDb = async () =>
thumbnailEventsStore.readThumbnailEvents(pgPool, THUMBNAIL_EVENT_DB_PATH);
const writeThumbnailEventDb = async (events) => {
if (Array.isArray(events) && events.length === 0) {
return thumbnailEventsStore.clearThumbnailEvents(pgPool, THUMBNAIL_EVENT_DB_PATH);
}
throw new Error("썸네일 이벤트는 DB/파일 전체 덮어쓰기를 지원하지 않습니다. clear([])만 사용하세요.");
};
const appendThumbnailEvent = async (payload) =>
thumbnailEventsStore.appendThumbnailEvent(pgPool, THUMBNAIL_EVENT_DB_PATH, THUMBNAIL_EVENT_KEEP, payload);
const getPgConfig = () => ({
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT || 5432),
database: process.env.DB_DATABASE,
user: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
ssl: process.env.DB_SSL === "1" ? { rejectUnauthorized: false } : false,
});
const assertPgConfig = () => {
if (!ENABLE_POSTGRES) return;
const config = getPgConfig();
const missing = ["host", "database", "user", "password"].filter((key) => !config[key]);
if (missing.length) {
throw new Error(`PostgreSQL env 누락: ${missing.join(", ")}`);
}
};
const initPostgres = async () => {
if (!ENABLE_POSTGRES) return;
try {
assertPgConfig();
pgPool = new Pool(getPgConfig());
await pgPool.query("SELECT 1");
const schemaSql = await fs.readFile(DB_SCHEMA_PATH, "utf-8");
await pgPool.query(schemaSql);
console.log("PostgreSQL 연결 및 스키마 적용 완료.");
} catch (err) {
console.warn("PostgreSQL 연결 실패, 파일 저장소로 폴백:", err.message);
pgPool = null;
}
};
function getClientIpForAudit(req) {
const xf = (req.headers["x-forwarded-for"] || "").split(",")[0].trim();
if (xf) return xf.slice(0, 45);
const ra = req.socket?.remoteAddress || req.connection?.remoteAddress;
return typeof ra === "string" ? ra.slice(0, 45) : null;
}
async function recordOpsEmailAuthEvent({ email, eventType, req, returnTo = null }) {
if (!pgPool || !email) return;
const e = String(email).trim().toLowerCase().slice(0, 320);
const ua = (req.headers["user-agent"] || "").slice(0, 4000) || null;
const ip = getClientIpForAudit(req);
try {
await pgPool.query(
`INSERT INTO ops_email_auth_events (email, event_type, ip_address, user_agent, return_to)
VALUES ($1, $2, $3, $4, $5)`,
[e, eventType, ip, ua, returnTo ? String(returnTo).slice(0, 2000) : null]
);
} catch (err) {
console.error("[ops-email-auth] record failed:", err.message);
}
}
async function upsertOpsEmailUserOnLogin(email) {
if (!pgPool || !email) return;
const e = String(email).trim().toLowerCase().slice(0, 320);
try {
await pgPool.query(
`INSERT INTO ops_email_users (email, first_seen_at, last_login_at, login_count)
VALUES ($1, NOW(), NOW(), 1)
ON CONFLICT (email) DO UPDATE SET
last_login_at = NOW(),
login_count = ops_email_users.login_count + 1`,
[e]
);
} catch (err) {
console.error("[ops-email-users] upsert failed:", err.message);
}
}
const opsAuth = require("./ops-auth")(DATA_DIR, {
onMagicLinkRequested: async ({ email, returnTo, req }) => {
await recordOpsEmailAuthEvent({
email,
eventType: "magic_link_requested",
req,
returnTo,
});
},
onLoginSuccess: async ({ email, returnTo, req }) => {
await recordOpsEmailAuthEvent({
email,
eventType: "login_success",
req,
returnTo,
});
await upsertOpsEmailUserOnLogin(email);
},
onLogout: async ({ email, req }) => {
if (email) {
await recordOpsEmailAuthEvent({
email,
eventType: "logout",
req,
});
}
},
});
const syncLecturesToPostgres = async (lectures) => {
if (!ENABLE_POSTGRES || !pgPool) return;
const client = await pgPool.connect();
try {
await client.query("BEGIN");
for (const lecture of lectures) {
await client.query(
`INSERT INTO lectures (
id, type, title, description, tags, youtube_url, file_name, original_name,
preview_title, slide_count, thumbnail_url, thumbnail_status, thumbnail_retry_count,
thumbnail_error, thumbnail_updated_at, created_at, list_section, news_url
) VALUES (
$1::uuid, $2, $3, $4, $5::text[], $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18
)
ON CONFLICT (id) DO UPDATE SET
type = EXCLUDED.type,
title = EXCLUDED.title,
description = EXCLUDED.description,
tags = EXCLUDED.tags,
youtube_url = EXCLUDED.youtube_url,
file_name = EXCLUDED.file_name,
original_name = EXCLUDED.original_name,
preview_title = EXCLUDED.preview_title,
slide_count = EXCLUDED.slide_count,
thumbnail_url = EXCLUDED.thumbnail_url,
thumbnail_status = EXCLUDED.thumbnail_status,
thumbnail_retry_count = EXCLUDED.thumbnail_retry_count,
thumbnail_error = EXCLUDED.thumbnail_error,
thumbnail_updated_at = EXCLUDED.thumbnail_updated_at,
list_section = EXCLUDED.list_section,
news_url = EXCLUDED.news_url`,
[
lecture.id,
lecture.type,
lecture.title,
lecture.description || "",
Array.isArray(lecture.tags) ? lecture.tags : [],
lecture.youtubeUrl || null,
lecture.fileName || null,
lecture.originalName || null,
lecture.previewTitle || null,
typeof lecture.slideCount === "number" ? lecture.slideCount : 0,
lecture.thumbnailUrl || null,
lecture.thumbnailStatus || "pending",
typeof lecture.thumbnailRetryCount === "number" ? lecture.thumbnailRetryCount : 0,
lecture.thumbnailError || null,
lecture.thumbnailUpdatedAt ? new Date(lecture.thumbnailUpdatedAt) : null,
lecture.createdAt ? new Date(lecture.createdAt) : new Date(),
lecture.listSection || "learning",
lecture.newsUrl || null,
]
);
}
if (lectures.length > 0) {
await client.query("DELETE FROM lectures WHERE id <> ALL($1::uuid[])", [lectures.map((lecture) => lecture.id)]);
} else {
await client.query("DELETE FROM lectures");
}
await client.query("COMMIT");
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
};
const escapeCsv = (value) => {
const str = String(value ?? "");
if (str.includes('"') || str.includes(",") || str.includes("\n")) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
};
const buildEventFilter = (query) => {
const eventType = (query.eventType || "all").toString();
const lectureId = (query.lectureId || "").toString().trim();
const reason = (query.reason || "").toString().trim();
const from = (query.from || "").toString().trim();
const to = (query.to || "").toString().trim();
const fromMs = from ? new Date(from).getTime() : null;
const toMs = to ? new Date(`${to}T23:59:59.999`).getTime() : null;
return {
eventType,
lectureId,
reason,
from,
to,
matches: (evt) => {
if (eventType !== "all" && evt.type !== eventType) return false;
if (lectureId && !String(evt.lectureId || "").includes(lectureId)) return false;
if (reason && !String(evt.reason || "").includes(reason)) return false;
const atMs = new Date(evt.at || "").getTime();
if (Number.isFinite(fromMs) && Number.isFinite(atMs) && atMs < fromMs) return false;
if (Number.isFinite(toMs) && Number.isFinite(atMs) && atMs > toMs) return false;
return true;
},
};
};
const storage = multer.diskStorage({
destination: (_, __, cb) => cb(null, UPLOAD_DIR),
filename: (_, file, cb) => {
const ext = (path.extname(file.originalname) || ".pptx").toLowerCase();
const allowed = [".pptx", ".pdf"];
const finalExt = allowed.includes(ext) ? ext : ".pptx";
cb(null, `${Date.now()}-${uuidv4()}${finalExt}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 50 * 1024 * 1024 },
fileFilter: (_, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (ext !== ".pptx" && ext !== ".pdf") {
cb(new Error("PDF(.pdf) 또는 PowerPoint(.pptx) 파일만 업로드할 수 있습니다."));
return;
}
cb(null, true);
},
});
const LECTURE_VIDEO_ALLOWED_EXT = [".mp4", ".webm", ".mov"];
const lectureVideoMaxBytes =
Math.max(1, Number(process.env.LECTURE_VIDEO_MAX_MB) || 500) * 1024 * 1024;
const videoStorage = multer.diskStorage({
destination: (_, __, cb) => cb(null, UPLOAD_DIR),
filename: (_, file, cb) => {
const ext = (path.extname(file.originalname) || ".mp4").toLowerCase();
const finalExt = LECTURE_VIDEO_ALLOWED_EXT.includes(ext) ? ext : ".mp4";
cb(null, `${Date.now()}-${uuidv4()}${finalExt}`);
},
});
const uploadVideo = multer({
storage: videoStorage,
limits: { fileSize: lectureVideoMaxBytes },
fileFilter: (_, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (!LECTURE_VIDEO_ALLOWED_EXT.includes(ext)) {
cb(new Error("동영상은 MP4, WebM, MOV 형식만 업로드할 수 있습니다."));
return;
}
cb(null, true);
},
});
const axApplyStorage = multer.diskStorage({
destination: (_, __, cb) => {
fsSync.mkdirSync(AX_APPLY_UPLOAD_DIR, { recursive: true });
cb(null, AX_APPLY_UPLOAD_DIR);
},
filename: (_, file, cb) => {
const ext = (path.extname(file.originalname) || ".docx").toLowerCase();
const finalExt = ext === ".docx" || ext === ".doc" ? ".docx" : ".docx";
cb(null, `${Date.now()}-${uuidv4()}${finalExt}`);
},
});
const uploadAxApply = multer({
storage: axApplyStorage,
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (_, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (ext !== ".docx" && ext !== ".doc") {
cb(new Error("Word 문서(.docx, .doc) 파일만 업로드할 수 있습니다."));
return;
}
cb(null, true);
},
});
const AI_SUCCESS_PUBLIC_PDF_DIR = path.join(ROOT_DIR, "public", "resources", "ai-success");
const aiSuccessPdfStorage = multer.diskStorage({
destination: (_, __, cb) => {
fsSync.mkdirSync(AI_SUCCESS_PUBLIC_PDF_DIR, { recursive: true });
cb(null, AI_SUCCESS_PUBLIC_PDF_DIR);
},
filename: (_, __, cb) => {
cb(null, `${Date.now()}-${uuidv4()}.pdf`);
},
});
const uploadAiSuccessPdf = multer({
storage: aiSuccessPdfStorage,
limits: { fileSize: 50 * 1024 * 1024 },
fileFilter: (_, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (ext !== ".pdf") {
cb(new Error("PDF(.pdf) 파일만 업로드할 수 있습니다."));
return;
}
cb(null, true);
},
});
const meetingMinutesAudioStorage = multer.diskStorage({
destination: (req, _, cb) => {
const email = req.meetingUserEmail || "unknown";
const safe = String(email).replace(/[^a-zA-Z0-9@._-]/g, "_").slice(0, 200);
const dir = path.join(MEETING_MINUTES_UPLOAD_DIR, safe);
fsSync.mkdirSync(dir, { recursive: true });
cb(null, dir);
},
filename: (req, file, cb) => {
const email = req.meetingUserEmail || "user";
const safe = String(email).replace(/[^a-zA-Z0-9@._-]/g, "_").slice(0, 200);
const now = new Date();
const p = (n) => String(n).padStart(2, "0");
const yyyyMMdd = `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}`;
const HHmmss = `${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
const ext = (path.extname(file.originalname) || ".webm").toLowerCase();
cb(null, `${safe}_${yyyyMMdd}_${HHmmss}${ext}`);
},
});
const uploadMeetingAudio = multer({
storage: meetingMinutesAudioStorage,
limits: { fileSize: MEETING_AUDIO_MAX_BYTES },
fileFilter: (_, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const allowed = [".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm", ".ogg", ".flac"];
if (!allowed.includes(ext)) {
cb(new Error(`지원 음성 형식: ${allowed.join(", ")}`));
return;
}
cb(null, true);
},
});
const mapRowToAxAssignment = (row) => ({
id: row.id,
department: row.department || "",
name: row.name || "",
employeeId: row.employee_id || "",
position: row.position || "",
phone: row.phone || "",
email: row.email || "",
workProcessDescription: row.work_process_description || "",
painPoint: row.pain_point || "",
currentTimeSpent: row.current_time_spent || "",
errorRateBefore: row.error_rate_before || "",
collaborationDepts: row.collaboration_depts || "",
reasonToSolve: row.reason_to_solve || "",
aiExpectation: row.ai_expectation || "",
outputType: row.output_type || "",
automationLevel: row.automation_level || "",
dataReadiness: row.data_readiness || "",
dataLocation: row.data_location || "",
personalInfo: row.personal_info || "",
dataQuality: row.data_quality || "",
dataCount: row.data_count || "",
dataTypes: Array.isArray(row.data_types) ? row.data_types : [],
timeReduction: row.time_reduction || "",
errorReduction: row.error_reduction || "",
volumeIncrease: row.volume_increase || "",
costReduction: row.cost_reduction || "",
responseTime: row.response_time || "",
otherMetrics: row.other_metrics || "",
annualSavings: row.annual_savings || "",
laborReplacement: row.labor_replacement || "",
revenueIncrease: row.revenue_increase || "",
otherEffects: row.other_effects || "",
qualitativeEffects: Array.isArray(row.qualitative_effects) ? row.qualitative_effects : [],
techStack: Array.isArray(row.tech_stack) ? row.tech_stack : [],
risks: Array.isArray(row.risks) ? row.risks : [],
riskDetail: row.risk_detail || "",
participationPledge: row.participation_pledge === true,
applicationFile: row.application_file || "",
status: row.status || "신청",
createdAt: row.created_at ? new Date(row.created_at).toISOString() : new Date().toISOString(),
updatedAt: row.updated_at ? new Date(row.updated_at).toISOString() : new Date().toISOString(),
});
const readAxAssignmentsDb = async () => {
if (pgPool) {
try {
const res = await pgPool.query(
"SELECT id, department, name, employee_id, position, phone, email, work_process_description, pain_point, current_time_spent, error_rate_before, collaboration_depts, reason_to_solve, ai_expectation, output_type, automation_level, data_readiness, data_location, personal_info, data_quality, data_count, data_types, time_reduction, error_reduction, volume_increase, cost_reduction, response_time, other_metrics, annual_savings, labor_replacement, revenue_increase, other_effects, qualitative_effects, tech_stack, risks, risk_detail, participation_pledge, application_file, status, created_at, updated_at FROM ax_assignments ORDER BY created_at DESC"
);
return (res.rows || []).map(mapRowToAxAssignment);
} catch (err) {
console.error("readAxAssignmentsDb from PostgreSQL failed:", err.message);
return [];
}
}
try {
const raw = await fs.readFile(AX_ASSIGNMENTS_DB_PATH, "utf-8");
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
const readAxAssignmentsByCredentials = async (department, name, email) => {
const d = (department || "").trim().toLowerCase();
const n = (name || "").trim().toLowerCase();
const e = (email || "").trim().toLowerCase();
if (!d || !n || !e) return [];
const all = await readAxAssignmentsDb();
return all.filter(
(a) =>
(a.department || "").trim().toLowerCase() === d &&
(a.name || "").trim().toLowerCase() === n &&
(a.email || "").trim().toLowerCase() === e
);
};
const isSampleRecord = (a) =>
(a?.department || "").trim() === "샘플" && (a?.name || "").trim() === "데이터";
const ensureStringArray = (val) => {
if (val === "" || val === null || val === undefined) return [];
if (Array.isArray(val)) return val.filter((x) => typeof x === "string");
if (typeof val === "string" && val.trim()) return val.split(",").map((s) => s.trim()).filter(Boolean);
return [];
};
const toPgArray = (arr) => {
if (arr === "" || arr === null || arr === undefined) return null;
const a = ensureStringArray(arr);
return a.length > 0 ? a : null;
};
const readAxAssignmentById = async (id) => {
if (!id) return null;
if (pgPool) {
try {
const res = await pgPool.query(
"SELECT id, department, name, employee_id, position, phone, email, work_process_description, pain_point, current_time_spent, error_rate_before, collaboration_depts, reason_to_solve, ai_expectation, output_type, automation_level, data_readiness, data_location, personal_info, data_quality, data_count, data_types, time_reduction, error_reduction, volume_increase, cost_reduction, response_time, other_metrics, annual_savings, labor_replacement, revenue_increase, other_effects, qualitative_effects, tech_stack, risks, risk_detail, participation_pledge, application_file, status, created_at, updated_at FROM ax_assignments WHERE id = $1",
[id]
);
return res.rows?.[0] ? mapRowToAxAssignment(res.rows[0]) : null;
} catch {
return null;
}
}
try {
const raw = await fs.readFile(AX_ASSIGNMENTS_DB_PATH, "utf-8");
const parsed = JSON.parse(raw);
const list = Array.isArray(parsed) ? parsed : [];
const found = list.find((a) => a.id === id);
return found ? { ...found, applicationFile: found.applicationFile || found.application_file || "" } : null;
} catch {
return null;
}
};
async function updateAxAssignmentDb(id, data) {
const existing = await readAxAssignmentById(id);
if (!existing) return null;
const existingAppFile = (existing.applicationFile || existing.application_file || "").trim();
const appFile = (data.applicationFile || data.application_file || "").trim();
const finalAppFile = appFile || existingAppFile;
const cleanArr = (v) => (v === "" || typeof v === "string" ? undefined : v);
const dataTypes = cleanArr(data.dataTypes ?? data.data_types) ?? existing.dataTypes;
const qualitativeEffects = cleanArr(data.qualitativeEffects ?? data.qualitative_effects) ?? existing.qualitativeEffects;
const techStack = cleanArr(data.techStack ?? data.techStackStr ?? data.tech_stack) ?? existing.techStack;
const risks = cleanArr(data.risks ?? data.risksStr ?? data.risks) ?? existing.risks;
const row = {
id,
department: ((data.department ?? existing.department) || "").trim(),
name: ((data.name ?? existing.name) || "").trim(),
employeeId: ((data.employeeId ?? data.employee_id ?? existing.employeeId) || "").trim(),
position: ((data.position ?? existing.position) || "").trim(),
phone: ((data.phone ?? existing.phone) || "").trim(),
email: ((data.email ?? existing.email) || "").trim(),
workProcessDescription: ((data.workProcessDescription ?? data.work_process_description ?? existing.workProcessDescription) || "").trim(),
painPoint: ((data.painPoint ?? data.pain_point ?? existing.painPoint) || "").trim(),
currentTimeSpent: ((data.currentTimeSpent ?? data.current_time_spent ?? existing.currentTimeSpent) || "").trim(),
errorRateBefore: ((data.errorRateBefore ?? data.error_rate_before ?? existing.errorRateBefore) || "").trim(),
collaborationDepts: ((data.collaborationDepts ?? data.collaboration_depts ?? existing.collaborationDepts) || "").trim(),
reasonToSolve: ((data.reasonToSolve ?? data.reason_to_solve ?? existing.reasonToSolve) || "").trim(),
aiExpectation: ((data.aiExpectation ?? data.ai_expectation ?? existing.aiExpectation) || "").trim(),
outputType: ((data.outputType ?? data.output_type ?? existing.outputType) || "").trim(),
automationLevel: ((data.automationLevel ?? data.automation_level ?? existing.automationLevel) || "").trim(),
dataReadiness: ((data.dataReadiness ?? data.data_readiness ?? existing.dataReadiness) || "").trim(),
dataLocation: ((data.dataLocation ?? data.data_location ?? existing.dataLocation) || "").trim(),
personalInfo: ((data.personalInfo ?? data.personal_info ?? existing.personalInfo) || "").trim(),
dataQuality: ((data.dataQuality ?? data.data_quality ?? existing.dataQuality) || "").trim(),
dataCount: ((data.dataCount ?? data.data_count ?? existing.dataCount) || "").trim(),
dataTypes: toPgArray(dataTypes),
timeReduction: ((data.timeReduction ?? data.time_reduction ?? existing.timeReduction) || "").trim(),
errorReduction: ((data.errorReduction ?? data.error_reduction ?? existing.errorReduction) || "").trim(),
volumeIncrease: ((data.volumeIncrease ?? data.volume_increase ?? existing.volumeIncrease) || "").trim(),
costReduction: ((data.costReduction ?? data.cost_reduction ?? existing.costReduction) || "").trim(),
responseTime: ((data.responseTime ?? data.response_time ?? existing.responseTime) || "").trim(),
otherMetrics: ((data.otherMetrics ?? data.other_metrics ?? existing.otherMetrics) || "").trim(),
annualSavings: ((data.annualSavings ?? data.annual_savings ?? existing.annualSavings) || "").trim(),
laborReplacement: ((data.laborReplacement ?? data.labor_replacement ?? existing.laborReplacement) || "").trim(),
revenueIncrease: ((data.revenueIncrease ?? data.revenue_increase ?? existing.revenueIncrease) || "").trim(),
otherEffects: ((data.otherEffects ?? data.other_effects ?? existing.otherEffects) || "").trim(),
qualitativeEffects: toPgArray(qualitativeEffects),
techStack: toPgArray(techStack),
risks: toPgArray(risks),
riskDetail: ((data.riskDetail ?? data.risk_detail ?? existing.riskDetail) || "").trim(),
participationPledge: data.participationPledge === true || data.participation_pledge === true,
applicationFile: finalAppFile,
status: existing.status || "신청",
createdAt: existing.createdAt,
updatedAt: new Date().toISOString(),
};
if (pgPool) {
const toArrayLiteralSafe = (v) => {
if (v === "" || (typeof v === "string") || v == null) return "'{}'";
const arr = Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
if (arr.length === 0) return "'{}'";
const escaped = arr.map((s) => `"${String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`);
return `'{${escaped.join(",")}}'`;
};
const dataTypesLit = toArrayLiteralSafe(row.dataTypes);
const qualitativeLit = toArrayLiteralSafe(row.qualitativeEffects);
const techStackLit = toArrayLiteralSafe(row.techStack);
const risksLit = toArrayLiteralSafe(row.risks);
try {
await pgPool.query(
`UPDATE ax_assignments SET
department = $2, name = $3, employee_id = $4, position = $5, phone = $6, email = $7,
work_process_description = $8, pain_point = $9, current_time_spent = $10, error_rate_before = $11,
collaboration_depts = $12, reason_to_solve = $13, ai_expectation = $14, output_type = $15, automation_level = $16,
data_readiness = $17, data_location = $18, personal_info = $19, data_quality = $20, data_count = $21, data_types = ${dataTypesLit}::text[],
time_reduction = $22, error_reduction = $23, volume_increase = $24, cost_reduction = $25, response_time = $26,
other_metrics = $27, annual_savings = $28, labor_replacement = $29, revenue_increase = $30, other_effects = $31,
qualitative_effects = ${qualitativeLit}::text[], tech_stack = ${techStackLit}::text[], risks = ${risksLit}::text[],
risk_detail = $32, participation_pledge = $33, application_file = $34, updated_at = $35
WHERE id = $1::uuid`,
[
row.id, row.department, row.name, row.employeeId, row.position, row.phone, row.email,
row.workProcessDescription, row.painPoint, row.currentTimeSpent, row.errorRateBefore,
row.collaborationDepts, row.reasonToSolve, row.aiExpectation, row.outputType, row.automationLevel,
row.dataReadiness, row.dataLocation, row.personalInfo, row.dataQuality, row.dataCount,
row.timeReduction, row.errorReduction, row.volumeIncrease, row.costReduction, row.responseTime,
row.otherMetrics, row.annualSavings, row.laborReplacement, row.revenueIncrease, row.otherEffects,
row.riskDetail, row.participationPledge, row.applicationFile || null,
new Date(row.updatedAt),
]
);
} catch (err) {
console.error("updateAxAssignmentDb failed:", err.message);
throw err;
}
} else {
const raw = await fs.readFile(AX_ASSIGNMENTS_DB_PATH, "utf-8");
const list = Array.isArray(JSON.parse(raw)) ? JSON.parse(raw) : [];
const idx = list.findIndex((a) => a.id === id);
if (idx >= 0) {
list[idx] = { ...list[idx], ...row };
await fs.writeFile(AX_ASSIGNMENTS_DB_PATH, JSON.stringify(list, null, 2), "utf-8");
}
}
return row;
};
const createAxAssignmentDb = async (data) => {
const id = uuidv4();
const now = new Date().toISOString();
const cleanArr = (v) => (v === "" || typeof v === "string" ? undefined : v);
const dataTypes = cleanArr(data.dataTypes ?? data.data_types);
const qualitativeEffects = cleanArr(data.qualitativeEffects ?? data.qualitative_effects);
const techStack = cleanArr(data.techStack ?? data.techStackStr ?? data.tech_stack);
const risks = cleanArr(data.risks ?? data.risksStr);
const row = {
id,
department: (data.department || "").trim(),
name: (data.name || "").trim(),
employeeId: (data.employeeId || data.employee_id || "").trim(),
position: (data.position || "").trim(),
phone: (data.phone || "").trim(),
email: (data.email || "").trim(),
workProcessDescription: (data.workProcessDescription || data.work_process_description || "").trim(),
painPoint: (data.painPoint || data.pain_point || "").trim(),
currentTimeSpent: (data.currentTimeSpent || data.current_time_spent || "").trim(),
errorRateBefore: (data.errorRateBefore || data.error_rate_before || "").trim(),
collaborationDepts: (data.collaborationDepts || data.collaboration_depts || "").trim(),
reasonToSolve: (data.reasonToSolve || data.reason_to_solve || "").trim(),
aiExpectation: (data.aiExpectation || data.ai_expectation || "").trim(),
outputType: (data.outputType || data.output_type || "").trim(),
automationLevel: (data.automationLevel || data.automation_level || "").trim(),
dataReadiness: (data.dataReadiness || data.data_readiness || "").trim(),
dataLocation: (data.dataLocation || data.data_location || "").trim(),
personalInfo: (data.personalInfo || data.personal_info || "").trim(),
dataQuality: (data.dataQuality || data.data_quality || "").trim(),
dataCount: (data.dataCount || data.data_count || "").trim(),
dataTypes: toPgArray(dataTypes),
timeReduction: (data.timeReduction || data.time_reduction || "").trim(),
errorReduction: (data.errorReduction || data.error_reduction || "").trim(),
volumeIncrease: (data.volumeIncrease || data.volume_increase || "").trim(),
costReduction: (data.costReduction || data.cost_reduction || "").trim(),
responseTime: (data.responseTime || data.response_time || "").trim(),
otherMetrics: (data.otherMetrics || data.other_metrics || "").trim(),
annualSavings: (data.annualSavings || data.annual_savings || "").trim(),
laborReplacement: (data.laborReplacement || data.labor_replacement || "").trim(),
revenueIncrease: (data.revenueIncrease || data.revenue_increase || "").trim(),
otherEffects: (data.otherEffects || data.other_effects || "").trim(),
qualitativeEffects: toPgArray(qualitativeEffects),
techStack: toPgArray(techStack),
risks: toPgArray(risks),
riskDetail: (data.riskDetail || data.risk_detail || "").trim(),
participationPledge: data.participationPledge === true || data.participation_pledge === true,
applicationFile: (data.applicationFile || data.application_file || "").trim(),
status: "신청",
createdAt: now,
updatedAt: now,
};
const toSafeArrayParam = (v) => {
if (v === "" || (typeof v === "string") || v === undefined) return null;
if (!Array.isArray(v)) return null;
const filtered = v.filter((x) => typeof x === "string");
return filtered.length > 0 ? filtered : null;
};
if (pgPool) {
const toArrayLiteralSafe = (v) => {
if (v === "" || (typeof v === "string") || v == null) return "'{}'";
const arr = Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
if (arr.length === 0) return "'{}'";
const escaped = arr.map((s) => `"${String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`);
return `'{${escaped.join(",")}}'`;
};
const dataTypesLit = toArrayLiteralSafe(row.dataTypes);
const qualitativeLit = toArrayLiteralSafe(row.qualitativeEffects);
const techStackLit = toArrayLiteralSafe(row.techStack);
const risksLit = toArrayLiteralSafe(row.risks);
try {
await pgPool.query(
`INSERT INTO ax_assignments (
id, department, name, employee_id, position, phone, email,
work_process_description, pain_point, current_time_spent, error_rate_before,
collaboration_depts, reason_to_solve, ai_expectation, output_type, automation_level,
data_readiness, data_location, personal_info, data_quality, data_count, data_types,
time_reduction, error_reduction, volume_increase, cost_reduction, response_time,
other_metrics, annual_savings, labor_replacement, revenue_increase, other_effects,
qualitative_effects, tech_stack, risks, risk_detail, participation_pledge, application_file, status,
created_at, updated_at
) VALUES (
$1::uuid, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16,
$17, $18, $19, $20, $21, ${dataTypesLit}::text[], $22, $23, $24, $25, $26, $27, $28, $29, $30,
$31, ${qualitativeLit}::text[], ${techStackLit}::text[], ${risksLit}::text[], $32, $33, $34, $35, $36, $37
)`,
[
row.id, row.department, row.name, row.employeeId, row.position, row.phone, row.email,
row.workProcessDescription, row.painPoint, row.currentTimeSpent, row.errorRateBefore,
row.collaborationDepts, row.reasonToSolve, row.aiExpectation, row.outputType, row.automationLevel,
row.dataReadiness, row.dataLocation, row.personalInfo, row.dataQuality, row.dataCount,
row.timeReduction, row.errorReduction, row.volumeIncrease, row.costReduction, row.responseTime,
row.otherMetrics, row.annualSavings, row.laborReplacement, row.revenueIncrease, row.otherEffects,
row.riskDetail, row.participationPledge, row.applicationFile || null, row.status,
new Date(row.createdAt), new Date(row.updatedAt),
]
);
} catch (err) {
console.error("createAxAssignmentDb failed:", err.message);
throw err;
}
} else {
const list = await readAxAssignmentsDb();
list.unshift(row);
await fs.writeFile(AX_ASSIGNMENTS_DB_PATH, JSON.stringify(list, null, 2), "utf-8");
}
return row;
};
const deleteAxAssignmentDb = async (id) => {
if (!id) return false;
if (pgPool) {
try {
const res = await pgPool.query("DELETE FROM ax_assignments WHERE id = $1::uuid", [id]);
return (res.rowCount || 0) > 0;
} catch (err) {
console.error("deleteAxAssignmentDb failed:", err.message);
throw err;
}
}
try {
const raw = await fs.readFile(AX_ASSIGNMENTS_DB_PATH, "utf-8");
const list = Array.isArray(JSON.parse(raw)) ? JSON.parse(raw) : [];
const idx = list.findIndex((a) => a.id === id);
if (idx < 0) return false;
list.splice(idx, 1);
await fs.writeFile(AX_ASSIGNMENTS_DB_PATH, JSON.stringify(list, null, 2), "utf-8");
return true;
} catch {
return false;
}
};
app.set("view engine", "ejs");
app.set("views", path.join(ROOT_DIR, "views"));
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(cookieParser());
const ADMIN_COOKIE_NAME = "admin_session";
const ADMIN_COOKIE_MAX_AGE = 24 * 60 * 60 * 1000; // 24시간
app.use((req, res, next) => {
const cookieToken = req.cookies?.[ADMIN_COOKIE_NAME] || "";
res.locals.adminMode =
ADMIN_TOKEN.length > 0 && cookieToken.length > 0 && cookieToken === ADMIN_TOKEN;
next();
});
/** GPT 채팅: SUPER 전면 허용. DEV는 관리자 토큰 세션. PROD는 이메일(OPS) 인증 세션만 */
function isChatGptAllowed(req, res) {
if (isOpsStateSuper()) return true;
if (isOpsStateDev() && res.locals.adminMode) return true;
if (isOpsStateProd() && res.locals.opsUserEmail) return true;
return false;
}
/** AI 성공 사례 상세: DEV일 때만 관리자 모드로 제한. PROD·SUPER는 상세 열람 가능 */
function isAiSuccessStoryDetailAllowed(req, res) {
if (!isOpsStateDev()) return true;
return !!res.locals.adminMode;
}
/** 과제신청: DEV에서만 관리자 제한. PROD·SUPER는 제한 없음 */
function isAxApplyDownloadSubmitAllowed(req, res) {
if (!isOpsStateDev()) return true;
return !!res.locals.adminMode;
}
/** AI 탐색(/ai-explore): DEV·비관리자일 때 프롬프트 카드만 사용 가능 */
function isAiExploreDevGuestRestricted(req, res) {
return isOpsStateDev() && !res.locals.adminMode;
}
/** OPS 이메일 세션, DEV+관리자(MEETING_DEV_EMAIL), SUPER(데모 이메일) — 회의록 AI */
function getMeetingMinutesUserEmail(req, res) {
if (res.locals.opsUserEmail) return String(res.locals.opsUserEmail).trim().toLowerCase();
if (isOpsStateDev() && res.locals.adminMode) {
return (process.env.MEETING_DEV_EMAIL || "dev@xavis.co.kr").trim().toLowerCase();
}
if (isOpsStateSuper()) {
return (process.env.MEETING_SUPER_EMAIL || process.env.MEETING_DEV_EMAIL || "demo@xavis.local")
.trim()
.toLowerCase();
}
return null;
}
const MEETING_MINUTES_ALLOWED_MODELS = new Set(["gpt-5-mini", "gpt-5.4"]);
/** YYYY-MM-DD 검증, 실패 시 { error } */
function parseMeetingDateIso(raw) {
if (raw == null || String(raw).trim() === "") return { value: null };
const s = String(raw).trim().slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return { error: "날짜는 YYYY-MM-DD 형식이어야 합니다." };
const [y, mo, d] = s.split("-").map(Number);
const dt = new Date(y, mo - 1, d);
if (dt.getFullYear() !== y || dt.getMonth() !== mo - 1 || dt.getDate() !== d) return { error: "유효하지 않은 날짜입니다." };
return { value: s };
}
function requireMeetingMinutesEmail(req, res, next) {
const email = getMeetingMinutesUserEmail(req, res);
if (!email) {
res.status(401).json({ error: "이메일 로그인이 필요합니다." });
return;
}
req.meetingUserEmail = email;
next();
}
async function ensureMeetingUserAndDefaultPrompt(email) {
await meetingAiStore.ensureUserAndDefaultPrompt(pgPool, email);
}
function mapRowToMeetingPrompt(row) {
if (!row) return null;
return {
id: row.id,
userEmail: row.user_email,
includeTitleLine: row.include_title_line !== false,
includeAttendees: row.include_attendees !== false,
includeSummary: row.include_summary !== false,
includeActionItems: row.include_action_items !== false,
includeChecklist: true,
customInstructions: row.custom_instructions || "",
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function mapRowToMeeting(row) {
if (!row) return null;
return {
id: row.id,
userEmail: row.user_email,
title: row.title || "",
sourceText: row.source_text || "",
transcriptText: row.transcript_text || "",
generatedMinutes: meetingMinutesLib.prepareMeetingMinutesForApi(row.generated_minutes || ""),
audioFilePath: row.audio_file_path || "",
audioOriginalName: row.audio_original_name || "",
chatModel: row.chat_model || "gpt-5-mini",
transcriptionModel: row.transcription_model || null,
meetingDate: row.meeting_date != null ? formatMeetingDateOnly(row.meeting_date) : null,
createdAt: row.created_at ? new Date(row.created_at).toISOString() : null,
updatedAt: row.updated_at ? new Date(row.updated_at).toISOString() : null,
checklistSnapshot: row.checklist_snapshot != null ? row.checklist_snapshot : null,
summaryText: row.summary_text != null && String(row.summary_text).trim() ? String(row.summary_text).trim() : "",
};
}
/**
* 규칙 기반 액션(번호)·체크리스트를 우선하고 LLM 추출로 보완 (제목 기준 중복 제거)
* @param {string} markdown
* @param {Array<{ title: string, detail?: string, assignee?: string|null, due_note?: string|null, completed?: boolean }>} llmItems
*/
function mergeLlmAndRuleWorkItemsForSync(markdown, llmItems) {
const rule = parseChecklistFromMinutes.parseAllRuleBasedWorkItems(markdown);
const seen = new Set();
/** @type {Array<{ title: string, detail: string, assignee: string|null, due_note: string|null, completed: boolean }>} */
const out = [];
function push(arr) {
for (const it of arr) {
const t = (it.title || "").trim().toLowerCase();
if (!t) continue;
if (seen.has(t)) continue;
seen.add(t);
out.push({
title: String(it.title).trim(),
detail: String(it.detail != null ? it.detail : "").trim(),
assignee: it.assignee != null && String(it.assignee).trim() ? String(it.assignee).trim() : null,
due_note: it.due_note != null && String(it.due_note).trim() ? String(it.due_note).trim() : null,
completed: !!it.completed,
});
}
}
push(rule);
push(llmItems || []);
return out;
}
/**
* 회의록 저장 직후: 규칙(액션·체크리스트) + LLM JSON 추출 병합 → 스냅샷 저장 → 업무 체크리스트 행 삽입
*/
async function syncAutoChecklistFromMeetingMinutes(openai, { pgPool, email, meetingId, generatedMinutes, uiModel }) {
if ((process.env.MEETING_AUTO_CHECKLIST || "1").trim() === "0") {
return { imported: 0, disabled: true };
}
const maxChars = Number(process.env.MEETING_CHECKLIST_EXTRACT_MAX_CHARS || 24000);
let slice = String(generatedMinutes || "").trim();
if (!slice) return { imported: 0 };
if (slice.length > maxChars) slice = slice.slice(slice.length - maxChars);
/** @type {Array<{ title: string, detail?: string, assignee?: string|null, due_note?: string|null, completed?: boolean }>} */
let llmItems = [];
let extractError = null;
try {
llmItems = await meetingMinutesLib.extractChecklistStructured(openai, {
minutesMarkdown: slice,
uiModel,
resolveApiModel: resolveOpenAiApiModel,
});
} catch (e) {
extractError = e?.response?.data?.error?.message || e?.message || "extract failed";
console.warn("[meeting] checklist extract:", extractError);
}
const merged = mergeLlmAndRuleWorkItemsForSync(slice, llmItems || []);
const snapshot = {
items: merged,
extractedAt: new Date().toISOString(),
extractor: "rule-actions-checklist+openai-json",
llmItemCount: (llmItems || []).length,
mergedItemCount: merged.length,
...(extractError ? { extractError } : {}),
};
try {
await meetingAiStore.updateMeetingChecklistSnapshot(pgPool, meetingId, email, snapshot);
} catch (e) {
console.warn("[meeting] checklist_snapshot update:", e?.message);
}
if (!merged.length) {
return { imported: 0, snapshot, ...(extractError ? { extractError } : {}) };
}
try {
const inserted = await taskChecklistStore.insertImportedBatch(pgPool, email, meetingId, merged);
return { imported: inserted.length, snapshot, ...(extractError ? { extractError } : {}) };
} catch (e) {
console.warn("[meeting] checklist insert batch:", e?.message);
return { imported: 0, snapshot, importError: e?.message, ...(extractError ? { extractError } : {}) };
}
}
app.use("/public", express.static(path.join(ROOT_DIR, "public")));
/** 채팅 마크다운 뷰어(marked + DOMPurify) — node_modules에서만 제공 */
app.use("/vendor/marked", express.static(path.join(ROOT_DIR, "node_modules/marked/lib")));
app.use("/vendor/dompurify", express.static(path.join(ROOT_DIR, "node_modules/dompurify/dist")));
app.use("/uploads", express.static(UPLOAD_DIR));
app.get("/resources/ax-apply/AX_과제_신청서.docx", (req, res) => {
if (!isAxApplyDownloadSubmitAllowed(req, res)) {
return res
.status(403)
.type("text/plain; charset=utf-8")
.send("로그인 후 이용 가능합니다.");
}
const fp = path.join(RESOURCES_AX_APPLY_DIR, "AX_과제_신청서.docx");
if (!fsSync.existsSync(fp)) return res.status(404).send("Not found");
res.download(fp, "AX_과제_신청서.docx");
});
app.use("/resources/ax-apply", express.static(RESOURCES_AX_APPLY_DIR));
app.use(opsAuth.middleware);
opsAuth.registerRoutes(app);
const pageRouter = express.Router();
pageRouter.get("/chat", (req, res) =>
res.render("chat", {
activeMenu: "chat",
chatGptAllowed: isChatGptAllowed(req, res),
opsState: normalizeOpsState(),
adminMode: res.locals.adminMode,
opsUserEmail: !!res.locals.opsUserEmail,
})
);
pageRouter.get("/ai-explore", (req, res) =>
res.render("ai-explore", {
activeMenu: "ai-explore",
adminMode: res.locals.adminMode,
opsUserEmail: !!res.locals.opsUserEmail,
aiExploreDevGuestRestricted: isAiExploreDevGuestRestricted(req, res),
})
);
pageRouter.get("/ai-explore/prompts", (req, res) =>
res.render("ai-prompts", { activeMenu: "ai-explore", prompts: loadCompanyPrompts() })
);
pageRouter.get("/ai-explore/meeting-minutes", (req, res) =>
res.render("meeting-minutes", {
activeMenu: "ai-explore",
adminMode: res.locals.adminMode,
meetingUserEmail: getMeetingMinutesUserEmail(req, res) || "",
opsState: normalizeOpsState(),
})
);
pageRouter.get("/ai-explore/task-checklist", (req, res) =>
res.render("task-checklist", {
activeMenu: "ai-explore",
adminMode: res.locals.adminMode,
meetingUserEmail: getMeetingMinutesUserEmail(req, res) || "",
opsState: normalizeOpsState(),
})
);
pageRouter.get("/ai-cases/write", (req, res) => {
if (!res.locals.adminMode) {
return res.status(403).send(
"<!doctype html><html lang=\"ko\"><head><meta charset=\"utf-8\"/><title>권한 없음</title></head><body><p>관리자 모드가 필요합니다. 좌측 하단 <strong>관리자</strong>에서 토큰을 입력한 뒤 다시 시도하세요.</p><p><a href=\"/ai-cases\">AI 성공 사례 목록으로</a></p></body></html>"
);
}
const editSlug = (req.query.edit || "").trim();
const meta = loadAiSuccessStoriesMeta();
let story = null;
if (editSlug) {
const m = meta.find((x) => x.slug === editSlug);
if (m) story = enrichAiSuccessStory(m);
}
res.render("ai-cases-write", {
activeMenu: "ai-cases",
adminMode: true,
story,
allStories: meta,
editSlug: editSlug || null,
});
});
pageRouter.get("/ai-cases", (req, res) => {
const q = (req.query.q || "").trim();
const tag = (req.query.tag || "").trim();
const meta = loadAiSuccessStoriesMeta();
const filtered = filterAiSuccessStories(meta, q, tag);
const tags = allAiSuccessStoryTags(meta);
res.render("ai-cases", {
activeMenu: "ai-cases",
adminMode: res.locals.adminMode,
opsUserEmail: !!res.locals.opsUserEmail,
successStoryDetailAllowed: isAiSuccessStoryDetailAllowed(req, res),
stories: filtered,
filters: { q, tag },
availableTags: tags,
});
});
pageRouter.get("/ai-cases/:slug", async (req, res, next) => {
try {
if (!isAiSuccessStoryDetailAllowed(req, res)) {
return res.status(403).send(
"<!doctype html><html lang=\"ko\"><head><meta charset=\"utf-8\"/><title>상세 열람 불가</title><link rel=\"stylesheet\" href=\"/public/styles.css\" /></head><body><div class=\"app-shell\"><div class=\"content-area\" style=\"padding:24px;max-width:560px;\"><h1>상세 열람 불가</h1><p>로그인 후 이용 가능합니다.</p><p><a href=\"/ai-cases\">AI 성공 사례 목록으로</a></p></div></div></body></html>"
);
}
const slug = (req.params.slug || "").trim();
const meta = loadAiSuccessStoriesMeta();
const m = meta.find((x) => x.slug === slug);
if (!m) return res.status(404).send("사례를 찾을 수 없습니다.");
const story = enrichAiSuccessStory(m);
const pdfUrl = (story.pdfUrl || "").trim();
let slideImageUrls = [];
let slides = [];
if (pdfUrl) {
const out = await ensureAiSuccessStorySlides(slug, pdfUrl);
slideImageUrls = out.urls || [];
slides = slideImageUrls.map(() => ({ title: "", lines: [] }));
}
res.render("ai-case-detail", {
activeMenu: "ai-cases",
adminMode: res.locals.adminMode,
story,
slideImageUrls,
slides,
});
} catch (err) {
next(err);
}
});
pageRouter.get("/ax-apply", async (req, res) => {
let assignments = [];
if (res.locals.adminMode) {
assignments = await readAxAssignmentsDb();
} else {
const all = await readAxAssignmentsDb();
const sample = all.find(isSampleRecord);
if (sample) assignments = [sample];
}
res.render("ax-apply", {
activeMenu: "ax-apply",
assignments,
adminMode: res.locals.adminMode,
axApplyDownloadSubmitAllowed: isAxApplyDownloadSubmitAllowed(req, res),
});
});
app.get("/api/ax-apply-list", async (req, res) => {
const department = (req.query.department || "").trim();
const name = (req.query.name || "").trim();
const email = (req.query.email || "").trim();
if (res.locals.adminMode) {
const list = department && name && email
? await readAxAssignmentsByCredentials(department, name, email)
: await readAxAssignmentsDb();
return res.json(list);
}
if (!department || !name || !email) {
const allEmpty = await readAxAssignmentsDb();
const sample = allEmpty.find(isSampleRecord);
return res.json(sample ? [sample] : []);
}
const list = await readAxAssignmentsByCredentials(department, name, email);
const all = await readAxAssignmentsDb();
const sample = all.find(isSampleRecord);
if (sample && !list.some((m) => m.id === sample.id)) {
list.push(sample);
}
res.json(list);
});
app.get("/api/ax-apply/:id", async (req, res) => {
const id = (req.params.id || "").trim();
if (!id) return res.status(404).json({ error: "잘못된 요청입니다." });
const department = (req.query.department || "").trim();
const name = (req.query.name || "").trim();
const email = (req.query.email || "").trim();
const one = await readAxAssignmentById(id);
if (!one) return res.status(404).json({ error: "해당 신청을 찾을 수 없습니다." });
if (!res.locals.adminMode) {
if (isSampleRecord(one)) {
return res.json(one);
}
if (!department || !name || !email) return res.status(403).json({ error: "소속 부서, 이름, 이메일을 입력해 조회한 후 수정할 수 있습니다." });
const matches = await readAxAssignmentsByCredentials(department, name, email);
if (!matches.some((m) => m.id === id)) return res.status(403).json({ error: "본인의 신청만 수정할 수 있습니다." });
}
res.json(one);
});
app.put("/api/ax-apply/:id", (req, res, next) => {
uploadAxApply.single("applicationFile")(req, res, (err) => {
if (err) return res.status(400).json({ error: err.message || "파일 업로드 실패" });
next();
});
}, async (req, res) => {
try {
if (!isAxApplyDownloadSubmitAllowed(req, res)) {
return res.status(403).json({ error: "로그인 후 이용 가능합니다." });
}
const id = (req.params.id || "").trim();
if (!id) return res.status(404).json({ error: "잘못된 요청입니다." });
const body = { ...req.body };
const department = (body.department || "").trim();
const name = (body.name || "").trim();
const email = (body.email || "").trim();
if (!res.locals.adminMode) {
const existing = await readAxAssignmentById(id);
if (existing && isSampleRecord(existing)) return res.status(403).json({ error: "샘플 데이터는 참고용으로 수정할 수 없습니다." });
if (!department || !name || !email) return res.status(403).json({ error: "소속 부서, 이름, 이메일을 모두 입력해 주세요." });
const matches = await readAxAssignmentsByCredentials(department, name, email);
if (!matches.some((m) => m.id === id)) return res.status(403).json({ error: "본인의 신청만 수정할 수 있습니다." });
}
if (body.techStackStr && typeof body.techStackStr === "string") {
body.techStack = body.techStackStr.split(",").map((s) => s.trim()).filter(Boolean);
}
if (body.risksStr && typeof body.risksStr === "string") {
body.risks = body.risksStr.split(",").map((s) => s.trim()).filter(Boolean);
}
for (const k of ["dataTypes", "data_types", "techStackStr", "tech_stack", "risksStr", "qualitativeEffects", "qualitative_effects"]) {
if (body[k] === "" || (typeof body[k] === "string" && body[k] != null)) delete body[k];
}
if (body.techStack && !Array.isArray(body.techStack)) delete body.techStack;
if (body.risks && !Array.isArray(body.risks)) delete body.risks;
if (!email) return res.status(400).json({ error: "이메일을 입력해 주세요." });
if (req.file) body.applicationFile = `/uploads/ax-apply/${req.file.filename}`;
body.participationPledge = body.participationPledge === "1" || body.participationPledge === true;
const row = await updateAxAssignmentDb(id, body);
if (!row) return res.status(404).json({ error: "해당 신청을 찾을 수 없습니다." });
res.json({ ok: true, id: row.id });
} catch (err) {
const msg = err?.message || "저장 실패";
if (err instanceof ReferenceError && /updateAxAssignmentDb/.test(String(msg))) {
return res.status(500).json({ error: "서버를 재시작한 후 다시 시도해 주세요. (코드 반영이 필요할 수 있습니다.)" });
}
res.status(500).json({ error: msg });
}
});
app.delete("/api/ax-apply/:id", async (req, res) => {
if (!res.locals.adminMode) return res.status(403).json({ error: "관리자만 삭제할 수 있습니다." });
try {
const id = (req.params.id || "").trim();
if (!id) return res.status(404).json({ error: "잘못된 요청입니다." });
const ok = await deleteAxAssignmentDb(id);
if (!ok) return res.status(404).json({ error: "해당 신청을 찾을 수 없습니다." });
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: err?.message || "삭제 실패" });
}
});
app.post("/api/ax-apply", (req, res, next) => {
uploadAxApply.single("applicationFile")(req, res, (err) => {
if (err) return res.status(400).json({ error: err.message || "파일 업로드 실패" });
next();
});
}, async (req, res) => {
try {
if (!isAxApplyDownloadSubmitAllowed(req, res)) {
return res.status(403).json({ error: "로그인 후 이용 가능합니다." });
}
const body = { ...req.body };
if (body.techStackStr && typeof body.techStackStr === "string") {
body.techStack = body.techStackStr.split(",").map((s) => s.trim()).filter(Boolean);
}
if (body.risksStr && typeof body.risksStr === "string") {
body.risks = body.risksStr.split(",").map((s) => s.trim()).filter(Boolean);
}
for (const k of ["dataTypes", "data_types", "techStackStr", "tech_stack", "risksStr", "qualitativeEffects", "qualitative_effects"]) {
if (body[k] === "" || (typeof body[k] === "string" && body[k] != null)) delete body[k];
}
if (body.techStack && !Array.isArray(body.techStack)) delete body.techStack;
if (body.risks && !Array.isArray(body.risks)) delete body.risks;
if (!req.file) {
return res.status(400).json({ error: "작성 완료 신청서(.docx) 파일을 업로드해 주세요." });
}
const email = (body.email || "").trim();
if (!email) {
return res.status(400).json({ error: "이메일을 입력해 주세요." });
}
body.applicationFile = `/uploads/ax-apply/${req.file.filename}`;
body.participationPledge = body.participationPledge === "1" || body.participationPledge === true;
const row = await createAxAssignmentDb(body);
res.json({ ok: true, id: row.id });
} catch (err) {
const msg = err?.message || "저장 실패";
if (err instanceof ReferenceError && /updateAxAssignmentDb/.test(String(msg))) {
return res.status(500).json({ error: "서버를 재시작한 후 다시 시도해 주세요. (코드 반영이 필요할 수 있습니다.)" });
}
res.status(500).json({ error: msg });
}
});
const SYSTEM_PROMPT = [
"당신은 자비스(XAVIS) 플랫폼의 도우미입니다. 친절하고 정확하게 답변해주세요.",
"",
"웹 검색이나 제공된 검색 결과를 쓸 수 있으면 여러 출처를 종합해 답하세요.",
"회사·조직의 주소·위치·소재지·오시는 길 등을 묻는 질문에는 본사(또는 등기·공시상 대표 주소)뿐 아니라, 검색·자료에 나오면 공장·연구소·지사·지역 거점 등 주요 부속 시설을 구분해 짧게 정리해 포함하세요. 정보가 본사만 확인되는 경우에도 그 한계를 짧게 밝히고, 추가 거점이 공개되어 있으면 빠짐없이 반영하세요.",
].join("\n");
const getProvider = (model) => {
const m = (model || "").toString().toLowerCase();
if (m.startsWith("claude-")) return "anthropic";
if (m.startsWith("gemini-")) return "google";
return "openai";
};
const GEMINI_MODEL_MAP = {
"gemini-3-pro": "gemini-2.5-pro",
"gemini-3-flash": "gemini-2.5-flash",
};
/** 채팅 UI와 동일하게 허용하는 모델만 처리 */
const CHAT_ALLOWED_MODELS = new Set(["gpt-5.4", "gpt-5-mini"]);
/** UI 선택값 → OpenAI Chat Completions API에 넘길 실제 모델 ID (gpt-5.4 등은 API에 없을 수 있어 기본은 gpt-4o 계열) */
function resolveOpenAiApiModel(uiModel) {
const map = {
"gpt-5.4": (process.env.OPENAI_MODEL_DEFAULT || "gpt-4o").trim(),
"gpt-5-mini": (process.env.OPENAI_MODEL_MINI || "gpt-4o-mini").trim(),
};
return map[uiModel] || uiModel;
}
function buildResponsesInputFromChatMessages(normalizedMessages) {
const input = [];
for (const m of normalizedMessages) {
const r = String(m.role || "user").toLowerCase();
if (r !== "user" && r !== "assistant") continue;
input.push({
role: r === "assistant" ? "assistant" : "user",
content: m.content,
});
}
return input;
}
function buildOpenAiWebSearchTool() {
const tool = { type: "web_search" };
const country = (process.env.OPENAI_WEB_SEARCH_COUNTRY || "KR").trim();
if (country) {
tool.user_location = {
type: "approximate",
country,
};
const city = (process.env.OPENAI_WEB_SEARCH_CITY || "").trim();
const region = (process.env.OPENAI_WEB_SEARCH_REGION || "").trim();
const timezone = (process.env.OPENAI_WEB_SEARCH_TIMEZONE || "Asia/Seoul").trim();
if (city) tool.user_location.city = city;
if (region) tool.user_location.region = region;
if (timezone) tool.user_location.timezone = timezone;
}
return tool;
}
function extractSourcesFromResponse(response) {
const out = [];
const seen = new Set();
if (!response?.output) return out;
for (const item of response.output) {
if (item.type !== "message") continue;
for (const part of item.content || []) {
if (part.type !== "output_text" || !part.annotations) continue;
for (const ann of part.annotations) {
if (ann.type === "url_citation" && ann.url && !seen.has(ann.url)) {
seen.add(ann.url);
out.push({ url: ann.url, title: ann.title || ann.url });
}
}
}
}
return out;
}
/** 회의록 본문 수동 저장 — PATCH/PUT/POST(프록시·구버전 호환) */
async function saveMeetingGeneratedMinutesApi(req, res) {
try {
await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail);
const id = (req.params.id || "").trim();
const b = req.body || {};
const generatedMinutes = meetingMinutesLib.prepareMeetingMinutesForApi((b.generatedMinutes ?? "").toString());
if (!id) return res.status(400).json({ error: "회의 ID가 필요합니다." });
const prev = await meetingAiStore.getMeeting(pgPool, id, req.meetingUserEmail);
if (!prev) return res.status(404).json({ error: "회의록을 찾을 수 없습니다." });
const model = (prev.chat_model || "gpt-5-mini").toString().trim();
const fields = { generatedMinutes };
if (Object.prototype.hasOwnProperty.call(b, "transcriptText")) {
fields.transcriptText = (b.transcriptText ?? "").toString();
}
if (Object.prototype.hasOwnProperty.call(b, "sourceText")) {
fields.sourceText = (b.sourceText ?? "").toString();
}
const updated = await meetingAiStore.updateMeetingContent(pgPool, id, req.meetingUserEmail, fields);
if (!updated) return res.status(404).json({ error: "저장할 수 없습니다." });
let checklistSync = { imported: 0 };
if (OPENAI_API_KEY) {
try {
const openai = new OpenAI({ apiKey: OPENAI_API_KEY });
checklistSync = await syncAutoChecklistFromMeetingMinutes(openai, {
pgPool,
email: req.meetingUserEmail,
meetingId: id,
generatedMinutes,
uiModel: model,
});
} catch (e) {
checklistSync = { imported: 0, extractError: e?.message };
}
} else {
checklistSync = { imported: 0, disabled: true };
}
const fresh = await meetingAiStore.getMeeting(pgPool, id, req.meetingUserEmail);
const meetingOut = mapRowToMeeting(fresh);
if (checklistSync.snapshot) meetingOut.checklistSnapshot = checklistSync.snapshot;
res.json({ meeting: meetingOut, checklistSync });
} catch (err) {
res.status(500).json({ error: err?.message || "저장 실패" });
}
}
const meetingMinutesSaveJson = express.json({ limit: "2mb" });
app.get("/api/meeting-minutes/prompt", requireMeetingMinutesEmail, async (req, res) => {
try {
await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail);
const row = await meetingAiStore.getPromptRow(pgPool, req.meetingUserEmail);
res.json({ prompt: mapRowToMeetingPrompt(row) });
} catch (err) {
res.status(500).json({ error: err?.message || "조회 실패" });
}
});
app.put("/api/meeting-minutes/prompt", requireMeetingMinutesEmail, express.json(), async (req, res) => {
try {
await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail);
const b = req.body || {};
const includeTitleLine = b.includeTitleLine !== false;
const includeAttendees = b.includeAttendees !== false;
const includeSummary = b.includeSummary !== false;
const includeActionItems = b.includeActionItems !== false;
const includeChecklist = true;
const customInstructions = (b.customInstructions || "").toString().slice(0, 8000);
const row = await meetingAiStore.upsertPrompt(pgPool, req.meetingUserEmail, {
includeTitleLine,
includeAttendees,
includeSummary,
includeActionItems,
includeChecklist,
customInstructions,
});
res.json({ prompt: mapRowToMeetingPrompt(row) });
} catch (err) {
res.status(500).json({ error: err?.message || "저장 실패" });
}
});
app.get("/api/meeting-minutes/meetings", requireMeetingMinutesEmail, async (req, res) => {
try {
await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail);
const rows = await meetingAiStore.listMeetings(pgPool, req.meetingUserEmail);
res.json({ meetings: (rows || []).map(mapRowToMeeting) });
} catch (err) {
res.status(500).json({ error: err?.message || "목록 조회 실패" });
}
});
app.get("/api/meeting-minutes/meetings/:id", requireMeetingMinutesEmail, async (req, res) => {
try {
const id = (req.params.id || "").trim();
const row = await meetingAiStore.getMeeting(pgPool, id, req.meetingUserEmail);
if (!row) return res.status(404).json({ error: "회의록을 찾을 수 없습니다." });
res.json({ meeting: mapRowToMeeting(row) });
} catch (err) {
res.status(500).json({ error: err?.message || "조회 실패" });
}
});
app.patch("/api/meeting-minutes/meetings/:id", requireMeetingMinutesEmail, meetingMinutesSaveJson, saveMeetingGeneratedMinutesApi);
app.put("/api/meeting-minutes/meetings/:id", requireMeetingMinutesEmail, meetingMinutesSaveJson, saveMeetingGeneratedMinutesApi);
app.post("/api/meeting-minutes/meetings/:id/save", requireMeetingMinutesEmail, meetingMinutesSaveJson, saveMeetingGeneratedMinutesApi);
app.delete("/api/meeting-minutes/meetings/:id", requireMeetingMinutesEmail, async (req, res) => {
try {
const id = (req.params.id || "").trim();
const result = await meetingAiStore.deleteMeeting(pgPool, id, req.meetingUserEmail);
if (!result.deleted) return res.status(404).json({ error: "삭제할 항목이 없습니다." });
if (result.audio_file_path) {
const abs = path.join(ROOT_DIR, result.audio_file_path.replace(/^\//, ""));
const norm = path.normalize(abs);
if (norm.startsWith(MEETING_MINUTES_UPLOAD_DIR) && fsSync.existsSync(norm)) {
fsSync.unlinkSync(norm);
}
}
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: err?.message || "삭제 실패" });
}
});
app.post("/api/meeting-minutes/generate-text", requireMeetingMinutesEmail, express.json({ limit: "2mb" }), async (req, res) => {
if (!OPENAI_API_KEY) return res.status(503).json({ error: "OPENAI_API_KEY가 설정되지 않았습니다." });
try {
await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail);
const title = (req.body?.title || "").toString().trim().slice(0, 500);
const sourceText = (req.body?.sourceText || "").toString().trim();
const model = (req.body?.model || "gpt-5-mini").toString().trim();
const mdParsed = parseMeetingDateIso(req.body?.meetingDate);
if (mdParsed.error) return res.status(400).json({ error: mdParsed.error });
if (!sourceText) return res.status(400).json({ error: "회의 내용을 입력해 주세요." });
if (!MEETING_MINUTES_ALLOWED_MODELS.has(model)) {
return res.status(400).json({ error: "지원 모델: gpt-5-mini, gpt-5.4" });
}
const pr = await meetingAiStore.getPromptRow(pgPool, req.meetingUserEmail);
const systemPrompt = meetingMinutesLib.buildMeetingMinutesSystemPrompt(mapRowToMeetingPrompt(pr));
const openai = new OpenAI({ apiKey: OPENAI_API_KEY });
const generated = await meetingMinutesLib.generateMeetingMinutes(openai, {
systemPrompt,
userContent: sourceText,
uiModel: model,
resolveApiModel: resolveOpenAiApiModel,
});
const ins = await meetingAiStore.insertMeetingText(pgPool, {
email: req.meetingUserEmail,
title,
sourceText,
generated,
model,
meetingDate: mdParsed.value,
});
let checklistSync = { imported: 0 };
try {
checklistSync = await syncAutoChecklistFromMeetingMinutes(openai, {
pgPool,
email: req.meetingUserEmail,
meetingId: ins.id,
generatedMinutes: generated,
uiModel: model,
});
} catch (e) {
checklistSync = { imported: 0, extractError: e?.message };
}
const meetingOut = mapRowToMeeting(ins);
if (checklistSync.snapshot) meetingOut.checklistSnapshot = checklistSync.snapshot;
res.json({ meeting: meetingOut, checklistSync });
} catch (err) {
const msg = err?.response?.data?.error?.message || err?.message || "생성 실패";
res.status(500).json({ error: msg });
}
});
app.post(
"/api/meeting-minutes/generate-audio",
requireMeetingMinutesEmail,
(req, res, next) => {
uploadMeetingAudio.single("audio")(req, res, (err) => {
if (err) return res.status(400).json({ error: err.message || "파일 업로드 실패" });
next();
});
},
async (req, res) => {
if (!OPENAI_API_KEY) return res.status(503).json({ error: "OPENAI_API_KEY가 설정되지 않았습니다." });
try {
await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail);
const title = (req.body?.title || "").toString().trim().slice(0, 500);
const model = (req.body?.model || "gpt-5-mini").toString().trim();
const whisperModel = (req.body?.whisperModel || meetingMinutesLib.DEFAULT_TRANSCRIPTION_MODEL).toString().trim();
const mdParsed = parseMeetingDateIso(req.body?.meetingDate);
if (mdParsed.error) return res.status(400).json({ error: mdParsed.error });
if (!req.file) return res.status(400).json({ error: "음성 파일을 선택해 주세요." });
if (!MEETING_MINUTES_ALLOWED_MODELS.has(model)) {
return res.status(400).json({ error: "지원 모델: gpt-5-mini, gpt-5.4" });
}
if (!meetingMinutesLib.TRANSCRIPTION_UI_MODELS.has(whisperModel)) {
return res.status(400).json({ error: "지원 전사 모델: gpt-4o-mini-transcribe, gpt-4o-transcribe" });
}
const relPath = `/uploads/${path.relative(UPLOAD_DIR, req.file.path).split(path.sep).join("/")}`;
const pr = await meetingAiStore.getPromptRow(pgPool, req.meetingUserEmail);
const systemPrompt = meetingMinutesLib.buildMeetingMinutesSystemPrompt(mapRowToMeetingPrompt(pr));
const openai = new OpenAI({ apiKey: OPENAI_API_KEY });
let transcript = "";
try {
transcript = await meetingMinutesLib.transcribeMeetingAudio(openai, req.file.path, whisperModel);
} catch (te) {
const tmsg = te?.response?.data?.error?.message || te?.message || "전사 실패";
return res.status(500).json({ error: `Whisper 전사 실패: ${tmsg}` });
}
if (!transcript.trim()) return res.status(400).json({ error: "전사 결과가 비어 있습니다." });
const generated = await meetingMinutesLib.generateMeetingMinutes(openai, {
systemPrompt,
userContent: transcript,
uiModel: model,
resolveApiModel: resolveOpenAiApiModel,
});
const ins = await meetingAiStore.insertMeetingAudio(pgPool, {
email: req.meetingUserEmail,
title,
transcript,
generated,
relPath,
originalName: req.file.originalname,
model,
whisperModel,
meetingDate: mdParsed.value,
});
let checklistSync = { imported: 0 };
try {
checklistSync = await syncAutoChecklistFromMeetingMinutes(openai, {
pgPool,
email: req.meetingUserEmail,
meetingId: ins.id,
generatedMinutes: generated,
uiModel: model,
});
} catch (e) {
checklistSync = { imported: 0, extractError: e?.message };
}
const meetingOut = mapRowToMeeting(ins);
if (checklistSync.snapshot) meetingOut.checklistSnapshot = checklistSync.snapshot;
res.json({ meeting: meetingOut, checklistSync });
} catch (err) {
const msg = err?.response?.data?.error?.message || err?.message || "생성 실패";
res.status(500).json({ error: msg });
}
}
);
app.get("/api/task-checklist/items", requireMeetingMinutesEmail, async (req, res) => {
try {
await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail);
const completed = req.query.completed;
let cf = null;
if (completed === "true") cf = true;
else if (completed === "false") cf = false;
const midRaw = (req.query.meetingId || "").trim();
const meetingId = midRaw && midRaw !== "__all__" ? midRaw : null;
const items = await taskChecklistStore.listItems(pgPool, req.meetingUserEmail, {
completed: cf,
meetingId: meetingId || undefined,
});
const mids = items.map((it) => (it.meetingId != null ? String(it.meetingId) : "")).filter(Boolean);
const metaById = await meetingAiStore.getMeetingMetaForIds(pgPool, req.meetingUserEmail, mids);
const enriched = items.map((it) => {
const mid = it.meetingId != null ? String(it.meetingId) : "";
const meta = mid && metaById.has(mid) ? metaById.get(mid) : null;
return {
...it,
meetingTitle: meta ? meta.meetingTitle : null,
meetingDate: meta ? meta.meetingDate : null,
meetingSummary: meta ? meta.meetingSummary : null,
};
});
res.json({ items: enriched });
} catch (err) {
res.status(500).json({ error: err?.message || "목록 조회 실패" });
}
});
app.post("/api/task-checklist/items", requireMeetingMinutesEmail, express.json(), async (req, res) => {
try {
await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail);
const item = await taskChecklistStore.insertItem(pgPool, req.meetingUserEmail, {
title: req.body?.title,
detail: req.body?.detail,
assignee: req.body?.assignee,
dueNote: req.body?.dueNote,
meetingId: req.body?.meetingId || null,
source: "manual",
completed: !!req.body?.completed,
});
res.json({ item });
} catch (err) {
const msg = err?.message || "저장 실패";
res.status(400).json({ error: msg });
}
});
app.patch("/api/task-checklist/items/:id", requireMeetingMinutesEmail, express.json(), async (req, res) => {
try {
const id = (req.params.id || "").trim();
const item = await taskChecklistStore.updateItem(pgPool, id, req.meetingUserEmail, req.body || {});
if (!item) return res.status(404).json({ error: "항목을 찾을 수 없습니다." });
res.json({ item });
} catch (err) {
res.status(500).json({ error: err?.message || "수정 실패" });
}
});
app.delete("/api/task-checklist/items/:id", requireMeetingMinutesEmail, async (req, res) => {
try {
const id = (req.params.id || "").trim();
const result = await taskChecklistStore.deleteItem(pgPool, id, req.meetingUserEmail);
if (!result.deleted) return res.status(404).json({ error: "삭제할 항목이 없습니다." });
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: err?.message || "삭제 실패" });
}
});
app.post("/api/task-checklist/import/:meetingId", requireMeetingMinutesEmail, async (req, res) => {
try {
await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail);
const meetingId = (req.params.meetingId || "").trim();
const mode = (req.query.mode || "checklist").toString().trim() === "actions" ? "actions" : "checklist";
const row = await meetingAiStore.getMeeting(pgPool, meetingId, req.meetingUserEmail);
if (!row) return res.status(404).json({ error: "회의록을 찾을 수 없습니다." });
const text = row.generated_minutes || "";
if (!text.trim()) return res.status(400).json({ error: "회의록 본문이 비어 있습니다." });
const parsed = parseChecklistFromMinutes.parseItemsFromMinutes(text, mode);
if (!parsed.length) {
return res.status(400).json({
error:
mode === "actions"
? "‘액션 아이템’ 섹션에서 항목을 찾지 못했습니다. 제목 형식(번호 목록)을 확인해 주세요."
: "‘후속 확인 체크리스트’ 등 체크리스트 섹션에서 항목을 찾지 못했습니다.",
parsedCount: 0,
});
}
const inserted = await taskChecklistStore.insertImportedBatch(pgPool, req.meetingUserEmail, meetingId, parsed);
res.json({ imported: inserted.length, items: inserted });
} catch (err) {
res.status(500).json({ error: err?.message || "가져오기 실패" });
}
});
/** 모든 회의록에 대해 규칙 기반 가져오기(회의별 중복은 기존과 동일하게 스킵) */
app.post("/api/task-checklist/import-all", requireMeetingMinutesEmail, async (req, res) => {
try {
await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail);
const mode = (req.query.mode || "checklist").toString().trim() === "actions" ? "actions" : "checklist";
const rows = await meetingAiStore.listMeetings(pgPool, req.meetingUserEmail);
if (!rows.length) {
return res.status(400).json({ error: "가져올 회의록이 없습니다." });
}
let imported = 0;
/** @type {Array<{ meetingId: string, title: string, imported: number, skip?: string }>} */
const details = [];
for (const row of rows) {
const meetingId = row.id != null ? String(row.id) : "";
const title = (row.title && String(row.title).trim()) || "제목 없음";
if (!meetingId) continue;
const text = row.generated_minutes || "";
if (!String(text).trim()) {
details.push({ meetingId, title, imported: 0, skip: "empty_body" });
continue;
}
const parsed = parseChecklistFromMinutes.parseItemsFromMinutes(text, mode);
if (!parsed.length) {
details.push({ meetingId, title, imported: 0, skip: "no_parsed_items" });
continue;
}
const inserted = await taskChecklistStore.insertImportedBatch(pgPool, req.meetingUserEmail, meetingId, parsed);
const n = inserted.length;
imported += n;
details.push({ meetingId, title, imported: n });
}
const meetingsWithImports = details.filter((d) => d.imported > 0).length;
res.json({
imported,
meetingCount: rows.length,
meetingsWithImports,
details,
});
} catch (err) {
res.status(500).json({ error: err?.message || "가져오기 실패" });
}
});
app.get("/api/chat/config", (req, res) => {
res.json({
configured: OPENAI_API_KEY.length > 0,
webSearch: OPENAI_WEB_SEARCH,
chatGptAllowed: isChatGptAllowed(req, res),
});
});
app.post("/api/chat", async (req, res) => {
if (!isChatGptAllowed(req, res)) {
res.status(403).json({ error: "허용된 사용자가 아닙니다." });
return;
}
const { messages, model } = req.body;
if (!Array.isArray(messages) || messages.length === 0) {
res.status(400).json({ error: "messages 배열이 필요합니다." });
return;
}
const chatModel = (model || "gpt-5-mini").toString().trim() || "gpt-5-mini";
if (!CHAT_ALLOWED_MODELS.has(chatModel)) {
res.status(400).json({ error: "지원하지 않는 모델입니다. gpt-5.4 또는 gpt-5-mini만 사용할 수 있습니다." });
return;
}
const provider = getProvider(chatModel);
const normalizedMessages = messages.map((m) => ({
role: m.role || "user",
content: String(m.content || ""),
}));
try {
let reply = "";
let sources;
if (provider === "openai") {
if (!OPENAI_API_KEY) {
res.status(503).json({ error: "OPENAI_API_KEY가 설정되지 않았습니다. .env 파일을 확인한 뒤 서버를 재시작해주세요." });
return;
}
const apiModel = resolveOpenAiApiModel(chatModel);
const openai = new OpenAI({ apiKey: OPENAI_API_KEY });
if (OPENAI_WEB_SEARCH) {
const resp = await openai.responses.create({
model: apiModel,
instructions: SYSTEM_PROMPT,
input: buildResponsesInputFromChatMessages(normalizedMessages),
tools: [buildOpenAiWebSearchTool()],
include: ["web_search_call.action.sources"],
});
reply = resp.output_text || "";
sources = extractSourcesFromResponse(resp);
} else {
const completion = await openai.chat.completions.create({
model: apiModel,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
...normalizedMessages,
],
});
reply = completion.choices?.[0]?.message?.content || "";
}
} else if (provider === "anthropic") {
if (!CLAUDE_API_KEY) {
res.status(503).json({ error: "CLAUDE_API_KEY가 설정되지 않았습니다. .env 파일을 확인해주세요." });
return;
}
const anthropic = new Anthropic({ apiKey: CLAUDE_API_KEY });
const claudeMessages = normalizedMessages
.filter((m) => m.role === "user" || m.role === "assistant")
.map((m) => ({ role: m.role, content: m.content }));
const msg = await anthropic.messages.create({
model: chatModel,
max_tokens: 4096,
system: SYSTEM_PROMPT,
messages: claudeMessages,
});
const textBlock = msg.content?.find((b) => b.type === "text");
reply = textBlock?.text || "";
} else if (provider === "google") {
if (!GENAI_API_KEY) {
res.status(503).json({ error: "GENAI_API_KEY가 설정되지 않았습니다. .env 파일을 확인해주세요." });
return;
}
const geminiModel = GEMINI_MODEL_MAP[chatModel] || chatModel;
const ai = new GoogleGenAI({ apiKey: GENAI_API_KEY });
const geminiContents = normalizedMessages.map((m) => ({
role: m.role === "assistant" ? "model" : "user",
parts: [{ text: m.content }],
}));
const response = await ai.models.generateContent({
model: geminiModel,
contents: geminiContents,
config: { systemInstruction: SYSTEM_PROMPT },
});
reply = response?.text || "";
}
const payload = { message: reply };
if (sources && sources.length) payload.sources = sources;
res.json(payload);
} catch (err) {
const apiMsg =
err?.error?.message ||
err?.response?.data?.error?.message ||
err?.message ||
`${provider} API 오류`;
const status =
typeof err?.status === "number"
? err.status
: err?.response?.status && err.response.status >= 400 && err.response.status < 600
? err.response.status
: 500;
res.status(status >= 400 && status < 600 ? status : 500).json({ error: apiMsg });
}
});
function writeChatSse(res, obj) {
res.write(`data: ${JSON.stringify(obj)}\n\n`);
}
/** OpenAI Chat Completions 스트리밍 (SSE). 클라이언트는 fetch + ReadableStream으로 수신 */
app.post("/api/chat/stream", async (req, res) => {
const { messages, model } = req.body;
if (!Array.isArray(messages) || messages.length === 0) {
res.status(400).json({ error: "messages 배열이 필요합니다." });
return;
}
if (!isChatGptAllowed(req, res)) {
res.status(403).json({ error: "허용된 사용자가 아닙니다." });
return;
}
const chatModel = (model || "gpt-5-mini").toString().trim() || "gpt-5-mini";
if (!CHAT_ALLOWED_MODELS.has(chatModel)) {
res.status(400).json({ error: "지원하지 않는 모델입니다. gpt-5.4 또는 gpt-5-mini만 사용할 수 있습니다." });
return;
}
const provider = getProvider(chatModel);
if (provider !== "openai") {
res.status(400).json({ error: "스트리밍은 OpenAI 모델만 지원합니다." });
return;
}
if (!OPENAI_API_KEY) {
res.status(503).json({ error: "OPENAI_API_KEY가 설정되지 않았습니다. .env 파일을 확인한 뒤 서버를 재시작해주세요." });
return;
}
const normalizedMessages = messages.map((m) => ({
role: m.role || "user",
content: String(m.content || ""),
}));
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
const apiModel = resolveOpenAiApiModel(chatModel);
const openai = new OpenAI({ apiKey: OPENAI_API_KEY });
try {
if (OPENAI_WEB_SEARCH) {
const stream = await openai.responses.create({
model: apiModel,
instructions: SYSTEM_PROMPT,
input: buildResponsesInputFromChatMessages(normalizedMessages),
tools: [buildOpenAiWebSearchTool()],
stream: true,
include: ["web_search_call.action.sources"],
});
for await (const event of stream) {
if (event.type === "response.output_text.delta") {
const d = event.delta;
if (d) writeChatSse(res, { type: "delta", text: d });
} else if (
event.type === "response.web_search_call.searching" ||
event.type === "response.web_search_call.in_progress"
) {
writeChatSse(res, { type: "status", phase: "web_search" });
} else if (event.type === "response.completed") {
const items = extractSourcesFromResponse(event.response);
if (items.length) writeChatSse(res, { type: "sources", items });
} else if (event.type === "error") {
throw new Error(event.message || "Responses API 오류");
} else if (event.type === "response.failed") {
const msg = event.response?.error?.message || "응답 생성 실패";
throw new Error(msg);
}
}
writeChatSse(res, { type: "done" });
res.end();
return;
}
const stream = await openai.chat.completions.create({
model: apiModel,
messages: [{ role: "system", content: SYSTEM_PROMPT }, ...normalizedMessages],
stream: true,
});
for await (const chunk of stream) {
const delta = chunk.choices?.[0]?.delta?.content ?? "";
if (delta) writeChatSse(res, { type: "delta", text: delta });
}
writeChatSse(res, { type: "done" });
res.end();
} catch (err) {
const apiMsg =
err?.error?.message ||
err?.response?.data?.error?.message ||
err?.message ||
"OpenAI API 오류";
try {
writeChatSse(res, { type: "error", error: apiMsg });
res.end();
} catch {
if (!res.headersSent) {
res.status(500).json({ error: apiMsg });
}
}
}
});
app.post(
"/api/ai-success-stories/upload-pdf",
requireAdminApi,
(req, res, next) => {
uploadAiSuccessPdf.single("pdfFile")(req, res, (err) => {
if (err) {
res.status(400).json({ error: err.message || "업로드 실패" });
return;
}
next();
});
},
(req, res) => {
try {
if (!req.file) {
res.status(400).json({ error: "PDF 파일을 선택해 주세요." });
return;
}
const rel = path.relative(path.join(ROOT_DIR, "public"), req.file.path).replace(/\\/g, "/");
const pdfUrl = `/public/${rel}`;
res.json({ ok: true, pdfUrl, filename: req.file.filename });
} catch (err) {
res.status(500).json({ error: err?.message || "저장 실패" });
}
}
);
app.post("/api/ai-success-stories", requireAdminApi, (req, res) => {
try {
const body = req.body || {};
const title = String(body.title || "").trim();
let slug = String(body.slug || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9\-]/g, "");
const bodyMd = String(body.bodyMarkdown || "").trim();
const pdfUrlTrim = String(body.pdfUrl || "").trim();
if (!title || !slug) {
res.status(400).json({ error: "제목과 슬러그(영문·숫자·하이픈)는 필수입니다." });
return;
}
if (!bodyMd && !pdfUrlTrim) {
res.status(400).json({ error: "본문(Markdown) 또는 원문 PDF 중 하나는 필수입니다." });
return;
}
const meta = loadAiSuccessStoriesMeta();
if (meta.some((m) => m.slug === slug)) {
res.status(400).json({ error: "같은 슬러그가 이미 있습니다." });
return;
}
const id = `story-${Date.now()}`;
const tags = String(body.tags || "")
.split(",")
.map((t) => t.trim())
.filter(Boolean);
const fileName = buildAiSuccessStoryContentFileName(slug);
fsSync.mkdirSync(AI_SUCCESS_CONTENT_DIR, { recursive: true });
fsSync.writeFileSync(path.join(AI_SUCCESS_CONTENT_DIR, fileName), bodyMd, "utf8");
const row = {
id,
slug,
title,
excerpt: String(body.excerpt || "").trim() || title.slice(0, 140),
author: String(body.author || "").trim(),
department: String(body.department || "").trim(),
publishedAt: String(body.publishedAt || "").trim() || new Date().toISOString().slice(0, 10),
tags,
contentFile: fileName,
pdfUrl: String(body.pdfUrl || "").trim(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
meta.push(row);
saveAiSuccessStoriesMeta(meta);
res.json({ ok: true, story: row });
} catch (err) {
res.status(500).json({ error: err?.message || "저장 실패" });
}
});
app.put("/api/ai-success-stories/:id", requireAdminApi, (req, res) => {
try {
const id = req.params.id;
const meta = loadAiSuccessStoriesMeta();
const idx = meta.findIndex((m) => m.id === id);
if (idx === -1) {
res.status(404).json({ error: "해당 사례가 없습니다." });
return;
}
const body = req.body || {};
const cur = meta[idx];
const title = body.title !== undefined ? String(body.title).trim() : cur.title;
let slug = body.slug !== undefined
? String(body.slug)
.trim()
.toLowerCase()
.replace(/[^a-z0-9\-]/g, "")
: cur.slug;
const bodyMd =
body.bodyMarkdown !== undefined ? String(body.bodyMarkdown) : loadStoryBodyMarkdown(cur);
const nextPdf = body.pdfUrl !== undefined ? String(body.pdfUrl).trim() : (cur.pdfUrl || "").trim();
if (!title || !slug) {
res.status(400).json({ error: "제목과 슬러그는 필수입니다." });
return;
}
if (!bodyMd.trim() && !nextPdf) {
res.status(400).json({ error: "본문(Markdown) 또는 원문 PDF 중 하나는 필수입니다." });
return;
}
if (meta.some((m, i) => m.slug === slug && i !== idx)) {
res.status(400).json({ error: "같은 슬러그가 이미 있습니다." });
return;
}
const oldSlug = cur.slug;
const oldFileBase = cur.contentFile ? path.basename(cur.contentFile) : `${oldSlug}.md`;
let nextFile;
if (slug !== oldSlug) {
nextFile = buildAiSuccessStoryContentFileName(slug);
} else {
nextFile = cur.contentFile ? path.basename(cur.contentFile) : buildAiSuccessStoryContentFileName(slug);
}
const safeFile = path.basename(nextFile);
fsSync.mkdirSync(AI_SUCCESS_CONTENT_DIR, { recursive: true });
fsSync.writeFileSync(path.join(AI_SUCCESS_CONTENT_DIR, safeFile), bodyMd, "utf8");
if (oldFileBase !== safeFile) {
try {
fsSync.unlinkSync(path.join(AI_SUCCESS_CONTENT_DIR, oldFileBase));
} catch {
/* ignore */
}
}
meta[idx] = {
...cur,
title,
slug,
excerpt: body.excerpt !== undefined ? String(body.excerpt).trim() : cur.excerpt,
author: body.author !== undefined ? String(body.author).trim() : cur.author,
department: body.department !== undefined ? String(body.department).trim() : cur.department,
publishedAt: body.publishedAt !== undefined ? String(body.publishedAt).trim() : cur.publishedAt,
tags:
body.tags !== undefined
? String(body.tags)
.split(",")
.map((t) => t.trim())
.filter(Boolean)
: cur.tags,
contentFile: safeFile,
pdfUrl: body.pdfUrl !== undefined ? String(body.pdfUrl).trim() : cur.pdfUrl || "",
updatedAt: new Date().toISOString(),
};
saveAiSuccessStoriesMeta(meta);
res.json({ ok: true, story: meta[idx] });
} catch (err) {
res.status(500).json({ error: err?.message || "저장 실패" });
}
});
app.delete("/api/ai-success-stories/:id", requireAdminApi, (req, res) => {
try {
const id = req.params.id;
const meta = loadAiSuccessStoriesMeta();
const idx = meta.findIndex((m) => m.id === id);
if (idx === -1) {
res.status(404).json({ error: "해당 사례가 없습니다." });
return;
}
const cur = meta[idx];
const cf = cur.contentFile;
if (cf) {
try {
fsSync.unlinkSync(path.join(AI_SUCCESS_CONTENT_DIR, path.basename(cf)));
} catch {
/* ignore */
}
}
meta.splice(idx, 1);
saveAiSuccessStoriesMeta(meta);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: err?.message || "삭제 실패" });
}
});
app.use("/", pageRouter);
const decodeXmlText = (value) =>
value
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
const escapeHtml = (value) =>
(value || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
const isYoutubeUrl = (url) =>
/^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\//i.test((url || "").trim());
/** http(s) 외부 링크 (뉴스 URL 등) */
const isHttpUrl = (url) => {
const s = (url || "").trim();
if (!s) return false;
try {
const u = new URL(s);
return u.protocol === "http:" || u.protocol === "https:";
} catch {
return false;
}
};
const normalizeLecture = (lecture) => {
if (!lecture) return lecture;
if (!lecture.listSection) lecture.listSection = "learning";
return lecture;
};
const toYoutubeEmbedUrl = (url) => {
const value = (url || "").trim();
try {
const parsed = new URL(value);
if (parsed.hostname.includes("youtu.be")) {
const videoId = parsed.pathname.replace("/", "");
return `https://www.youtube.com/embed/${videoId}`;
}
if (parsed.hostname.includes("youtube.com")) {
const videoId = parsed.searchParams.get("v");
if (videoId) return `https://www.youtube.com/embed/${videoId}`;
if (parsed.pathname.startsWith("/embed/")) return value;
}
return value;
} catch {
return value;
}
};
const getYoutubeThumbnailUrl = (url) => {
if (!url || !isYoutubeUrl(url)) return null;
try {
const parsed = new URL((url || "").trim());
let videoId = null;
if (parsed.hostname.includes("youtu.be")) {
videoId = parsed.pathname.replace(/^\/+/, "").split("/")[0];
} else if (parsed.hostname.includes("youtube.com")) {
videoId = parsed.searchParams.get("v");
if (!videoId && parsed.pathname.startsWith("/embed/")) {
videoId = (parsed.pathname.match(/\/embed\/([^/?]+)/) || [])[1];
}
}
if (videoId) return `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
} catch {}
return null;
};
app.locals.getYoutubeThumbnailUrl = getYoutubeThumbnailUrl;
const mapRowToLecture = (row) =>
normalizeLecture({
id: row.id,
type: row.type,
title: row.title,
description: row.description || "",
tags: Array.isArray(row.tags) ? row.tags : [],
youtubeUrl: row.youtube_url || null,
fileName: row.file_name || null,
originalName: row.original_name || null,
previewTitle: row.preview_title || null,
slideCount: typeof row.slide_count === "number" ? row.slide_count : 0,
thumbnailUrl: row.thumbnail_url || null,
thumbnailStatus: row.thumbnail_status || "pending",
thumbnailRetryCount: typeof row.thumbnail_retry_count === "number" ? row.thumbnail_retry_count : 0,
thumbnailError: row.thumbnail_error || null,
thumbnailUpdatedAt: row.thumbnail_updated_at ? new Date(row.thumbnail_updated_at).toISOString() : null,
createdAt: row.created_at ? new Date(row.created_at).toISOString() : new Date().toISOString(),
listSection: row.list_section || "learning",
newsUrl: row.news_url || null,
});
const readLectureDb = async () => {
if (pgPool) {
try {
const res = await pgPool.query(
"SELECT id, type, title, description, tags, youtube_url, file_name, original_name, preview_title, slide_count, thumbnail_url, thumbnail_status, thumbnail_retry_count, thumbnail_error, thumbnail_updated_at, created_at, list_section, news_url FROM lectures ORDER BY created_at DESC"
);
return (res.rows || []).map(mapRowToLecture);
} catch (err) {
console.error("readLectureDb from PostgreSQL failed:", err.message);
throw err;
}
}
try {
const raw = await fs.readFile(LECTURE_DB_PATH, "utf-8");
const parsed = JSON.parse(raw);
const list = Array.isArray(parsed) ? parsed : [];
return list.map((item) => normalizeLecture({ ...item }));
} catch {
return [];
}
};
const writeLectureDb = async (lectures) => {
if (pgPool) {
await syncLecturesToPostgres(lectures);
return;
}
await fs.writeFile(LECTURE_DB_PATH, JSON.stringify(lectures, null, 2), "utf-8");
};
/** link·news: og:image URL을 thumbnailUrl(절대 URL)에 저장 */
const applyLinkOgPreviewToLecture = async (lectureId, pageUrl) => {
const img = await fetchOpenGraphImageUrl(pageUrl);
const lectures = await readLectureDb();
const lec = lectures.find((l) => l.id === lectureId);
if (!lec || (lec.type !== "link" && lec.type !== "news")) return;
lec.thumbnailUrl = img || null;
lec.thumbnailStatus = img ? "ready" : "failed";
lec.thumbnailError = img ? null : "og:image 없음";
lec.thumbnailUpdatedAt = new Date().toISOString();
if (typeof lec.thumbnailRetryCount !== "number") lec.thumbnailRetryCount = 0;
await writeLectureDb(lectures);
};
const scheduleLinkOgPreviewFetch = (lectureId, pageUrl) => {
setImmediate(() => {
applyLinkOgPreviewToLecture(lectureId, pageUrl).catch((e) =>
console.error("[link-preview]", lectureId, e?.message || e)
);
});
};
/** 목록에 표시 중인 pending 링크에 대해 서버당 1회 OG fetch 예약(기존 등록 건 보정) */
const linkOgPreviewScheduledIds = new Set();
const parseTags = (value) =>
(value || "")
.split(",")
.map((tag) => tag.trim())
.filter(Boolean)
.filter((tag, idx, arr) => arr.indexOf(tag) === idx);
const LECTURE_CATEGORIES = ["AX 사고 전환", "AI 툴 활용", "AI Agent", "바이브 코딩"];
const mergeTagsWithCategory = (tagsStr, category) => {
const tags = parseTags(tagsStr);
const cat = (category || "").toString().trim();
if (cat && LECTURE_CATEGORIES.includes(cat) && !tags.includes(cat)) {
tags.push(cat);
}
return tags;
};
const parsePptxSlides = async (filePath) => {
const buffer = await fs.readFile(filePath);
const zip = await JSZip.loadAsync(buffer);
const slideFiles = Object.keys(zip.files)
.filter((name) => /^ppt\/slides\/slide\d+\.xml$/.test(name))
.sort((a, b) => {
const aNum = Number((a.match(/slide(\d+)\.xml/) || [])[1] || 0);
const bNum = Number((b.match(/slide(\d+)\.xml/) || [])[1] || 0);
return aNum - bNum;
});
const slides = [];
for (const slideFile of slideFiles) {
const xml = await zip.files[slideFile].async("string");
const texts = [];
const matcher = xml.matchAll(/<a:t[^>]*>([\s\S]*?)<\/a:t>/g);
for (const matched of matcher) {
const plain = decodeXmlText(matched[1] || "").trim();
if (plain) texts.push(plain);
}
slides.push({
title: texts[0] || "",
lines: texts,
});
}
return slides;
};
const getPptxMeta = async (filePath) => {
const slides = await parsePptxSlides(filePath);
const first = slides[0] || { title: "" };
return {
slideCount: slides.length,
previewTitle: first.title || "",
};
};
const getPdfMeta = async (filePath) => {
try {
const { stdout } = await execFileAsync("pdfinfo", [filePath], { timeout: 10000 });
const match = (stdout || "").match(/Pages:\s*(\d+)/);
const slideCount = match ? Math.max(1, parseInt(match[1], 10)) : 0;
return { slideCount, previewTitle: "" };
} catch {
return { slideCount: 0, previewTitle: "" };
}
};
const parsePdfSlides = async (filePath) => {
const meta = await getPdfMeta(filePath);
return Array.from({ length: meta.slideCount }, (_, i) => ({
title: "",
lines: [],
}));
};
const tryExec = async (command, args, timeout = 20000) => {
try {
await execFileAsync(command, args, {
timeout,
windowsHide: true,
});
return true;
} catch (err) {
return false;
}
};
const tryExecWithLog = async (command, args, timeout, logContext) => {
try {
await execFileAsync(command, args, {
timeout,
windowsHide: true,
});
return { ok: true };
} catch (err) {
console.error(`[slide-images] ${logContext}: ${command} failed:`, err.message || err);
return { ok: false, error: err.message };
}
};
const safeRemove = async (targetPath) => {
try {
await fs.rm(targetPath, { recursive: true, force: true });
} catch {
// Ignore cleanup errors.
}
};
const generateWithQuickLook = async (filePath, targetKey) => {
if (process.platform !== "darwin") return null;
const qlmanagePath = "/usr/bin/qlmanage";
if (!fsSync.existsSync(qlmanagePath)) return null;
const before = new Set(await fs.readdir(THUMBNAIL_DIR));
const ok = await tryExec(qlmanagePath, ["-t", "-s", String(THUMBNAIL_WIDTH), "-o", THUMBNAIL_DIR, filePath]);
if (!ok) return null;
const after = await fs.readdir(THUMBNAIL_DIR);
const createdCandidates = after.filter((name) => !before.has(name) && /\.(png|jpg|jpeg)$/i.test(name));
const base = path.basename(filePath);
const matched = createdCandidates.find((name) => name.includes(base)) || createdCandidates[0];
if (!matched) return null;
const sourcePath = path.join(THUMBNAIL_DIR, matched);
const ext = path.extname(matched) || ".png";
const finalName = `${targetKey}${ext.toLowerCase()}`;
const finalPath = path.join(THUMBNAIL_DIR, finalName);
try {
await fs.rename(sourcePath, finalPath);
} catch {
return null;
}
return `/uploads/thumbnails/${finalName}`;
};
const generateWithLibreOffice = async (filePath, targetKey) => {
const tmpWorkDir = path.join(TMP_DIR, `thumb-${targetKey}-${Date.now()}`);
await fs.mkdir(tmpWorkDir, { recursive: true });
const sofficeCandidates = ["soffice", "libreoffice"];
let converted = false;
for (const cmd of sofficeCandidates) {
// soffice --headless --convert-to pdf --outdir <tmp> <file.pptx>
const ok = await tryExec(cmd, ["--headless", "--convert-to", "pdf", "--outdir", tmpWorkDir, filePath], 60000);
if (ok) {
converted = true;
break;
}
}
if (!converted) {
await safeRemove(tmpWorkDir);
return null;
}
const pdfPath = path.join(tmpWorkDir, `${path.basename(filePath, path.extname(filePath))}.pdf`);
if (!fsSync.existsSync(pdfPath)) {
await safeRemove(tmpWorkDir);
return null;
}
const outPrefix = path.join(tmpWorkDir, "preview");
const okPpm = await tryExec("pdftoppm", ["-f", "1", "-singlefile", "-png", pdfPath, outPrefix], 30000);
if (!okPpm) {
await safeRemove(tmpWorkDir);
return null;
}
const generatedPng = `${outPrefix}.png`;
if (!fsSync.existsSync(generatedPng)) {
await safeRemove(tmpWorkDir);
return null;
}
const finalName = `${targetKey}.png`;
const finalPath = path.join(THUMBNAIL_DIR, finalName);
try {
await fs.copyFile(generatedPng, finalPath);
} catch {
await safeRemove(tmpWorkDir);
return null;
}
await safeRemove(tmpWorkDir);
return `/uploads/thumbnails/${finalName}`;
};
const generateWithPdfDirect = async (filePath, targetKey) => {
const ext = path.extname(filePath).toLowerCase();
if (ext !== ".pdf") return null;
const tmpWorkDir = path.join(TMP_DIR, `thumb-pdf-${targetKey}-${Date.now()}`);
await fs.mkdir(tmpWorkDir, { recursive: true });
const outPrefix = path.join(tmpWorkDir, "preview");
const okPpm = await tryExec("pdftoppm", ["-f", "1", "-singlefile", "-png", "-r", "150", filePath, outPrefix], 30000);
if (!okPpm) {
await safeRemove(tmpWorkDir);
return null;
}
const generatedPng = `${outPrefix}.png`;
if (!fsSync.existsSync(generatedPng)) {
await safeRemove(tmpWorkDir);
return null;
}
const finalName = `${targetKey}.png`;
const finalPath = path.join(THUMBNAIL_DIR, finalName);
try {
await fs.copyFile(generatedPng, finalPath);
} catch {
await safeRemove(tmpWorkDir);
return null;
}
await safeRemove(tmpWorkDir);
return `/uploads/thumbnails/${finalName}`;
};
const generatePptThumbnail = async (filePath, targetKey) => {
if (!ENABLE_PPT_THUMBNAIL) return null;
await fs.mkdir(THUMBNAIL_DIR, { recursive: true });
const pdfDirect = await generateWithPdfDirect(filePath, targetKey);
if (pdfDirect) return pdfDirect;
const quickLook = await generateWithQuickLook(filePath, targetKey);
if (quickLook) return quickLook;
const libre = await generateWithLibreOffice(filePath, targetKey);
if (libre) return libre;
return null;
};
const generateSlideImages = async (filePath, lectureId) => {
const outDir = path.join(SLIDES_DIR, lectureId);
await fs.mkdir(outDir, { recursive: true });
const ext = path.extname(filePath).toLowerCase();
const tmpWorkDir = path.join(TMP_DIR, `slides-${lectureId}-${Date.now()}`);
await fs.mkdir(tmpWorkDir, { recursive: true });
let pdfPath = filePath;
if (ext === ".pptx") {
const libreOfficeCandidates = [
"soffice",
"libreoffice",
...(process.platform === "darwin"
? ["/Applications/LibreOffice.app/Contents/MacOS/soffice"]
: []),
];
let converted = false;
for (const cmd of libreOfficeCandidates) {
const { ok } = await tryExecWithLog(
cmd,
["--headless", "--convert-to", "pdf", "--outdir", tmpWorkDir, filePath],
90000,
`lecture ${lectureId} PPTX→PDF`
);
if (ok) {
converted = true;
break;
}
}
if (!converted) {
console.error(`[slide-images] lecture ${lectureId}: PPTX 변환 실패. LibreOffice(soffice/libreoffice) 설치 필요. brew install --cask libreoffice`);
await safeRemove(tmpWorkDir);
return null;
}
pdfPath = path.join(tmpWorkDir, `${path.basename(filePath, path.extname(filePath))}.pdf`);
if (!fsSync.existsSync(pdfPath)) {
console.error(`[slide-images] lecture ${lectureId}: 변환된 PDF 파일을 찾을 수 없음: ${pdfPath}`);
await safeRemove(tmpWorkDir);
return null;
}
} else if (ext !== ".pdf") {
await safeRemove(tmpWorkDir);
return null;
}
const outPrefix = path.join(tmpWorkDir, "slide");
const { ok: okPpm } = await tryExecWithLog(
"pdftoppm",
["-png", "-r", "150", pdfPath, outPrefix],
60000,
`lecture ${lectureId} pdftoppm`
);
if (!okPpm) {
await safeRemove(tmpWorkDir);
return null;
}
const slidePngs = fsSync.readdirSync(tmpWorkDir)
.filter((n) => /^slide-\d+\.png$/.test(n))
.sort((a, b) => {
const na = Number((a.match(/slide-(\d+)\.png/) || [])[1] || 0);
const nb = Number((b.match(/slide-(\d+)\.png/) || [])[1] || 0);
return na - nb;
});
if (slidePngs.length === 0) {
console.error(`[slide-images] lecture ${lectureId}: pdftoppm 출력 파일 없음. tmpWorkDir=${tmpWorkDir}`);
}
const urls = [];
for (let i = 0; i < slidePngs.length; i++) {
const srcPath = path.join(tmpWorkDir, slidePngs[i]);
const destName = `slide-${i + 1}.png`;
const destPath = path.join(outDir, destName);
try {
await fs.copyFile(srcPath, destPath);
urls.push(`/uploads/slides/${lectureId}/${destName}`);
} catch (err) {
console.error(`[slide-images] lecture ${lectureId}: 파일 복사 실패 ${slidePngs[i]}:`, err.message);
break;
}
}
await safeRemove(tmpWorkDir);
if (urls.length > 0) {
console.log(`[slide-images] lecture ${lectureId}: ${urls.length}장 슬라이드 이미지 생성 완료`);
}
return urls.length > 0 ? urls : null;
};
const getSlideImageUrls = (lectureId) => {
const dir = path.join(SLIDES_DIR, lectureId);
if (!fsSync.existsSync(dir)) return [];
const files = fsSync.readdirSync(dir)
.filter((n) => /^slide-\d+\.png$/.test(n))
.sort((a, b) => {
const na = Number((a.match(/slide-(\d+)\.png/) || [])[1] || 0);
const nb = Number((b.match(/slide-(\d+)\.png/) || [])[1] || 0);
return na - nb;
});
return files.map((f) => `/uploads/slides/${lectureId}/${f}`);
};
const AI_SUCCESS_SLIDES_SUBDIR = "ai-success-slides";
function safeAiSuccessSlug(slug) {
return (
String(slug || "")
.replace(/[^a-zA-Z0-9\-_]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "") || "story"
);
}
/** `/public/.../file.pdf` 또는 절대 URL의 `/public/...` 경로 → 절대 경로 (public 하위 PDF만) */
function resolvePublicPdfPath(pdfUrl) {
const raw = (pdfUrl || "").trim();
if (!raw) return null;
let pathname = raw;
if (/^https?:\/\//i.test(raw)) {
try {
pathname = new URL(raw).pathname;
} catch {
return null;
}
} else if (!raw.startsWith("/")) {
if (raw.startsWith("public/")) pathname = `/${raw}`;
else return null;
}
if (!pathname.startsWith("/public/")) return null;
let rel;
try {
rel = decodeURIComponent(pathname).replace(/^\/+/, "");
} catch {
rel = pathname.replace(/^\/+/, "");
}
const abs = path.join(ROOT_DIR, rel);
const normalized = path.normalize(abs);
const publicRoot = path.join(ROOT_DIR, "public");
if (!normalized.startsWith(publicRoot) || !fsSync.existsSync(normalized)) return null;
if (path.extname(normalized).toLowerCase() !== ".pdf") return null;
return normalized;
}
function getAiSuccessSlideImageUrls(slug) {
const safeSlug = safeAiSuccessSlug(slug);
const dir = path.join(UPLOAD_DIR, AI_SUCCESS_SLIDES_SUBDIR, safeSlug);
if (!fsSync.existsSync(dir)) return [];
const files = fsSync
.readdirSync(dir)
.filter((n) => /^slide-\d+\.png$/.test(n))
.sort((a, b) => {
const na = Number((a.match(/slide-(\d+)\.png/) || [])[1] || 0);
const nb = Number((b.match(/slide-(\d+)\.png/) || [])[1] || 0);
return na - nb;
});
return files.map((f) => `/uploads/${AI_SUCCESS_SLIDES_SUBDIR}/${safeSlug}/${f}`);
}
async function generateAiSuccessPdfSlides(pdfPath, slug) {
const safeSlug = safeAiSuccessSlug(slug);
const outDir = path.join(UPLOAD_DIR, AI_SUCCESS_SLIDES_SUBDIR, safeSlug);
await fs.mkdir(outDir, { recursive: true });
const tmpWorkDir = path.join(TMP_DIR, `ai-success-${safeSlug}-${Date.now()}`);
await fs.mkdir(tmpWorkDir, { recursive: true });
const outPrefix = path.join(tmpWorkDir, "slide");
const { ok: okPpm } = await tryExecWithLog(
"pdftoppm",
["-png", "-r", "150", pdfPath, outPrefix],
60000,
`ai-success ${safeSlug} pdftoppm`
);
if (!okPpm) {
await safeRemove(tmpWorkDir);
return null;
}
const slidePngs = fsSync
.readdirSync(tmpWorkDir)
.filter((n) => /^slide-\d+\.png$/.test(n))
.sort((a, b) => {
const na = Number((a.match(/slide-(\d+)\.png/) || [])[1] || 0);
const nb = Number((b.match(/slide-(\d+)\.png/) || [])[1] || 0);
return na - nb;
});
const urls = [];
for (let i = 0; i < slidePngs.length; i++) {
const srcPath = path.join(tmpWorkDir, slidePngs[i]);
const destName = `slide-${i + 1}.png`;
const destPath = path.join(outDir, destName);
try {
await fs.copyFile(srcPath, destPath);
urls.push(`/uploads/${AI_SUCCESS_SLIDES_SUBDIR}/${safeSlug}/${destName}`);
} catch (err) {
console.error(`[ai-success-slides] 복사 실패 ${slidePngs[i]}:`, err?.message || err);
break;
}
}
await safeRemove(tmpWorkDir);
if (urls.length > 0) {
console.log(`[ai-success-slides] ${safeSlug}: ${urls.length}장 생성`);
}
return urls.length > 0 ? urls : null;
}
async function ensureAiSuccessStorySlides(slug, pdfUrl) {
const pdfPath = resolvePublicPdfPath(pdfUrl);
if (!pdfPath) {
return { urls: [], slidesError: true };
}
const existing = getAiSuccessSlideImageUrls(slug);
if (existing.length > 0) {
return { urls: existing, slidesError: false };
}
const generated = await generateAiSuccessPdfSlides(pdfPath, slug);
if (!generated || generated.length === 0) {
return { urls: [], slidesError: true };
}
return { urls: generated, slidesError: false };
}
const ensurePptThumbnailFields = (lecture) => {
if (!lecture || lecture.type !== "ppt") return false;
let changed = false;
if (!lecture.thumbnailStatus) {
lecture.thumbnailStatus = lecture.thumbnailUrl ? "ready" : "pending";
changed = true;
}
if (typeof lecture.thumbnailRetryCount !== "number") {
lecture.thumbnailRetryCount = 0;
changed = true;
}
if (!lecture.thumbnailError) {
lecture.thumbnailError = null;
changed = true;
}
if (!lecture.thumbnailUpdatedAt) {
lecture.thumbnailUpdatedAt = lecture.createdAt || new Date().toISOString();
changed = true;
}
return changed;
};
const enqueueThumbnailJob = (lectureId, options = {}) => {
const force = options.force === true;
const reason = options.reason || "manual";
const persist = options.persist !== false;
if (!lectureId) return false;
if (queuedLectureIds.has(lectureId)) return false;
thumbnailQueue.push({ lectureId, force, reason, enqueuedAt: Date.now() });
queuedLectureIds.add(lectureId);
appendThumbnailEvent({
type: "enqueue",
lectureId,
reason,
force,
queueSizeAfter: thumbnailQueue.length,
}).catch(() => {
// Ignore event logging failure.
});
if (persist) {
writeThumbnailJobDb().catch(() => {
// Ignore queue persistence failure.
});
}
processThumbnailQueue();
return true;
};
const processThumbnailQueue = async () => {
if (thumbnailWorkerRunning) return;
thumbnailWorkerRunning = true;
while (thumbnailQueue.length > 0) {
const job = thumbnailQueue.shift();
queuedLectureIds.delete(job.lectureId);
await writeThumbnailJobDb().catch(() => {
// Ignore queue persistence failure.
});
try {
const startedAt = Date.now();
const lectures = await readLectureDb();
const lecture = lectures.find((item) => item.id === job.lectureId);
if (!lecture || lecture.type !== "ppt" || !lecture.fileName) continue;
ensurePptThumbnailFields(lecture);
if (!job.force && lecture.thumbnailStatus === "ready" && lecture.thumbnailUrl) {
continue;
}
lecture.thumbnailStatus = "processing";
lecture.thumbnailError = null;
lecture.thumbnailUpdatedAt = new Date().toISOString();
await writeLectureDb(lectures);
await appendThumbnailEvent({
type: "start",
lectureId: lecture.id,
lectureTitle: lecture.title,
reason: job.reason,
force: job.force,
queueSizeAfter: thumbnailQueue.length,
}).catch(() => {
// Ignore event logging failure.
});
const filePath = path.join(UPLOAD_DIR, lecture.fileName);
const newThumb = await generatePptThumbnail(filePath, lecture.id);
if (newThumb) {
lecture.thumbnailUrl = newThumb;
lecture.thumbnailStatus = "ready";
lecture.thumbnailError = null;
} else {
lecture.thumbnailStatus = "failed";
lecture.thumbnailError = "썸네일 생성 도구 실행 실패 또는 미설치";
lecture.thumbnailRetryCount = (lecture.thumbnailRetryCount || 0) + 1;
}
lecture.thumbnailUpdatedAt = new Date().toISOString();
await writeLectureDb(lectures);
await appendThumbnailEvent({
type: lecture.thumbnailStatus === "ready" ? "success" : "failed",
lectureId: lecture.id,
lectureTitle: lecture.title,
reason: job.reason,
force: job.force,
retryCount: lecture.thumbnailRetryCount || 0,
error: lecture.thumbnailError || null,
durationMs: Date.now() - startedAt,
}).catch(() => {
// Ignore event logging failure.
});
if (
lecture.thumbnailStatus === "failed" &&
lecture.thumbnailRetryCount <= THUMBNAIL_MAX_RETRY &&
ENABLE_PPT_THUMBNAIL
) {
setTimeout(() => {
enqueueThumbnailJob(lecture.id, { force: true, reason: "auto-retry" });
}, THUMBNAIL_RETRY_DELAY_MS);
}
} catch (error) {
await appendThumbnailEvent({
type: "worker-error",
lectureId: job.lectureId,
reason: job.reason,
force: job.force,
error: error?.message || "unknown worker error",
}).catch(() => {
// Ignore event logging failure.
});
// Keep queue worker alive for next jobs.
}
}
thumbnailWorkerRunning = false;
};
const removeQueuedThumbnailJobs = async (lectureId) => {
if (!lectureId) return;
let changed = false;
for (let i = thumbnailQueue.length - 1; i >= 0; i -= 1) {
if (thumbnailQueue[i].lectureId === lectureId) {
thumbnailQueue.splice(i, 1);
changed = true;
}
}
if (changed) {
queuedLectureIds.delete(lectureId);
await writeThumbnailJobDb().catch(() => {
// Ignore queue persistence failure.
});
}
};
const seedResourceLectures = async () => {
let lectures = await readLectureDb();
if (lectures.length > 0) return;
if (!fsSync.existsSync(RESOURCES_LECTURE_DIR)) return;
const files = await fs.readdir(RESOURCES_LECTURE_DIR);
const pptxFiles = files.filter((name) => path.extname(name).toLowerCase() === ".pptx");
for (const fileName of pptxFiles) {
const source = path.join(RESOURCES_LECTURE_DIR, fileName);
const targetName = `${Date.now()}-${uuidv4()}${path.extname(fileName)}`;
const target = path.join(UPLOAD_DIR, targetName);
await fs.copyFile(source, target);
lectures.push({
id: uuidv4(),
type: "ppt",
listSection: "learning",
title: path.basename(fileName, path.extname(fileName)),
description: "초기 샘플 PPT 강의",
tags: ["샘플", "PPT"],
fileName: targetName,
originalName: fileName,
createdAt: new Date().toISOString(),
});
const inserted = lectures[lectures.length - 1];
const meta = await getPptxMeta(target);
inserted.previewTitle = meta.previewTitle;
inserted.slideCount = meta.slideCount;
inserted.thumbnailUrl = null;
inserted.thumbnailStatus = "pending";
inserted.thumbnailRetryCount = 0;
inserted.thumbnailError = null;
inserted.thumbnailUpdatedAt = new Date().toISOString();
enqueueThumbnailJob(inserted.id, { force: false, reason: "seed" });
}
await writeLectureDb(lectures);
};
const ensureBootstrap = async () => {
await initPostgres();
await thumbnailEventsStore
.migrateThumbnailEventsFromJson(pgPool, THUMBNAIL_EVENT_DB_PATH, THUMBNAIL_EVENT_KEEP)
.catch((e) => console.warn("thumbnail-events migration:", e?.message || e));
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.mkdir(TMP_DIR, { recursive: true });
await fs.mkdir(UPLOAD_DIR, { recursive: true });
await fs.mkdir(THUMBNAIL_DIR, { recursive: true });
if (!pgPool && !fsSync.existsSync(LECTURE_DB_PATH)) {
await writeLectureDb([]);
}
if (!pgPool && !fsSync.existsSync(AX_ASSIGNMENTS_DB_PATH)) {
await fs.writeFile(AX_ASSIGNMENTS_DB_PATH, "[]", "utf-8");
}
if (!fsSync.existsSync(THUMBNAIL_JOB_DB_PATH)) {
await fs.writeFile(THUMBNAIL_JOB_DB_PATH, "[]", "utf-8");
}
if (!pgPool && !fsSync.existsSync(THUMBNAIL_EVENT_DB_PATH)) {
await fs.writeFile(THUMBNAIL_EVENT_DB_PATH, "[]", "utf-8");
}
await seedResourceLectures();
const lectures = await readLectureDb();
let changed = false;
for (const lecture of lectures) {
if (lecture.type !== "ppt") continue;
if (ensurePptThumbnailFields(lecture)) changed = true;
if (lecture.thumbnailStatus === "processing") {
lecture.thumbnailStatus = "pending";
lecture.thumbnailUpdatedAt = new Date().toISOString();
changed = true;
}
if (lecture.thumbnailStatus === "pending") {
enqueueThumbnailJob(lecture.id, { force: false, reason: "bootstrap" });
}
if (
lecture.thumbnailStatus === "failed" &&
lecture.thumbnailRetryCount < THUMBNAIL_MAX_RETRY &&
ENABLE_PPT_THUMBNAIL
) {
lecture.thumbnailStatus = "pending";
lecture.thumbnailError = null;
lecture.thumbnailUpdatedAt = new Date().toISOString();
changed = true;
enqueueThumbnailJob(lecture.id, { force: true, reason: "bootstrap-retry" });
}
}
const persistedJobs = await readThumbnailJobDb();
for (const job of persistedJobs) {
if (!job?.lectureId) continue;
enqueueThumbnailJob(job.lectureId, {
force: job.force === true,
reason: job.reason || "restored",
persist: false,
});
}
await writeThumbnailJobDb().catch(() => {
// Ignore queue persistence failure.
});
if (changed) await writeLectureDb(lectures);
};
function normalizeLectureSearchText(s) {
try {
return String(s || "")
.normalize("NFC")
.toLowerCase();
} catch {
return String(s || "").toLowerCase();
}
}
/** "클로드" 검색 시 본문의 "Claude", "claude" 등과도 매칭되도록 보강 */
function lectureSearchQueryVariants(qNorm) {
if (!qNorm) return [""];
const v = new Set([qNorm]);
if (qNorm.includes("클로드")) v.add(qNorm.replace(/클로드/g, "claude"));
if (qNorm.includes("claude")) v.add(qNorm.replace(/claude/g, "클로드"));
return [...v];
}
/**
* 강의 검색어(q) 매칭: title, description, previewTitle, PPT/PDF는 originalName(확장자 제외).
* 한글 NFC 정규화 + 클로드/claude 상호 보강.
*/
function lectureMatchesSearchQuery(lecture, qNorm) {
if (!qNorm) return true;
const parts = [lecture.title || "", lecture.description || ""];
const pt = (lecture.previewTitle || "").trim();
if (pt && pt !== "제목 없음" && pt !== "미리보기 생성 실패") {
parts.push(pt);
}
if (lecture.type === "ppt" || lecture.type === "video") {
const orig = (lecture.originalName || "").replace(/\.[^.]+$/, "");
if (orig) parts.push(orig);
}
if (lecture.type === "link" || lecture.type === "news") {
const u = (lecture.newsUrl || "").trim();
if (u) parts.push(u);
}
const haystack = normalizeLectureSearchText(parts.join(" "));
return lectureSearchQueryVariants(qNorm).some((variant) => haystack.includes(variant));
}
const buildLectureListContext = async (req, options = {}) => {
const { basePath = "/learning", forAdmin = false } = options;
const page = Math.max(Number(req.query.page) || 1, 1);
const q = (req.query.q || "").toString().trim();
const type = (req.query.type || "all").toString();
const tag = (req.query.tag || "").toString().trim();
const category = (req.query.category || "").toString().trim();
let tokenVal = req.query.token;
if (Array.isArray(tokenVal)) tokenVal = tokenVal[0];
const token = (tokenVal != null ? String(tokenVal) : "").trim();
const retryQueued = Math.max(Number(req.query.retryQueued) || 0, 0);
const eventsCleared = req.query.eventsCleared === "1" || req.query.eventsCleared === "true";
let lectures = await readLectureDb();
let hasMutated = false;
for (const lecture of lectures) {
if (!Array.isArray(lecture.tags)) {
lecture.tags = [];
hasMutated = true;
}
const needsMeta =
lecture.type === "ppt" &&
(lecture.previewTitle === "미리보기 생성 실패" ||
!lecture.previewTitle ||
typeof lecture.slideCount !== "number");
if (needsMeta) {
try {
const filePath = path.join(UPLOAD_DIR, lecture.fileName);
const ext = path.extname(lecture.fileName || "").toLowerCase();
const meta = ext === ".pdf" ? await getPdfMeta(filePath) : await getPptxMeta(filePath);
lecture.previewTitle = meta.previewTitle || (ext === ".pdf" ? "PDF 문서" : "");
lecture.slideCount = meta.slideCount;
hasMutated = true;
} catch {
lecture.previewTitle = lecture.previewTitle || "미리보기 생성 실패";
lecture.slideCount = typeof lecture.slideCount === "number" ? lecture.slideCount : 0;
hasMutated = true;
}
}
if (lecture.type === "ppt") {
if (ensurePptThumbnailFields(lecture)) hasMutated = true;
if (lecture.thumbnailStatus === "pending" && lecture.fileName) {
enqueueThumbnailJob(lecture.id, { force: false, reason: "list-view" });
}
}
}
if (hasMutated) await writeLectureDb(lectures);
/** 학습센터 단일 목록: learning + (구)뉴스 섹션으로 등록된 항목 포함 */
const inSection = (lec) => lec.listSection === "learning" || lec.listSection === "news";
const sectionLectures = lectures.filter(inSection);
const availableTags = [...new Set(sectionLectures.flatMap((lecture) => lecture.tags || []))].sort((a, b) =>
a.localeCompare(b, "ko-KR")
);
const ordered = [...sectionLectures].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
const qNorm = q ? normalizeLectureSearchText(q) : "";
const filtered = ordered.filter((lecture) => {
if (type !== "all" && lecture.type !== type) return false;
if (tag && !(lecture.tags || []).includes(tag)) return false;
if (category && !(lecture.tags || []).includes(category)) return false;
if (!q) return true;
return lectureMatchesSearchQuery(lecture, qNorm);
});
const totalCount = filtered.length;
const totalPages = Math.max(Math.ceil(totalCount / PAGE_SIZE), 1);
const currentPage = Math.min(page, totalPages);
const start = (currentPage - 1) * PAGE_SIZE;
const pageItems = filtered.slice(start, start + PAGE_SIZE);
for (const lec of pageItems) {
const st = lec.thumbnailStatus || "pending";
if (
(lec.type === "link" || lec.type === "news") &&
lec.newsUrl &&
!lec.thumbnailUrl &&
st !== "failed" &&
st !== "ready" &&
!linkOgPreviewScheduledIds.has(lec.id)
) {
linkOgPreviewScheduledIds.add(lec.id);
scheduleLinkOgPreviewFetch(lec.id, lec.newsUrl);
}
}
const isValidToken =
forAdmin && ADMIN_TOKEN.length > 0 && token.length > 0 && token === ADMIN_TOKEN;
const tokenForQuery = isValidToken ? token : "";
const makeQueryForPath = (nextPage) => {
const params = new URLSearchParams();
if (q) params.set("q", q);
if (type && type !== "all") params.set("type", type);
if (tag) params.set("tag", tag);
if (category) params.set("category", category);
if (forAdmin && tokenForQuery) params.set("token", tokenForQuery);
if (nextPage && nextPage > 1) params.set("page", String(nextPage));
return params.toString();
};
const returnQuery = makeQueryForPath(currentPage);
const prevQuery = makeQueryForPath(currentPage - 1);
const nextQuery = makeQueryForPath(currentPage + 1);
const paginationData = {
page: currentPage,
totalPages,
totalCount,
hasPrev: currentPage > 1,
hasNext: currentPage < totalPages,
prevQuery,
nextQuery,
pages: Array.from({ length: totalPages }, (_, idx) => {
const p = idx + 1;
return { page: p, query: makeQueryForPath(p), active: p === currentPage };
}),
};
const ctx = {
lectures: pageItems,
filters: { q: q || "", type: type || "all", tag: tag || "", category: category || "" },
availableTags,
pagination: paginationData,
returnQuery,
viewerBasePath: options.viewerBasePath || "/learning",
learningApiPath: options.learningApiPath || "/api/learning/lectures",
adminRegisterHref: options.adminRegisterHref || "/admin",
adminBasePath: options.adminBasePath || "/admin",
navActiveMenu: options.navActiveMenu || "learning",
pageTitle: options.pageTitle || "학습센터",
heroTitle: options.heroTitle || "최신 컨텐츠로 학습하고, 바로 업무에 적용하세요.",
heroDesc:
options.heroDesc ||
"유튜브·PPT·웹 링크를 등록한 뒤, 목록에서 클릭하여 강의를 시청하거나 외부 자료를 열 수 있습니다.",
sectionListHeading: options.sectionListHeading || "등록된 강의",
filterPanelTitle: options.filterPanelTitle,
adminPageTitle: options.adminPageTitle || "학습센터 관리",
};
if (forAdmin) {
ctx.adminMode = isValidToken;
ctx.adminRequested = !!token;
ctx.tokenRaw = isValidToken ? token : "";
ctx.retryQueued = retryQueued;
ctx.eventsCleared = eventsCleared;
ctx.recentEvents = [...(await readThumbnailEventDb())].reverse().slice(0, 12);
const events = await readThumbnailEventDb();
ctx.failureReasons = Object.values(
events.reduce((acc, evt) => {
if (evt.type !== "failed" || !evt.error) return acc;
const key = evt.error;
if (!acc[key]) acc[key] = { reason: key, count: 0, latestAt: evt.at };
acc[key].count += 1;
if (new Date(evt.at).getTime() > new Date(acc[key].latestAt).getTime()) acc[key].latestAt = evt.at;
return acc;
}, {})
).sort((a, b) => b.count - a.count);
ctx.thumbnailStatusSummary = sectionLectures.reduce(
(acc, l) => {
if (l.type !== "ppt") return acc;
const s = l.thumbnailStatus || "pending";
if (s === "ready") acc.ready += 1;
else if (s === "processing") acc.processing += 1;
else if (s === "failed") acc.failed += 1;
else acc.pending += 1;
return acc;
},
{ ready: 0, processing: 0, failed: 0, pending: 0 }
);
ctx.thumbnailQueueInfo = { pending: thumbnailQueue.length, working: thumbnailWorkerRunning, maxRetry: THUMBNAIL_MAX_RETRY };
}
ctx.opsUserEmail = !!(req.res && req.res.locals && req.res.locals.opsUserEmail);
return ctx;
};
app.post("/api/admin/validate-token", (req, res) => {
const token = (req.body?.token ?? "").toString().trim();
const valid =
ADMIN_TOKEN.length > 0 &&
token.length > 0 &&
token === ADMIN_TOKEN;
res.json({ valid: !!valid });
});
app.get("/", (req, res) => res.redirect("/learning"));
app.get("/learning", async (req, res) => {
const ctx = await buildLectureListContext(req, { basePath: "/learning", forAdmin: false });
res.render("learning-viewer", ctx);
});
/** 구 URL 호환 */
app.get("/newsletter", (req, res) => {
res.redirect(302, "/learning");
});
app.get("/newsletter/admin", (req, res) => {
const q = req.url.includes("?") ? req.url.slice(req.url.indexOf("?")) : "";
res.redirect(302, "/admin" + q);
});
app.get("/api/learning/lectures", async (req, res) => {
try {
const ctx = await buildLectureListContext(req, { basePath: "/learning", forAdmin: false });
res.render("learning-lectures-partial", ctx, (err, html) => {
if (err) return res.status(500).json({ error: err?.message || "렌더링 실패" });
res.json({
html: html || "",
hasNext: ctx.pagination?.hasNext || false,
nextPage: (ctx.pagination?.page || 1) + 1,
totalCount: ctx.pagination?.totalCount ?? 0,
});
});
} catch (err) {
res.status(500).json({ error: err?.message || "요청 처리 실패" });
}
});
app.get("/admin/logout", (req, res) => {
res.clearCookie(ADMIN_COOKIE_NAME, { path: "/" });
res.redirect("/learning");
});
/** 관리자: OPS 이메일 인증 로그인 사용자 목록(이메일·최근 접속일) */
app.get("/admin/users", async (req, res) => {
const token = (req.query.token != null ? String(req.query.token) : "").trim();
const cookieToken = (req.cookies?.[ADMIN_COOKIE_NAME] || "").trim();
const isValidUrlToken =
ADMIN_TOKEN.length > 0 && token.length > 0 && token === ADMIN_TOKEN;
const isValidCookie =
ADMIN_TOKEN.length > 0 && cookieToken.length > 0 && cookieToken === ADMIN_TOKEN;
if (isValidUrlToken) {
res.cookie(ADMIN_COOKIE_NAME, token, {
httpOnly: true,
maxAge: ADMIN_COOKIE_MAX_AGE,
path: "/",
});
} else if (req.cookies?.[ADMIN_COOKIE_NAME] && !isValidCookie) {
res.clearCookie(ADMIN_COOKIE_NAME, { path: "/" });
}
const allowed = isValidUrlToken || isValidCookie;
if (!allowed) {
return res.redirect("/learning");
}
let users = [];
let dbError = null;
const pgConnected = ENABLE_POSTGRES && !!pgPool;
try {
if (pgPool) {
const r = await pgPool.query(
`SELECT email, last_login_at, first_seen_at, login_count
FROM ops_email_users
ORDER BY last_login_at DESC NULLS LAST`
);
users = (r.rows || []).map((row) => ({
email: row.email,
lastLoginAt: row.last_login_at,
firstSeenAt: row.first_seen_at,
loginCount: row.login_count,
}));
}
} catch (err) {
console.error("[admin/users]", err);
dbError = err?.message || "조회 실패";
}
res.render("admin-users", {
adminMode: true,
activeMenu: "admin-users",
users,
dbError,
pgConnected,
});
});
app.get("/admin", async (req, res) => {
const ctx = await buildLectureListContext(req, {
basePath: "/admin",
forAdmin: true,
adminBasePath: "/admin",
viewerBasePath: "/learning",
navActiveMenu: "learning",
});
const token = (req.query.token != null ? String(req.query.token) : "").trim();
const cookieToken = (req.cookies?.[ADMIN_COOKIE_NAME] || "").trim();
const isValidUrlToken =
ADMIN_TOKEN.length > 0 && token.length > 0 && token === ADMIN_TOKEN;
const isValidCookie =
ADMIN_TOKEN.length > 0 && cookieToken.length > 0 && cookieToken === ADMIN_TOKEN;
if (isValidUrlToken) {
res.cookie(ADMIN_COOKIE_NAME, token, {
httpOnly: true,
maxAge: ADMIN_COOKIE_MAX_AGE,
path: "/",
});
} else if (isValidCookie) {
ctx.adminMode = true;
ctx.tokenRaw = cookieToken;
} else if (req.cookies?.[ADMIN_COOKIE_NAME]) {
res.clearCookie(ADMIN_COOKIE_NAME, { path: "/" });
res.locals.adminMode = false;
}
res.render("learning-admin", ctx);
});
app.post("/lectures/youtube", async (req, res) => {
const token = (req.body.token || "").toString().trim();
if (token !== ADMIN_TOKEN) {
res.status(403).send("관리자 토큰이 필요합니다.");
return;
}
const { title, description, youtubeUrl, tags, category } = req.body;
if (!title?.trim() || !youtubeUrl?.trim()) {
res.status(400).send("제목과 유튜브 링크는 필수입니다.");
return;
}
if (!isYoutubeUrl(youtubeUrl)) {
res.status(400).send("유효한 유튜브 링크를 입력해주세요.");
return;
}
const lectures = await readLectureDb();
lectures.push({
id: uuidv4(),
type: "youtube",
listSection: "learning",
title: title.trim(),
description: (description || "").trim(),
tags: mergeTagsWithCategory(tags, category),
youtubeUrl: youtubeUrl.trim(),
createdAt: new Date().toISOString(),
});
await writeLectureDb(lectures);
const returnTo = (req.body.returnTo || "").toString().trim();
res.redirect(returnTo.startsWith("/") ? returnTo : "/admin");
});
app.post("/lectures/ppt", upload.single("pptFile"), async (req, res) => {
const token = (req.body.token || "").toString().trim();
if (token !== ADMIN_TOKEN) {
res.status(403).send("관리자 토큰이 필요합니다.");
return;
}
const { title, description, tags, category } = req.body;
if (!req.file) {
res.status(400).send("PDF 또는 PPT 파일이 필요합니다.");
return;
}
if (!title?.trim()) {
res.status(400).send("제목은 필수입니다.");
return;
}
const filePath = path.join(UPLOAD_DIR, req.file.filename);
const ext = path.extname(req.file.filename).toLowerCase();
let pptMeta = { previewTitle: "", slideCount: 0 };
try {
if (ext === ".pdf") {
pptMeta = await getPdfMeta(filePath);
} else {
pptMeta = await getPptxMeta(filePath);
}
} catch {
// Keep defaults; upload still succeeds even if metadata parsing fails.
}
const lectures = await readLectureDb();
const lectureId = uuidv4();
lectures.push({
id: lectureId,
type: "ppt",
listSection: "learning",
title: title.trim(),
description: (description || "").trim(),
tags: mergeTagsWithCategory(tags, category),
fileName: req.file.filename,
originalName: req.file.originalname,
previewTitle: pptMeta.previewTitle,
slideCount: pptMeta.slideCount,
thumbnailUrl: null,
thumbnailStatus: "pending",
thumbnailRetryCount: 0,
thumbnailError: null,
thumbnailUpdatedAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
});
await writeLectureDb(lectures);
enqueueThumbnailJob(lectureId, { force: false, reason: "upload" });
if (ENABLE_PPT_THUMBNAIL) {
generateSlideImages(filePath, lectureId).catch((err) => {
console.error("[slide-images] 업로드 후 생성 실패:", err?.message || err);
});
}
const returnTo = (req.body.returnTo || "").toString().trim();
res.redirect(returnTo.startsWith("/") ? returnTo : "/admin");
});
app.post(
"/lectures/video",
(req, res, next) => {
uploadVideo.single("videoFile")(req, res, (err) => {
if (err) {
res.status(400).send(err.message || "동영상 업로드에 실패했습니다.");
return;
}
next();
});
},
async (req, res) => {
const token = (req.body.token || "").toString().trim();
if (token !== ADMIN_TOKEN) {
res.status(403).send("관리자 토큰이 필요합니다.");
return;
}
const { title, description, tags, category } = req.body;
if (!req.file) {
res.status(400).send("동영상 파일이 필요합니다.");
return;
}
if (!title?.trim()) {
res.status(400).send("제목은 필수입니다.");
return;
}
const lectures = await readLectureDb();
const lectureId = uuidv4();
const createdAt = new Date().toISOString();
lectures.push({
id: lectureId,
type: "video",
listSection: "learning",
title: title.trim(),
description: (description || "").trim(),
tags: mergeTagsWithCategory(tags, category),
fileName: req.file.filename,
originalName: req.file.originalname,
previewTitle: null,
slideCount: 0,
youtubeUrl: null,
thumbnailUrl: null,
thumbnailStatus: "ready",
thumbnailRetryCount: 0,
thumbnailError: null,
thumbnailUpdatedAt: createdAt,
createdAt,
});
await writeLectureDb(lectures);
const returnTo = (req.body.returnTo || "").toString().trim();
res.redirect(returnTo.startsWith("/") ? returnTo : "/admin");
}
);
/** 학습센터: 유튜브가 아닌 일반 http(s) 링크를 카드로 등록 (news_url 컬럼 사용) */
app.post("/lectures/link", async (req, res) => {
const token = (req.body.token || "").toString().trim();
if (token !== ADMIN_TOKEN) {
res.status(403).send("관리자 토큰이 필요합니다.");
return;
}
const { title, description, tags, category } = req.body;
const linkUrl = (req.body.linkUrl || "").toString().trim();
if (!title?.trim() || !linkUrl) {
res.status(400).send("제목과 URL은 필수입니다.");
return;
}
if (!isHttpUrl(linkUrl)) {
res.status(400).send("유효한 http(s) URL을 입력해주세요.");
return;
}
if (isYoutubeUrl(linkUrl)) {
res.status(400).send("유튜브 링크는 «유튜브 강의 등록»을 이용해주세요.");
return;
}
const lectures = await readLectureDb();
const lectureId = uuidv4();
const createdAt = new Date().toISOString();
lectures.push({
id: lectureId,
type: "link",
listSection: "learning",
title: title.trim(),
description: (description || "").trim(),
tags: mergeTagsWithCategory(tags, category),
newsUrl: linkUrl,
thumbnailUrl: null,
thumbnailStatus: "pending",
thumbnailRetryCount: 0,
thumbnailError: null,
thumbnailUpdatedAt: createdAt,
createdAt,
});
await writeLectureDb(lectures);
scheduleLinkOgPreviewFetch(lectureId, linkUrl);
const returnTo = (req.body.returnTo || "").toString().trim();
res.redirect(returnTo.startsWith("/") ? returnTo : "/admin");
});
app.post("/lectures/:id/delete", async (req, res) => {
const { token, returnTo } = req.body;
if ((token || "").trim() !== ADMIN_TOKEN) {
res.status(403).send("관리자 토큰이 유효하지 않습니다.");
return;
}
const lectures = await readLectureDb();
const index = lectures.findIndex((item) => item.id === req.params.id);
if (index < 0) {
res.status(404).send("삭제할 강의를 찾을 수 없습니다.");
return;
}
const target = lectures[index];
await removeQueuedThumbnailJobs(target.id);
const storedFileTypes = ["ppt", "video"];
if (storedFileTypes.includes(target.type) && target.fileName) {
const filePath = path.join(UPLOAD_DIR, target.fileName);
try {
await fs.unlink(filePath);
} catch {
// Ignore file delete failures to keep metadata consistent.
}
if (target.type === "ppt") {
if (target.thumbnailUrl) {
const thumbPath = path.join(ROOT_DIR, target.thumbnailUrl.replace(/^\//, ""));
try {
await fs.unlink(thumbPath);
} catch {
// Ignore thumbnail delete failures.
}
}
const slidesDir = path.join(SLIDES_DIR, target.id);
try {
await fs.rm(slidesDir, { recursive: true, force: true });
} catch {
// Ignore slide images delete failures.
}
}
}
lectures.splice(index, 1);
await writeLectureDb(lectures);
const safeReturn = (returnTo || "").toString().trim();
if (safeReturn.startsWith("/")) {
res.redirect(safeReturn);
return;
}
if (safeReturn.startsWith("?")) {
res.redirect(`/admin${safeReturn}`);
return;
}
res.redirect("/admin");
});
app.post("/lectures/:id/thumbnail/regenerate", async (req, res) => {
const { token, returnTo } = req.body;
if ((token || "").trim() !== ADMIN_TOKEN) {
res.status(403).send("관리자 토큰이 유효하지 않습니다.");
return;
}
const lectures = await readLectureDb();
const lecture = lectures.find((item) => item.id === req.params.id);
if (!lecture) {
res.status(404).send("강의를 찾을 수 없습니다.");
return;
}
if (lecture.type !== "ppt" || !lecture.fileName) {
res.status(400).send("PPT 강의만 썸네일 재생성이 가능합니다.");
return;
}
ensurePptThumbnailFields(lecture);
lecture.thumbnailStatus = "pending";
lecture.thumbnailError = null;
lecture.thumbnailRetryCount = 0;
lecture.thumbnailUpdatedAt = new Date().toISOString();
await writeLectureDb(lectures);
enqueueThumbnailJob(lecture.id, { force: true, reason: "manual-regenerate" });
const safeReturn = (returnTo || "").toString().trim();
if (safeReturn.startsWith("/")) {
res.redirect(safeReturn);
return;
}
if (safeReturn.startsWith("?")) {
res.redirect(`/admin${safeReturn}`);
return;
}
res.redirect("/admin");
});
app.post("/lectures/:id/slides/regenerate", async (req, res) => {
const { token, returnTo } = req.body;
if ((token || "").trim() !== ADMIN_TOKEN) {
res.status(403).send("관리자 토큰이 유효하지 않습니다.");
return;
}
const lectures = await readLectureDb();
const lecture = lectures.find((item) => item.id === req.params.id);
if (!lecture) {
res.status(404).send("강의를 찾을 수 없습니다.");
return;
}
if (lecture.type !== "ppt" || !lecture.fileName) {
res.status(400).send("PDF/PowerPoint 강의만 슬라이드 이미지 재생성이 가능합니다.");
return;
}
const filePath = path.join(UPLOAD_DIR, lecture.fileName);
if (!fsSync.existsSync(filePath)) {
res.status(400).send("원본 파일을 찾을 수 없습니다.");
return;
}
const outDir = path.join(SLIDES_DIR, lecture.id);
try {
await fs.rm(outDir, { recursive: true, force: true });
} catch {
// Ignore
}
const urls = await generateSlideImages(filePath, lecture.id);
if (urls && urls.length > 0) {
res.redirect(`/lectures/${lecture.id}`);
} else {
res.redirect(`/lectures/${lecture.id}?slidesError=1`);
}
});
app.post("/thumbnails/retry-failed", async (req, res) => {
const { token, returnTo } = req.body;
if ((token || "").trim() !== ADMIN_TOKEN) {
res.status(403).send("관리자 토큰이 유효하지 않습니다.");
return;
}
const lectures = await readLectureDb();
let queued = 0;
for (const lecture of lectures) {
if (lecture.type !== "ppt") continue;
ensurePptThumbnailFields(lecture);
if (lecture.thumbnailStatus === "failed") {
lecture.thumbnailStatus = "pending";
lecture.thumbnailError = null;
lecture.thumbnailRetryCount = 0;
lecture.thumbnailUpdatedAt = new Date().toISOString();
if (enqueueThumbnailJob(lecture.id, { force: true, reason: "bulk-retry" })) {
queued += 1;
}
}
}
await writeLectureDb(lectures);
const safeReturn = (returnTo || "").toString().trim();
if (safeReturn.startsWith("/")) {
const sep = safeReturn.includes("?") ? "&" : "?";
res.redirect(`${safeReturn}${sep}retryQueued=${queued}`);
return;
}
if (safeReturn.startsWith("?")) {
const joiner = safeReturn.length > 1 ? "&" : "";
res.redirect(`/admin${safeReturn}${joiner}retryQueued=${queued}`);
return;
}
res.redirect(`/admin?retryQueued=${queued}`);
});
app.post("/thumbnails/events/clear", async (req, res) => {
const { token, returnTo } = req.body;
if ((token || "").trim() !== ADMIN_TOKEN) {
res.status(403).send("관리자 토큰이 유효하지 않습니다.");
return;
}
await writeThumbnailEventDb([]);
const safeReturn = (returnTo || "").toString().trim();
if (safeReturn.startsWith("/")) {
const sep = safeReturn.includes("?") ? "&" : "?";
res.redirect(`${safeReturn}${sep}eventsCleared=1`);
return;
}
if (safeReturn.startsWith("?")) {
const joiner = safeReturn.length > 1 ? "&" : "";
res.redirect(`/admin${safeReturn}${joiner}eventsCleared=1`);
return;
}
res.redirect("/admin?eventsCleared=1");
});
app.get("/admin/thumbnail-events", async (req, res) => {
const token = (req.query.token || "").toString();
if (token !== ADMIN_TOKEN) {
res.status(403).send("관리자 토큰이 유효하지 않습니다.");
return;
}
const page = Math.max(Number(req.query.page) || 1, 1);
const limit = Math.min(Math.max(Number(req.query.limit) || THUMBNAIL_EVENT_PAGE_SIZE, 10), 200);
const filter = buildEventFilter(req.query);
const events = await readThumbnailEventDb();
const filtered = [...events].reverse().filter(filter.matches);
const totalCount = filtered.length;
const totalPages = Math.max(Math.ceil(totalCount / limit), 1);
const currentPage = Math.min(page, totalPages);
const start = (currentPage - 1) * limit;
const pageItems = filtered.slice(start, start + limit);
const makeQuery = (nextPage) => {
const params = new URLSearchParams();
params.set("token", token);
if (filter.eventType !== "all") params.set("eventType", filter.eventType);
if (filter.lectureId) params.set("lectureId", filter.lectureId);
if (filter.reason) params.set("reason", filter.reason);
if (filter.from) params.set("from", filter.from);
if (filter.to) params.set("to", filter.to);
if (limit !== THUMBNAIL_EVENT_PAGE_SIZE) params.set("limit", String(limit));
if (nextPage > 1) params.set("page", String(nextPage));
return params.toString();
};
const pagination = {
page: currentPage,
totalPages,
totalCount,
hasPrev: currentPage > 1,
hasNext: currentPage < totalPages,
prevQuery: makeQuery(currentPage - 1),
nextQuery: makeQuery(currentPage + 1),
pages: Array.from({ length: totalPages }, (_, idx) => {
const p = idx + 1;
return { page: p, query: makeQuery(p), active: p === currentPage };
}),
};
const csvQuery = (() => {
const params = new URLSearchParams(makeQuery(1));
params.delete("page");
return params.toString();
})();
const eventsCleared = req.query.eventsCleared === "1" || req.query.eventsCleared === "true";
res.render("admin-thumbnail-events", {
token,
filters: {
eventType: filter.eventType,
lectureId: filter.lectureId,
reason: filter.reason,
from: filter.from,
to: filter.to,
limit,
},
events: pageItems,
pagination,
csvQuery,
eventsCleared,
});
});
app.get("/admin/thumbnail-events.csv", async (req, res) => {
const token = (req.query.token || "").toString();
if (token !== ADMIN_TOKEN) {
res.status(403).send("관리자 토큰이 유효하지 않습니다.");
return;
}
const filter = buildEventFilter(req.query);
const events = await readThumbnailEventDb();
const filtered = [...events].reverse().filter(filter.matches);
const header = ["id", "at", "type", "lectureId", "lectureTitle", "reason", "force", "retryCount", "durationMs", "error"];
const rows = filtered.map((evt) =>
[
evt.id,
evt.at,
evt.type,
evt.lectureId || "",
evt.lectureTitle || "",
evt.reason || "",
evt.force === true ? "true" : "false",
evt.retryCount ?? "",
evt.durationMs ?? "",
evt.error || "",
]
.map(escapeCsv)
.join(",")
);
const csv = [header.join(","), ...rows].join("\n");
res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader("Content-Disposition", `attachment; filename="thumbnail-events-${Date.now()}.csv"`);
res.send(csv);
});
app.get("/api/queue/metrics", async (req, res) => {
const token = (req.query.token || "").toString();
if (token !== ADMIN_TOKEN) {
res.status(403).json({ message: "관리자 토큰이 유효하지 않습니다." });
return;
}
const lectures = await readLectureDb();
const events = await readThumbnailEventDb();
const pptLectures = lectures.filter((lecture) => lecture.type === "ppt");
const statusCounts = pptLectures.reduce(
(acc, lecture) => {
const status = lecture.thumbnailStatus || "pending";
if (status === "ready") acc.ready += 1;
else if (status === "processing") acc.processing += 1;
else if (status === "failed") acc.failed += 1;
else acc.pending += 1;
return acc;
},
{ ready: 0, processing: 0, pending: 0, failed: 0 }
);
const recentFailures = [...events]
.filter((evt) => evt.type === "failed")
.reverse()
.slice(0, 10)
.map((evt) => ({
at: evt.at,
lectureId: evt.lectureId,
lectureTitle: evt.lectureTitle || null,
reason: evt.reason || null,
error: evt.error || null,
retryCount: evt.retryCount || 0,
}));
res.json({
queue: {
pending: thumbnailQueue.length,
working: thumbnailWorkerRunning,
queuedLectureIds: [...queuedLectureIds],
},
statusCounts,
config: {
maxRetry: THUMBNAIL_MAX_RETRY,
retryDelayMs: THUMBNAIL_RETRY_DELAY_MS,
eventKeep: THUMBNAIL_EVENT_KEEP,
thumbnailEnabled: ENABLE_PPT_THUMBNAIL,
},
recentFailures,
generatedAt: new Date().toISOString(),
});
});
app.get("/api/queue/events-summary", async (req, res) => {
const token = (req.query.token || "").toString();
if (token !== ADMIN_TOKEN) {
res.status(403).json({ message: "관리자 토큰이 유효하지 않습니다." });
return;
}
const hours = Math.min(Math.max(Number(req.query.hours) || 24, 1), 168);
const now = Date.now();
const startMs = now - hours * 60 * 60 * 1000;
const events = await readThumbnailEventDb();
const scoped = events.filter((evt) => {
const atMs = new Date(evt.at || "").getTime();
return Number.isFinite(atMs) && atMs >= startMs;
});
const processed = scoped.filter((evt) => evt.type === "success" || evt.type === "failed");
const successCount = processed.filter((evt) => evt.type === "success").length;
const failedCount = processed.filter((evt) => evt.type === "failed").length;
const processedCount = processed.length;
const failureRate = processedCount > 0 ? failedCount / processedCount : 0;
const durations = processed
.map((evt) => Number(evt.durationMs))
.filter((ms) => Number.isFinite(ms) && ms >= 0);
const avgDurationMs = durations.length
? Math.round(durations.reduce((sum, ms) => sum + ms, 0) / durations.length)
: 0;
const bucketMap = new Map();
for (let i = 0; i < hours; i += 1) {
const bucketStart = startMs + i * 60 * 60 * 1000;
const key = new Date(bucketStart).toISOString().slice(0, 13);
bucketMap.set(key, {
key,
label: `${new Date(bucketStart).getHours()}`,
processed: 0,
success: 0,
failed: 0,
});
}
for (const evt of processed) {
const atMs = new Date(evt.at || "").getTime();
if (!Number.isFinite(atMs) || atMs < startMs) continue;
const key = new Date(atMs).toISOString().slice(0, 13);
const bucket = bucketMap.get(key);
if (!bucket) continue;
bucket.processed += 1;
if (evt.type === "success") bucket.success += 1;
if (evt.type === "failed") bucket.failed += 1;
}
const buckets = [...bucketMap.values()];
const queue = {
pending: thumbnailQueue.length,
working: thumbnailWorkerRunning,
};
res.json({
windowHours: hours,
generatedAt: new Date().toISOString(),
kpi: {
processedCount,
successCount,
failedCount,
failureRate,
avgDurationMs,
},
queue,
buckets,
});
});
app.get("/lectures/:id/edit", async (req, res) => {
const queryToken = (req.query.token || "").toString().trim();
const cookieToken = (req.cookies?.[ADMIN_COOKIE_NAME] || "").toString().trim();
const isValidQuery = ADMIN_TOKEN.length > 0 && queryToken === ADMIN_TOKEN;
const isValidCookie = ADMIN_TOKEN.length > 0 && cookieToken.length > 0 && cookieToken === ADMIN_TOKEN;
if (!isValidQuery && !isValidCookie) {
res.status(403).send("관리자 토큰이 필요합니다.");
return;
}
const tokenRaw = isValidQuery ? queryToken : cookieToken;
const lectures = await readLectureDb();
const lecture = lectures.find((item) => item.id === req.params.id);
if (!lecture) {
res.status(404).send("강의를 찾을 수 없습니다.");
return;
}
const returnQuery = new URLSearchParams(req.query).toString();
res.render("learning-edit", {
lecture,
tokenRaw,
returnQuery: returnQuery || (tokenRaw ? `token=${encodeURIComponent(tokenRaw)}` : ""),
adminBasePath: "/admin",
viewerBasePath: "/learning",
navActiveMenu: "learning",
});
});
app.post("/lectures/:id/update", async (req, res) => {
const { token, returnTo, title, description, tags, category } = req.body;
const youtubeUrl = req.body.youtubeUrl;
if ((token || "").trim() !== ADMIN_TOKEN) {
res.status(403).send("관리자 토큰이 유효하지 않습니다.");
return;
}
const lectures = await readLectureDb();
const index = lectures.findIndex((item) => item.id === req.params.id);
if (index < 0) {
res.status(404).send("수정할 강의를 찾을 수 없습니다.");
return;
}
const lecture = lectures[index];
if (!title?.trim()) {
res.status(400).send("제목은 필수입니다.");
return;
}
if (lecture.type === "youtube") {
if (!youtubeUrl?.trim()) {
res.status(400).send("유튜브 링크는 필수입니다.");
return;
}
if (!isYoutubeUrl(youtubeUrl)) {
res.status(400).send("유효한 유튜브 링크를 입력해주세요.");
return;
}
lecture.youtubeUrl = youtubeUrl.trim();
}
let scheduleOgAfterWrite = false;
if (lecture.type === "news" || lecture.type === "link") {
const nu = (req.body.newsUrl || "").toString().trim();
if (!nu) {
res.status(400).send(lecture.type === "link" ? "URL은 필수입니다." : "뉴스 URL은 필수입니다.");
return;
}
if (!isHttpUrl(nu)) {
res.status(400).send("유효한 http(s) URL을 입력해주세요.");
return;
}
if (lecture.type === "link" && isYoutubeUrl(nu)) {
res.status(400).send("유튜브 링크는 «유튜브 강의 등록»을 이용해주세요.");
return;
}
const prevUrl = (lecture.newsUrl || "").trim();
lecture.newsUrl = nu;
if (prevUrl !== nu) {
scheduleOgAfterWrite = true;
lecture.thumbnailUrl = null;
lecture.thumbnailStatus = "pending";
lecture.thumbnailError = null;
lecture.thumbnailUpdatedAt = new Date().toISOString();
}
}
lecture.title = title.trim();
lecture.description = (description || "").trim();
lecture.tags = mergeTagsWithCategory(tags, category);
await writeLectureDb(lectures);
if (scheduleOgAfterWrite && (lecture.type === "news" || lecture.type === "link")) {
const nu = (lecture.newsUrl || "").trim();
if (nu && isHttpUrl(nu) && !(lecture.type === "link" && isYoutubeUrl(nu))) {
scheduleLinkOgPreviewFetch(lecture.id, nu);
}
}
const safeReturn = (returnTo || "").toString().trim();
res.redirect(safeReturn.startsWith("/") ? safeReturn : `/admin?${safeReturn || ""}`);
});
app.get("/lectures/:id", async (req, res) => {
const lectures = await readLectureDb();
const lecture = lectures.find((item) => item.id === req.params.id);
if (!lecture) {
res.status(404).send("강의를 찾을 수 없습니다.");
return;
}
if (lecture.type === "youtube") {
res.render("lecture-youtube", {
lecture,
embedUrl: toYoutubeEmbedUrl(lecture.youtubeUrl),
});
return;
}
if (lecture.type === "news" || lecture.type === "link") {
const nu = (lecture.newsUrl || "").trim();
if (nu && isHttpUrl(nu)) {
res.redirect(302, nu);
return;
}
res.render(lecture.type === "news" ? "lecture-news" : "lecture-link", { lecture });
return;
}
if (lecture.type === "video" && lecture.fileName) {
const filePath = path.join(UPLOAD_DIR, lecture.fileName);
if (!fsSync.existsSync(filePath)) {
res.status(404).send("동영상 파일을 찾을 수 없습니다.");
return;
}
const videoSrc = `/uploads/${path.basename(lecture.fileName)}`;
res.render("lecture-video", { lecture, videoSrc });
return;
}
const filePath = path.join(UPLOAD_DIR, lecture.fileName);
const ext = path.extname(lecture.fileName || "").toLowerCase();
let slides =
ext === ".pdf"
? await parsePdfSlides(filePath)
: await parsePptxSlides(filePath);
const slideImageUrls = getSlideImageUrls(lecture.id);
if (slides.length === 0 && slideImageUrls.length > 0) {
slides = slideImageUrls.map(() => ({ title: "", lines: [] }));
}
res.render("lecture-ppt", {
lecture,
slides,
slideImageUrls: slideImageUrls || [],
slidesError: req.query.slidesError === "1",
});
});
app.use((req, res) => {
res.status(404).send(`페이지를 찾을 수 없습니다: ${req.method} ${req.path}`);
});
app.use((error, _, res, __) => {
res.status(400).send(error.message || "요청 처리 중 오류가 발생했습니다.");
});
ensureBootstrap()
.then(() => {
app.listen(PORT, HOST, () => {
const hint = HOST === "0.0.0.0" || HOST === "::" ? "localhost" : HOST;
console.log(`Server started: http://${hint}:${PORT} (listening on ${HOST}:${PORT})`);
});
})
.catch((error) => {
console.error("Failed to bootstrap:", error);
process.exit(1);
});