4292 lines
159 KiB
JavaScript
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(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'");
|
|
|
|
const escapeHtml = (value) =>
|
|
(value || "")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
|
|
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);
|
|
});
|