Initial commit: AI platform app (server, views, lib, data, deploy docs)

Made-with: Cursor
This commit is contained in:
2026-04-03 20:45:17 +09:00
commit da39cfeef9
70 changed files with 17506 additions and 0 deletions

62
scripts/apply-schema.js Normal file
View File

@@ -0,0 +1,62 @@
require("dotenv").config({ quiet: true });
const fs = require("fs").promises;
const path = require("path");
const { Pool } = require("pg");
const ROOT_DIR = path.join(__dirname, "..");
const SCHEMA_PATH = path.join(ROOT_DIR, "db", "schema.sql");
const getConfig = () => ({
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 validateConfig = (config) => {
const missing = ["host", "database", "user", "password"].filter((key) => !config[key]);
if (missing.length) {
throw new Error(`Missing PostgreSQL envs: ${missing.join(", ")}`);
}
};
const run = async () => {
const config = getConfig();
validateConfig(config);
const sql = await fs.readFile(SCHEMA_PATH, "utf-8");
const pool = new Pool(config);
try {
await pool.query("SELECT 1");
await pool.query(sql);
console.log("Schema applied successfully.");
} finally {
await pool.end();
}
};
run().catch((error) => {
console.error("Failed to apply schema:", error.message);
const msg = String(error.message || "");
if (error.code === "28P01" || /password authentication failed/i.test(msg)) {
console.error(
"\n[DB 인증 실패] .env의 DB_USERNAME·DB_PASSWORD가 PostgreSQL에 등록된 사용자·비밀번호와 일치하는지 확인하세요.",
);
console.error(
" (원격 DB면 관리자에게 계정을 확인하고, 로컬이면 `sudo -u postgres psql`에서 \\password 사용자명 으로 비밀번호를 맞추세요.)",
);
} else if (/ECONNREFUSED|ENOTFOUND/i.test(msg) || error.code === "ECONNREFUSED") {
console.error("\n[DB 연결 불가] DB_HOST·DB_PORT가 맞는지, 방화벽·PostgreSQL listen_addresses를 확인하세요.");
} else if (error.code === "28000" || /role .* does not exist/i.test(msg)) {
console.error(
"\n[DB 사용자 없음] .env의 DB_USERNAME에 해당하는 PostgreSQL 역할(role)이 서버에 없습니다.",
);
console.error(
" 슈퍼유저로 접속해 CREATE ROLE ... LOGIN PASSWORD '...'; 및 GRANT를 실행하거나, db/bootstrap-role.sql.example을 참고하세요.",
);
}
process.exit(1);
});

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env node
/**
* ax_assignments 테이블 컬럼 타입 확인 (PostgreSQL)
* 사용: node scripts/check-ax-schema.js
*/
require("dotenv").config({ path: require("path").join(__dirname, "..", ".env") });
const { Pool } = require("pg");
const pool = new Pool({
host: process.env.DB_HOST || "localhost",
port: Number(process.env.DB_PORT || 5432),
database: process.env.DB_DATABASE || "ai_web_platform",
user: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
});
async function main() {
const res = await pool.query(`
SELECT column_name, data_type, udt_name
FROM information_schema.columns
WHERE table_name = 'ax_assignments'
ORDER BY ordinal_position
`);
console.log("ax_assignments columns:");
res.rows.forEach((r) => console.log(` ${r.column_name}: ${r.data_type} (${r.udt_name})`));
await pool.end();
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,269 @@
#!/usr/bin/env node
/**
* .env.ncue(원본 DB) → .env(대상 DB) 데이터 복사
*
* 학습센터(강의 목록): lectures 테이블
* 선택: lecture_thumbnail_events (썸네일 작업 로그)
*
* 사용 전:
* 1) 대상 DB에 스키마 적용: node scripts/apply-schema.js (.env 기준)
* 2) 대상 서버에 강의 파일 동기화(별도): DB만으로는 PPT/슬라이드/썸네일 바이너리가 없음
* - resources/lecture/ (업로드 원본 PPT 등)
* - uploads/thumbnails/ (썸네일)
* - uploads/slides/<강의UUID>/ (슬라이드 이미지)
*
* 실행 (저장소 루트에서):
* node scripts/migrate-db-ncue-to-env.js
* node scripts/migrate-db-ncue-to-env.js --dry-run
* node scripts/migrate-db-ncue-to-env.js --with-thumbnail-events
*
* 대상에 강의만 원본과 동일하게 맞추고(기존 행 삭제 후 복사):
* node scripts/migrate-db-ncue-to-env.js --truncate-target
*
* 경로 변경:
* SOURCE_ENV=.env.ncue TARGET_ENV=.env node scripts/migrate-db-ncue-to-env.js
*/
require("dotenv").config({ quiet: true });
const fs = require("fs");
const path = require("path");
const dotenv = require("dotenv");
const { Pool } = require("pg");
const ROOT = path.join(__dirname, "..");
const LECTURE_COLUMNS = [
"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",
"updated_at",
"list_section",
"news_url",
];
const THUMB_EVENT_COLUMNS = [
"id",
"occurred_at",
"event_type",
"lecture_id",
"lecture_title",
"reason",
"force_flag",
"queue_size_after",
"retry_count",
"duration_ms",
"error_text",
];
function parseArgs(argv) {
const out = { dryRun: false, withThumbnailEvents: false, truncateTarget: false };
for (const a of argv) {
if (a === "--dry-run") out.dryRun = true;
if (a === "--with-thumbnail-events") out.withThumbnailEvents = true;
if (a === "--truncate-target") out.truncateTarget = true;
}
return out;
}
function loadEnvFile(filePath) {
const abs = path.isAbsolute(filePath) ? filePath : path.join(ROOT, filePath);
if (!fs.existsSync(abs)) {
throw new Error(`환경 파일이 없습니다: ${abs}`);
}
return dotenv.parse(fs.readFileSync(abs, "utf8"));
}
function poolConfigFromEnv(e) {
const missing = ["DB_HOST", "DB_DATABASE", "DB_USERNAME", "DB_PASSWORD"].filter((k) => !e[k]);
if (missing.length) {
throw new Error(`DB 설정 누락: ${missing.join(", ")}`);
}
return {
host: e.DB_HOST,
port: Number(e.DB_PORT || 5432),
database: e.DB_DATABASE,
user: e.DB_USERNAME,
password: e.DB_PASSWORD,
ssl: e.DB_SSL === "1" ? { rejectUnauthorized: false } : false,
};
}
function placeholders(n) {
return Array.from({ length: n }, (_, i) => `$${i + 1}`).join(", ");
}
function buildLectureUpsert() {
const cols = LECTURE_COLUMNS;
const ph = placeholders(cols.length);
const updates = cols
.filter((c) => c !== "id")
.map((c) => `${c} = EXCLUDED.${c}`)
.join(", ");
return `
INSERT INTO lectures (${cols.join(", ")})
VALUES (${ph})
ON CONFLICT (id) DO UPDATE SET ${updates}
`;
}
function buildThumbEventUpsert() {
const cols = THUMB_EVENT_COLUMNS;
const ph = placeholders(cols.length);
const updates = cols
.filter((c) => c !== "id")
.map((c) => `${c} = EXCLUDED.${c}`)
.join(", ");
return `
INSERT INTO lecture_thumbnail_events (${cols.join(", ")})
VALUES (${ph})
ON CONFLICT (id) DO UPDATE SET ${updates}
`;
}
function rowValues(row, columns) {
return columns.map((c) => row[c]);
}
async function truncateTargetTables(targetPool, dryRun) {
if (dryRun) {
console.log(
"[truncate] dry-run: lecture_thumbnail_events, lectures TRUNCATE 생략"
);
return;
}
await targetPool.query("TRUNCATE lecture_thumbnail_events");
await targetPool.query("TRUNCATE lectures");
console.log("[truncate] 대상 lecture_thumbnail_events, lectures 비움");
}
async function migrateLectures(sourcePool, targetPool, dryRun) {
const { rows } = await sourcePool.query(
`SELECT ${LECTURE_COLUMNS.join(", ")} FROM lectures ORDER BY created_at ASC`
);
console.log(`[lectures] 원본 행 수: ${rows.length}`);
if (dryRun) {
console.log("[lectures] dry-run: INSERT 생략");
return rows.length;
}
const sql = buildLectureUpsert();
const client = await targetPool.connect();
try {
await client.query("BEGIN");
let n = 0;
for (const row of rows) {
await client.query(sql, rowValues(row, LECTURE_COLUMNS));
n++;
}
await client.query("COMMIT");
console.log(`[lectures] 대상 DB 반영 완료: ${n}`);
return n;
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
async function migrateThumbnailEvents(sourcePool, targetPool, dryRun) {
const { rows } = await sourcePool.query(
`SELECT ${THUMB_EVENT_COLUMNS.join(", ")} FROM lecture_thumbnail_events ORDER BY occurred_at ASC`
);
console.log(`[lecture_thumbnail_events] 원본 행 수: ${rows.length}`);
if (dryRun) {
console.log("[lecture_thumbnail_events] dry-run: INSERT 생략");
return rows.length;
}
const sql = buildThumbEventUpsert();
const client = await targetPool.connect();
try {
await client.query("BEGIN");
let n = 0;
for (const row of rows) {
await client.query(sql, rowValues(row, THUMB_EVENT_COLUMNS));
n++;
}
await client.query("COMMIT");
console.log(`[lecture_thumbnail_events] 대상 DB 반영 완료: ${n}`);
return n;
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
function printFileHint() {
console.log(`
[파일 동기화 안내] DB만 복사하면 메타데이터만 옮겨집니다. 강의 PPT/슬라이드/썸네일이 비어 있으면
원본 서버(또는 .env.ncue가 가리키는 환경의 디스크)에서 대상(ai.xavis.co.kr) 배포 디렉터리로 복사하세요.
예시 (원본 호스트에서 대상으로 rsync — 경로는 실제 배포에 맞게 조정):
rsync -avz ./resources/lecture/ user@ai-host:/path/to/webplatform/resources/lecture/
rsync -avz ./uploads/thumbnails/ user@ai-host:/path/to/webplatform/uploads/thumbnails/
rsync -avz ./uploads/slides/ user@ai-host:/path/to/webplatform/uploads/slides/
`);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const sourcePath = process.env.SOURCE_ENV || ".env.ncue";
const targetPath = process.env.TARGET_ENV || ".env";
const sourceEnv = loadEnvFile(sourcePath);
const targetEnv = loadEnvFile(targetPath);
const srcCfg = poolConfigFromEnv(sourceEnv);
const tgtCfg = poolConfigFromEnv(targetEnv);
console.log(`원본: ${sourcePath}${srcCfg.host}/${srcCfg.database}`);
console.log(`대상: ${targetPath}${tgtCfg.host}/${tgtCfg.database}`);
if (args.dryRun) {
console.log("모드: DRY-RUN (쓰기 없음)\n");
}
const sourcePool = new Pool(srcCfg);
const targetPool = new Pool(tgtCfg);
try {
await sourcePool.query("SELECT 1");
await targetPool.query("SELECT 1");
} catch (e) {
console.error("DB 연결 실패:", e.message);
process.exit(1);
}
try {
if (args.truncateTarget) {
await truncateTargetTables(targetPool, args.dryRun);
}
await migrateLectures(sourcePool, targetPool, args.dryRun);
if (args.withThumbnailEvents) {
await migrateThumbnailEvents(sourcePool, targetPool, args.dryRun);
}
printFileHint();
console.log("완료.");
} catch (e) {
console.error("마이그레이션 실패:", e.message || e);
process.exit(1);
} finally {
await sourcePool.end();
await targetPool.end();
}
}
main();

View File

@@ -0,0 +1,69 @@
#!/usr/bin/env node
/**
* DB에 저장된 회의록 generated_minutes에 prepareMeetingMinutesForApi(말미 제거·제목 승격)를
* 일괄 적용합니다. 서버 재시작 없이 기존 데이터를 정리할 때 사용합니다.
*
* 사용: node scripts/normalize-stored-meeting-minutes.js
* 요구: .env에 DB_HOST, DB_DATABASE, DB_USERNAME, DB_PASSWORD 등 (apply-schema와 동일)
*/
require("dotenv").config({ quiet: true });
const { Pool } = require("pg");
const path = require("path");
const { prepareMeetingMinutesForApi } = require(path.join(__dirname, "..", "lib", "meeting-minutes"));
const { extractMeetingSummary } = require(path.join(__dirname, "..", "lib", "meeting-minutes-summary"));
const getConfig = () => ({
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 validateConfig = (config) => {
const missing = ["host", "database", "user", "password"].filter((key) => !config[key]);
if (missing.length) {
throw new Error(`Missing PostgreSQL envs: ${missing.join(", ")}`);
}
};
async function main() {
const config = getConfig();
validateConfig(config);
const pool = new Pool(config);
let updated = 0;
let unchanged = 0;
try {
const r = await pool.query(
`SELECT id, user_email, generated_minutes FROM meeting_ai_meetings ORDER BY created_at ASC`
);
for (const row of r.rows) {
const raw = row.generated_minutes != null ? String(row.generated_minutes) : "";
const next = prepareMeetingMinutesForApi(raw);
if (next === raw) {
unchanged++;
continue;
}
const summaryText = extractMeetingSummary(next, 1200);
await pool.query(
`UPDATE meeting_ai_meetings
SET generated_minutes = $1, summary_text = $2, updated_at = NOW()
WHERE id = $3::uuid AND user_email = $4`,
[next, summaryText || null, row.id, row.user_email]
);
updated++;
console.log("updated:", row.id, row.user_email);
}
console.log(`Done. updated=${updated}, unchanged=${unchanged}, total=${r.rows.length}`);
} finally {
await pool.end();
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

42
scripts/test-ax-apply.js Normal file
View File

@@ -0,0 +1,42 @@
#!/usr/bin/env node
/**
* AX 과제 신청 API 테스트 (필수값만)
* 사용: node scripts/test-ax-apply.js
* ENABLE_POSTGRES=0: JSON 폴백 사용 (DB 불필요)
* ENABLE_POSTGRES=1: PostgreSQL 사용 (기본값)
*/
require("dotenv").config({ path: require("path").join(__dirname, "..", ".env") });
const BASE = process.env.TEST_BASE_URL || "http://localhost:8030";
const minimalPayload = {
department: "테스트부서",
name: "홍길동",
workProcessDescription: "테스트",
painPoint: "테스트",
currentTimeSpent: "30분",
errorRateBefore: "5%",
reasonToSolve: "테스트",
aiExpectation: "테스트",
outputType: "테스트",
participationPledge: true,
};
async function main() {
console.log("POST", BASE + "/api/ax-apply");
console.log("Payload (필수값만):", JSON.stringify(minimalPayload, null, 2));
const res = await fetch(BASE + "/api/ax-apply", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(minimalPayload),
});
const body = await res.json().catch(() => ({}));
console.log("Status:", res.status);
console.log("Response:", JSON.stringify(body, null, 2));
process.exit(res.ok && body.ok ? 0 : 1);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

64
scripts/test-ax-insert.js Normal file
View File

@@ -0,0 +1,64 @@
#!/usr/bin/env node
/**
* ax_assignments INSERT 직접 테스트 (배열 SQL 리터럴 방식)
*/
require("dotenv").config({ path: require("path").join(__dirname, "..", ".env") });
const { Pool } = require("pg");
const { v4: uuidv4 } = require("uuid");
const pool = new Pool({
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,
});
async function main() {
const id = uuidv4();
const now = new Date();
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 = "'{}'";
const qualitativeLit = "'{}'";
const techStackLit = "'{}'";
const risksLit = "'{}'";
const sql = `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, 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
)`;
const params = [
... [id, "테스트부서", "홍길동", "", "", "", "", "테스트", "테스트", "30분", "5%", "", "테스트", "테스트", "테스트", "", "", "", "", "", "" ],
... [ "", "", "", "", "", "", "", "", "", "" ],
... [ "", false, "신청", now, now ],
];
console.log("SQL preview:", sql.substring(200, 400));
const res = await pool.query(sql, params);
console.log("OK, inserted:", id);
await pool.end();
}
main().catch((e) => {
console.error("Error:", e.message);
process.exit(1);
});