feat: xavis ai_platform 기능 이전 및 ncue 환경 전환
xavis 소스·DB 스키마·활용사례/F-Scan/프롬프트 라이브러리 등 기능 반영. @xavis.co.kr → @ncue.net, 관리자 토큰 ncue-admin, 런타임 data/ Git 추적 제외. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
334
lib/prompt-library.js
Normal file
334
lib/prompt-library.js
Normal file
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* 프롬프트 라이브러리(공식 JSON + DB 커뮤니티/좋아요)
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
function maskEmailForDisplay(email) {
|
||||
const s = String(email || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const at = s.indexOf("@");
|
||||
if (at < 1) return "팀원";
|
||||
const local = s.slice(0, at);
|
||||
const dom = s.slice(at);
|
||||
if (local.length <= 2) {
|
||||
return `${local[0] || "*"}*${dom}`;
|
||||
}
|
||||
return `${local[0]}**${local.slice(-1)}${dom}`;
|
||||
}
|
||||
|
||||
/** @ 사인 앞 로컬부(예: spark_ai@ncue.net → spark_ai) — 라이브러리 카드·메타 표시용 */
|
||||
function emailLocalPartForDisplay(email) {
|
||||
const s = String(email || "").trim();
|
||||
if (!s) return "팀원";
|
||||
const at = s.indexOf("@");
|
||||
if (at < 1) {
|
||||
const t = s.slice(0, 80);
|
||||
return t || "팀원";
|
||||
}
|
||||
const local = s.slice(0, at);
|
||||
return (local && local.trim()) || "팀원";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool} pool
|
||||
* @param {Array<{id:string,title?:string,description?:string,tag?:string,body?:string}>} officialPrompts
|
||||
* @param {string | null} userEmail
|
||||
*/
|
||||
async function getLibraryData(pool, officialPrompts, userEmail) {
|
||||
const official = Array.isArray(officialPrompts) ? officialPrompts : [];
|
||||
const officialIds = official.map((p) => p.id).filter((id) => id && String(id).length < 200);
|
||||
|
||||
if (!pool) {
|
||||
return {
|
||||
hasDb: false,
|
||||
community: [],
|
||||
likeCountOfficial: {},
|
||||
likeCountCommunity: {},
|
||||
myOfficialLikes: [],
|
||||
myCommunityLikes: [],
|
||||
mySubmissions: [],
|
||||
};
|
||||
}
|
||||
|
||||
const [commRes, offCntRes, myLikesRes] = await Promise.all([
|
||||
pool.query(
|
||||
`SELECT id, author_email, title, description, body, tag, created_at, prompt_attachments, result_sample_attachments
|
||||
FROM prompt_community_entries
|
||||
WHERE is_published = true AND is_deleted = false
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 300`
|
||||
),
|
||||
officialIds.length
|
||||
? pool.query(
|
||||
`SELECT target_id, COUNT(*)::int AS c
|
||||
FROM prompt_likes
|
||||
WHERE target_kind = 'official' AND target_id = ANY($1::text[])
|
||||
GROUP BY target_id`,
|
||||
[officialIds]
|
||||
)
|
||||
: Promise.resolve({ rows: [] }),
|
||||
userEmail
|
||||
? pool.query(
|
||||
`SELECT target_kind, target_id FROM prompt_likes WHERE user_email = $1`,
|
||||
[userEmail]
|
||||
)
|
||||
: Promise.resolve({ rows: [] }),
|
||||
]);
|
||||
|
||||
const commIds = commRes.rows.map((r) => r.id);
|
||||
let commCntRes = { rows: [] };
|
||||
if (commIds.length) {
|
||||
commCntRes = await pool.query(
|
||||
`SELECT target_id, COUNT(*)::int AS c
|
||||
FROM prompt_likes
|
||||
WHERE target_kind = 'community' AND target_id = ANY($1::text[])
|
||||
GROUP BY target_id`,
|
||||
[commIds.map((id) => String(id))]
|
||||
);
|
||||
}
|
||||
|
||||
const likeCountOfficial = {};
|
||||
for (const r of offCntRes.rows) {
|
||||
likeCountOfficial[r.target_id] = r.c;
|
||||
}
|
||||
const likeCountCommunity = {};
|
||||
for (const r of commCntRes.rows) {
|
||||
likeCountCommunity[r.target_id] = r.c;
|
||||
}
|
||||
|
||||
const myOfficialLikes = [];
|
||||
const myCommunityLikes = [];
|
||||
for (const r of myLikesRes.rows) {
|
||||
if (r.target_kind === "official") myOfficialLikes.push(r.target_id);
|
||||
else if (r.target_kind === "community") myCommunityLikes.push(r.target_id);
|
||||
}
|
||||
|
||||
let mySubmissions = [];
|
||||
if (userEmail) {
|
||||
const mine = await pool.query(
|
||||
`SELECT id, title, created_at
|
||||
FROM prompt_community_entries
|
||||
WHERE author_email = $1 AND is_deleted = false
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100`,
|
||||
[userEmail]
|
||||
);
|
||||
mySubmissions = (mine.rows || []).map((row) => ({
|
||||
id: String(row.id),
|
||||
title: (row.title || "").trim() || "제목 없음",
|
||||
createdAt: row.created_at ? new Date(row.created_at).toISOString() : "",
|
||||
}));
|
||||
}
|
||||
|
||||
const community = (commRes.rows || []).map((row) => {
|
||||
const parseFileJson = (raw) => {
|
||||
try {
|
||||
const j = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
return Array.isArray(j) ? j : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
return {
|
||||
id: String(row.id),
|
||||
title: (row.title || "").trim() || "제목 없음",
|
||||
description: String(row.description || "").trim(),
|
||||
body: String(row.body || ""),
|
||||
tag: (row.tag || "기타").trim() || "기타",
|
||||
authorLabel: emailLocalPartForDisplay(row.author_email),
|
||||
likeCount: likeCountCommunity[String(row.id)] || 0,
|
||||
createdAt: row.created_at ? new Date(row.created_at).toISOString() : "",
|
||||
promptFiles: parseFileJson(row.prompt_attachments).filter((f) => f && f.relativePath),
|
||||
resultFiles: parseFileJson(row.result_sample_attachments).filter((f) => f && f.relativePath),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
hasDb: true,
|
||||
community,
|
||||
likeCountOfficial,
|
||||
likeCountCommunity,
|
||||
myOfficialLikes,
|
||||
myCommunityLikes,
|
||||
mySubmissions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool} pool
|
||||
* @param {string} userEmail
|
||||
* @param {string} kind
|
||||
* @param {string} targetId
|
||||
* @param {Set<string>} officialIdSet
|
||||
*/
|
||||
async function toggleLike(pool, userEmail, kind, targetId, officialIdSet) {
|
||||
if (!["official", "community"].includes(kind)) {
|
||||
const err = new Error("target_kind");
|
||||
err.code = "VALIDATION";
|
||||
throw err;
|
||||
}
|
||||
const id = String(targetId || "").trim();
|
||||
if (!id) {
|
||||
const err = new Error("target_id");
|
||||
err.code = "VALIDATION";
|
||||
throw err;
|
||||
}
|
||||
if (kind === "official" && !officialIdSet.has(id)) {
|
||||
const err = new Error("알 수 없는 공식 프롬프트입니다.");
|
||||
err.code = "VALIDATION";
|
||||
throw err;
|
||||
}
|
||||
if (kind === "community" && !UUID_RE.test(id)) {
|
||||
const err = new Error("잘못된 ID입니다.");
|
||||
err.code = "VALIDATION";
|
||||
throw err;
|
||||
}
|
||||
if (kind === "community") {
|
||||
const r = await pool.query(
|
||||
`SELECT 1 FROM prompt_community_entries WHERE id = $1::uuid AND is_deleted = false AND is_published = true LIMIT 1`,
|
||||
[id]
|
||||
);
|
||||
if (!r.rowCount) {
|
||||
const err = new Error("삭제되었거나 없는 프롬프트입니다.");
|
||||
err.code = "NOT_FOUND";
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const del = await pool.query(
|
||||
`DELETE FROM prompt_likes
|
||||
WHERE user_email = $1 AND target_kind = $2 AND target_id = $3
|
||||
RETURNING id`,
|
||||
[userEmail, kind, id]
|
||||
);
|
||||
if (del.rowCount) {
|
||||
const cnt = await pool.query(
|
||||
`SELECT COUNT(*)::int AS c FROM prompt_likes WHERE target_kind = $1 AND target_id = $2`,
|
||||
[kind, id]
|
||||
);
|
||||
return { liked: false, likeCount: cnt.rows[0].c || 0 };
|
||||
}
|
||||
await pool.query(
|
||||
`INSERT INTO prompt_likes (user_email, target_kind, target_id) VALUES ($1, $2, $3)`,
|
||||
[userEmail, kind, id]
|
||||
);
|
||||
const cnt = await pool.query(
|
||||
`SELECT COUNT(*)::int AS c FROM prompt_likes WHERE target_kind = $1 AND target_id = $2`,
|
||||
[kind, id]
|
||||
);
|
||||
return { liked: true, likeCount: cnt.rows[0].c || 0 };
|
||||
}
|
||||
|
||||
const MAX_TITLE = 500;
|
||||
const MAX_DESC = 2000;
|
||||
const MAX_BODY = 50000;
|
||||
const MAX_TAG = 100;
|
||||
const MAX_ATTACH_PER_GROUP = 5;
|
||||
const MAX_ATTACH_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool} pool
|
||||
* @param {object} p
|
||||
* @param {string} p.authorEmail
|
||||
* @param {string} p.title
|
||||
* @param {string} p.description
|
||||
* @param {string} p.body
|
||||
* @param {string} p.tag
|
||||
* @param {Array<{ originalName: string, relativePath: string, size: number }>} [p.promptAttachments]
|
||||
* @param {Array<{ originalName: string, relativePath: string, size: number }>} [p.resultSampleAttachments]
|
||||
*/
|
||||
function normalizeFileList(arr, maxN) {
|
||||
if (!Array.isArray(arr) || !arr.length) return [];
|
||||
return arr
|
||||
.filter((a) => a && typeof a.relativePath === "string" && a.relativePath.startsWith("/uploads/"))
|
||||
.slice(0, maxN)
|
||||
.map((a) => ({
|
||||
originalName: String(a.originalName || "file").slice(0, 400),
|
||||
relativePath: String(a.relativePath).slice(0, 2000),
|
||||
size: Math.min(Number(a.size) || 0, MAX_ATTACH_BYTES * 2),
|
||||
}));
|
||||
}
|
||||
|
||||
async function createCommunityEntry(pool, p) {
|
||||
const title = String(p.title || "")
|
||||
.trim()
|
||||
.slice(0, MAX_TITLE);
|
||||
const description = String(p.description || "")
|
||||
.trim()
|
||||
.slice(0, MAX_DESC);
|
||||
const body = String(p.body || "").trim();
|
||||
const tag = String(p.tag || "기타")
|
||||
.trim()
|
||||
.slice(0, MAX_TAG) || "기타";
|
||||
if (!title) {
|
||||
const e = new Error("제목을 입력해 주세요.");
|
||||
e.code = "VALIDATION";
|
||||
throw e;
|
||||
}
|
||||
if (body.length < 10) {
|
||||
const e = new Error("본문은 10자 이상 입력해 주세요.");
|
||||
e.code = "VALIDATION";
|
||||
throw e;
|
||||
}
|
||||
if (body.length > MAX_BODY) {
|
||||
const e = new Error(`본문은 ${MAX_BODY}자 이하여야 합니다.`);
|
||||
e.code = "VALIDATION";
|
||||
throw e;
|
||||
}
|
||||
const promptA = normalizeFileList(p.promptAttachments, MAX_ATTACH_PER_GROUP);
|
||||
const resultA = normalizeFileList(p.resultSampleAttachments, MAX_ATTACH_PER_GROUP);
|
||||
const r = await pool.query(
|
||||
`INSERT INTO prompt_community_entries (author_email, title, description, body, tag, prompt_attachments, result_sample_attachments)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb)
|
||||
RETURNING id, created_at`,
|
||||
[p.authorEmail, title, description, body, tag, JSON.stringify(promptA), JSON.stringify(resultA)]
|
||||
);
|
||||
const row = r.rows[0];
|
||||
return {
|
||||
id: String(row.id),
|
||||
createdAt: row.created_at ? new Date(row.created_at).toISOString() : "",
|
||||
promptFiles: promptA,
|
||||
resultFiles: resultA,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("pg").Pool} pool
|
||||
* @param {string} id
|
||||
* @param {string} authorEmail
|
||||
*/
|
||||
async function softDeleteCommunityEntry(pool, id, authorEmail) {
|
||||
if (!UUID_RE.test(String(id || "").trim())) {
|
||||
const e = new Error("잘못된 ID입니다.");
|
||||
e.code = "VALIDATION";
|
||||
throw e;
|
||||
}
|
||||
const r = await pool.query(
|
||||
`UPDATE prompt_community_entries
|
||||
SET is_deleted = true, is_published = false, updated_at = NOW()
|
||||
WHERE id = $1::uuid AND author_email = $2 AND is_deleted = false
|
||||
RETURNING id`,
|
||||
[id, authorEmail]
|
||||
);
|
||||
if (!r.rowCount) {
|
||||
const e = new Error("삭제할 수 없습니다. 본인이 올린 글만 삭제할 수 있습니다.");
|
||||
e.code = "NOT_FOUND";
|
||||
throw e;
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getLibraryData,
|
||||
toggleLike,
|
||||
createCommunityEntry,
|
||||
softDeleteCommunityEntry,
|
||||
maskEmailForDisplay,
|
||||
emailLocalPartForDisplay,
|
||||
UUID_RE,
|
||||
MAX_ATTACH_PER_GROUP,
|
||||
MAX_ATTACH_BYTES,
|
||||
};
|
||||
Reference in New Issue
Block a user