feat(db): 공개여부·언어·대댓글·좋아요(hearts) 스키마와 쿼리 개편
- projects: is_public(공개/비공개)·lang 컬럼 추가(멱등) - comments: parent_id 추가 — 대댓글 1뎁스(앱에서 강제) - hearts(❤️ 좋아요) 테이블 신설, stars(⭐ 즐겨찾기)는 유지 - db.js: 카운트+viewer 반응여부 공통 SELECT, 공개+본인비공개만 노출, toggleHeart/toggleStar({on,count}), setVisibility/deleteProject/deleteComment Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
19
schema.sql
19
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),
|
||||
|
||||
120
src/db.js
120
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user