// Postgres 풀 (OpenEverest appdb, search_path=portal). DATABASE_URL 로 연결. import pg from "pg"; import { config } from "./config.js"; export const pool = new pg.Pool({ connectionString: config.databaseUrl }); export const q = (text, params) => pool.query(text, params); // 로그인 시 사용자 upsert export async function upsertUser({ sabun, name, email }) { await q( `INSERT INTO users (sabun, name, email, avatar_seed, last_login) VALUES ($1,$2,$3,$1, now()) ON CONFLICT (sabun) DO UPDATE SET name=EXCLUDED.name, email=EXCLUDED.email, last_login=now()`, [sabun, name, email || null] ); } // 카드/피드에 필요한 카운트 + 현재 사용자(viewer)의 ❤️/⭐ 여부를 함께 계산하는 공통 SELECT 절. // $1 = viewer 사번. const PROJECT_SELECT = ` SELECT p.*, u.name AS owner_name, (SELECT count(*) FROM hearts h WHERE h.project_id=p.id)::int AS heart_count, (SELECT count(*) FROM stars s WHERE s.project_id=p.id)::int AS star_count, (SELECT count(*) FROM comments c WHERE c.project_id=p.id)::int AS comment_count, EXISTS(SELECT 1 FROM hearts h WHERE h.project_id=p.id AND h.sabun=$1) AS hearted, EXISTS(SELECT 1 FROM stars s WHERE s.project_id=p.id AND s.sabun=$1) AS starred FROM projects p JOIN users u ON u.sabun=p.owner_sabun`; // 프로젝트 목록 — 공개 프로젝트 + viewer 본인의 비공개 프로젝트. // (남의 비공개는 절대 반환하지 않음) 피드/내프로젝트 분리는 호출부(server.js)에서. export async function listProjects(viewer) { const { rows } = await q( `${PROJECT_SELECT} WHERE p.is_public OR p.owner_sabun=$1 ORDER BY p.created_at DESC`, [viewer] ); return rows; } // 단일 프로젝트 (가시성 검사는 호출부에서: is_public 이거나 본인일 때만 노출) export async function getProject(id, viewer) { const { rows } = await q(`${PROJECT_SELECT} WHERE p.id=$2`, [viewer, id]); return rows[0]; } // 댓글 목록 (parent_id 포함, 작성 순서). 스레드 구성은 뷰에서. export async function listComments(projectId) { const { rows } = await q( `SELECT c.*, u.name AS author_name FROM comments c JOIN users u ON u.sabun=c.author_sabun WHERE c.project_id=$1 ORDER BY c.created_at ASC`, [projectId] ); return rows; } // 여러 프로젝트의 댓글을 한 번에 (대시보드 모달 저장소용). parent_id 포함. export async function listCommentsByProjects(ids) { if (!ids || !ids.length) return []; const { rows } = await q( `SELECT c.*, u.name AS author_name FROM comments c JOIN users u ON u.sabun=c.author_sabun WHERE c.project_id = ANY($1::bigint[]) ORDER BY c.created_at ASC`, [ids] ); return rows; } export async function addProject({ owner, title, description, repoUrl, appUrl, tags, isPublic, lang }) { const { rows } = await q( `INSERT INTO projects (owner_sabun, title, description, repo_url, app_url, tags, is_public, lang) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`, [owner, title, description || "", repoUrl || "", appUrl || "", tags || "", isPublic !== false, lang || ""] ); return rows[0].id; } // 본인 프로젝트 삭제. 삭제 행 수 반환(0이면 권한 없음/없음). export async function deleteProject({ id, sabun }) { const { rowCount } = await q( `DELETE FROM projects WHERE id=$1 AND owner_sabun=$2`, [id, sabun] ); return rowCount; } // 공개/비공개 전환 (본인만). 적용 행 수 반환. export async function setVisibility({ id, sabun, isPublic }) { const { rowCount } = await q( `UPDATE projects SET is_public=$3, updated_at=now() WHERE id=$1 AND owner_sabun=$2`, [id, sabun, !!isPublic] ); return rowCount; } // 댓글/대댓글 작성. parentId 가 있으면 대댓글. // 1뎁스 강제: 부모 댓글이 같은 프로젝트의 "최상위(parent_id IS NULL)" 일 때만 허용. // 반환: 새 댓글 행(작성자 이름 포함). 거부 시 null. export async function addComment({ projectId, author, body, parentId }) { let parent = null; if (parentId) { const { rows } = await q( `SELECT id FROM comments WHERE id=$1 AND project_id=$2 AND parent_id IS NULL`, [parentId, projectId] ); if (!rows[0]) return null; // 대댓글에 또 답글(2뎁스) 또는 잘못된 부모 → 거부 parent = parentId; } const { rows } = await q( `INSERT INTO comments (project_id, author_sabun, parent_id, body) VALUES ($1,$2,$3,$4) RETURNING *`, [projectId, author, parent, body] ); const c = rows[0]; const { rows: u } = await q(`SELECT name FROM users WHERE sabun=$1`, [author]); c.author_name = u[0] ? u[0].name : author; return c; } // 본인 댓글 삭제 (자식 대댓글은 FK ON DELETE CASCADE 로 함께 삭제). 삭제 행 수 반환. export async function deleteComment({ id, sabun }) { const { rowCount } = await q( `DELETE FROM comments WHERE id=$1 AND author_sabun=$2`, [id, sabun] ); return rowCount; } // ❤️ 좋아요 토글 → { on, count } export async function toggleHeart({ projectId, sabun }) { const { rowCount } = await q(`DELETE FROM hearts WHERE project_id=$1 AND sabun=$2`, [projectId, sabun]); const on = rowCount === 0; if (on) await q(`INSERT INTO hearts (project_id, sabun) VALUES ($1,$2)`, [projectId, sabun]); const { rows } = await q(`SELECT count(*)::int AS c FROM hearts WHERE project_id=$1`, [projectId]); return { on, count: rows[0].c }; } // ⭐ 즐겨찾기(Star) 토글 → { on, count } export async function toggleStar({ projectId, sabun }) { const { rowCount } = await q(`DELETE FROM stars WHERE project_id=$1 AND sabun=$2`, [projectId, sabun]); const on = rowCount === 0; if (on) await q(`INSERT INTO stars (project_id, sabun) VALUES ($1,$2)`, [projectId, sabun]); const { rows } = await q(`SELECT count(*)::int AS c FROM stars WHERE project_id=$1`, [projectId]); return { on, count: rows[0].c }; }