xavis 소스·DB 스키마·활용사례/F-Scan/프롬프트 라이브러리 등 기능 반영. @xavis.co.kr → @ncue.net, 관리자 토큰 ncue-admin, 런타임 data/ Git 추적 제외. Co-authored-by: Cursor <cursoragent@cursor.com>
335 lines
10 KiB
JavaScript
335 lines
10 KiB
JavaScript
/**
|
|
* 프롬프트 라이브러리(공식 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,
|
|
};
|