/** * 프롬프트 라이브러리(공식 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} 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, };