diff --git a/schema.sql b/schema.sql index d9fdf4b..ee5669d 100644 --- a/schema.sql +++ b/schema.sql @@ -19,22 +19,37 @@ CREATE TABLE IF NOT EXISTS projects ( repo_url text DEFAULT '', -- Gitea repo app_url text DEFAULT '', -- 배포된 앱 (*.apps.bokdev.in) tags text DEFAULT '', -- 콤마 구분 + is_public boolean NOT NULL DEFAULT true, -- 공개(피드 노출) / 비공개(나만) + lang text DEFAULT '', -- 대표 언어(태그에서 추론, 카드 언어 점) created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); +-- 기존 배포 DB 대비 멱등 컬럼 추가 +ALTER TABLE projects ADD COLUMN IF NOT EXISTS is_public boolean NOT NULL DEFAULT true; +ALTER TABLE projects ADD COLUMN IF NOT EXISTS lang text DEFAULT ''; CREATE INDEX IF NOT EXISTS idx_projects_created ON projects(created_at DESC); --- 코멘트 +-- 코멘트 (parent_id 가 null 이면 최상위, 있으면 대댓글 — 1뎁스만 허용: 앱에서 검증) CREATE TABLE IF NOT EXISTS comments ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, project_id bigint NOT NULL REFERENCES projects(id) ON DELETE CASCADE, author_sabun text NOT NULL REFERENCES users(sabun), + parent_id bigint REFERENCES comments(id) ON DELETE CASCADE, body text NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); +ALTER TABLE comments ADD COLUMN IF NOT EXISTS parent_id bigint REFERENCES comments(id) ON DELETE CASCADE; CREATE INDEX IF NOT EXISTS idx_comments_project ON comments(project_id, created_at); --- 스타(좋아요) +-- 좋아요(❤️) — 1인 1좋아요 +CREATE TABLE IF NOT EXISTS hearts ( + project_id bigint NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + sabun text NOT NULL REFERENCES users(sabun), + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (project_id, sabun) +); + +-- 즐겨찾기(⭐ Star) — 1인 1즐겨찾기 CREATE TABLE IF NOT EXISTS stars ( project_id bigint NOT NULL REFERENCES projects(id) ON DELETE CASCADE, sabun text NOT NULL REFERENCES users(sabun), diff --git a/src/db.js b/src/db.js index 795d0fc..db0bf4d 100644 --- a/src/db.js +++ b/src/db.js @@ -17,28 +17,36 @@ export async function upsertUser({ sabun, name, email }) { ); } -// 프로젝트 목록 (스타 수 + 코멘트 수 + 소유자 이름 포함) -export async function listProjects() { +// 카드/피드에 필요한 카운트 + 현재 사용자(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( - `SELECT p.*, u.name AS owner_name, - (SELECT count(*) FROM stars s WHERE s.project_id=p.id) AS star_count, - (SELECT count(*) FROM comments c WHERE c.project_id=p.id) AS comment_count - FROM projects p JOIN users u ON u.sabun=p.owner_sabun - ORDER BY p.created_at DESC` + `${PROJECT_SELECT} + WHERE p.is_public OR p.owner_sabun=$1 + ORDER BY p.created_at DESC`, + [viewer] ); return rows; } -export async function getProject(id) { - const { rows } = await q( - `SELECT p.*, u.name AS owner_name, - (SELECT count(*) FROM stars s WHERE s.project_id=p.id) AS star_count - FROM projects p JOIN users u ON u.sabun=p.owner_sabun WHERE p.id=$1`, - [id] - ); +// 단일 프로젝트 (가시성 검사는 호출부에서: 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 @@ -49,30 +57,80 @@ export async function listComments(projectId) { return rows; } -export async function addProject({ owner, title, description, repoUrl, appUrl, tags }) { +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) - VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`, - [owner, title, description || "", repoUrl || "", appUrl || "", tags || ""] + `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; } -export async function addComment({ projectId, author, body }) { - await q(`INSERT INTO comments (project_id, author_sabun, body) VALUES ($1,$2,$3)`, - [projectId, author, body]); +// 본인 프로젝트 삭제. 삭제 행 수 반환(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]); - if (rowCount === 0) { - await q(`INSERT INTO stars (project_id, sabun) VALUES ($1,$2)`, [projectId, sabun]); - return true; - } - return false; -} - -export async function isStarred({ projectId, sabun }) { - const { rowCount } = await q(`SELECT 1 FROM stars WHERE project_id=$1 AND sabun=$2`, [projectId, sabun]); - return rowCount > 0; + 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 }; }