import { createError } from 'h3' import { getPostgresClient } from './postgres-client' /** * @typedef {Object} PostComment * @property {string} id - 댓글 ID * @property {string} postId - 게시물 ID * @property {string | null} parentId - 부모 댓글 ID * @property {string} body - 댓글 내용 * @property {string} status - 댓글 상태 * @property {string} createdAt - 생성 시각 * @property {string} updatedAt - 수정 시각 * @property {number} likeCount - 좋아요 수 * @property {boolean} likedByMe - 현재 회원 좋아요 여부 * @property {{ id: string, username: string, avatarUrl: string }} user - 작성자 정보 */ /** * DB 클라이언트 조회 (선택) * @returns {ReturnType | null} postgres sql 클라이언트 */ const getSql = () => getPostgresClient() /** * Postgres undefined table 에러 여부 확인 * @param {unknown} error - 에러 객체 * @returns {boolean} undefined table 여부 */ const isUndefinedTableError = (error) => String(error?.code || '') === '42P01' /** * 게시물 ID 조회 * @param {ReturnType} sql - postgres 클라이언트 * @param {string} slug - 게시물 슬러그 * @returns {Promise} 게시물 ID */ const findPublishedPostIdBySlug = async (sql, slug) => { const rows = await sql` SELECT id FROM posts WHERE slug = ${slug} AND status = 'published' AND ( published_at IS NULL OR published_at <= now() ) LIMIT 1 ` return rows?.[0]?.id || null } /** * 게시물 댓글 조회 * @param {string} slug - 게시물 슬러그 * @returns {Promise>} 댓글 목록 */ export const listPostCommentsBySlug = async (slug, viewerUserId = null) => { const sql = getSql() if (!sql) { return [] } const postId = await findPublishedPostIdBySlug(sql, slug) if (!postId) { throw createError({ statusCode: 404, statusMessage: '게시물을 찾을 수 없습니다' }) } let rows = [] try { rows = await sql` SELECT comments.id, comments.post_id AS "postId", comments.parent_id AS "parentId", comments.body, comments.status, comments.created_at AS "createdAt", comments.updated_at AS "updatedAt", users.id AS "userId", users.username AS "username", users.avatar_url AS "avatarUrl", COALESCE(comment_like_counts.like_count, 0) AS "likeCount", CASE WHEN viewer_comment_likes.user_id IS NULL THEN false ELSE true END AS "likedByMe" FROM comments INNER JOIN users ON users.id = comments.user_id LEFT JOIN ( SELECT comment_id, COUNT(*)::INT AS like_count FROM comment_likes GROUP BY comment_id ) AS comment_like_counts ON comment_like_counts.comment_id = comments.id LEFT JOIN comment_likes AS viewer_comment_likes ON viewer_comment_likes.comment_id = comments.id AND viewer_comment_likes.user_id = ${viewerUserId} WHERE comments.post_id = ${postId} AND comments.status = 'published' ORDER BY comments.created_at ASC ` } catch (error) { if (!isUndefinedTableError(error)) { throw error } rows = await sql` SELECT comments.id, comments.post_id AS "postId", comments.parent_id AS "parentId", comments.body, comments.status, comments.created_at AS "createdAt", comments.updated_at AS "updatedAt", users.id AS "userId", users.username AS "username", users.avatar_url AS "avatarUrl" FROM comments INNER JOIN users ON users.id = comments.user_id WHERE comments.post_id = ${postId} AND comments.status = 'published' ORDER BY comments.created_at ASC ` } return rows.map((row) => ({ id: row.id, postId: row.postId, parentId: row.parentId || null, body: row.body, status: row.status, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), likeCount: Number(row.likeCount || 0), likedByMe: Boolean(row.likedByMe), user: { id: row.userId, username: row.username, avatarUrl: row.avatarUrl || '' } })) } /** * 댓글 생성 * @param {{ slug: string, userId: string, body: string, parentId?: string | null }} input - 댓글 입력값 * @returns {Promise} 생성된 댓글 */ export const createComment = async (input) => { const sql = getSql() if (!sql) { throw createError({ statusCode: 500, message: '데이터베이스 설정이 필요합니다.' }) } const postId = await findPublishedPostIdBySlug(sql, input.slug) if (!postId) { throw createError({ statusCode: 404, statusMessage: '게시물을 찾을 수 없습니다' }) } let parentId = input.parentId || null if (parentId) { const parentRows = await sql` SELECT id, post_id AS "postId", parent_id AS "parentId", status FROM comments WHERE id = ${parentId} LIMIT 1 ` const parent = parentRows?.[0] if (!parent || parent.postId !== postId || parent.status !== 'published') { throw createError({ statusCode: 400, message: '유효하지 않은 부모 댓글입니다.' }) } if (parent.parentId) { throw createError({ statusCode: 400, message: '대댓글에는 추가 답글을 달 수 없습니다.' }) } } const rows = await sql` INSERT INTO comments (post_id, user_id, parent_id, body, status) VALUES (${postId}, ${input.userId}, ${parentId}, ${input.body}, 'published') RETURNING id, post_id AS "postId", parent_id AS "parentId", body, status, created_at AS "createdAt", updated_at AS "updatedAt" ` const created = rows?.[0] if (!created) { throw createError({ statusCode: 500, message: '댓글 생성에 실패했습니다.' }) } const userRows = await sql` SELECT id, username, avatar_url FROM users WHERE id = ${input.userId} LIMIT 1 ` const user = userRows?.[0] if (!user) { throw createError({ statusCode: 401, message: '회원 정보를 찾을 수 없습니다.' }) } return { id: created.id, postId: created.postId, parentId: created.parentId || null, body: created.body, status: created.status, createdAt: created.createdAt.toISOString(), updatedAt: created.updatedAt.toISOString(), user: { id: user.id, username: user.username, avatarUrl: user.avatar_url || '' } } } /** * 댓글 좋아요를 토글한다. * @param {{ slug: string, commentId: string, userId: string }} input - 좋아요 입력값 * @returns {Promise<{ liked: boolean, likeCount: number }>} 좋아요 결과 */ export const toggleCommentLike = async (input) => { const sql = getSql() if (!sql) { throw createError({ statusCode: 500, message: '데이터베이스 설정이 필요합니다.' }) } const postId = await findPublishedPostIdBySlug(sql, input.slug) if (!postId) { throw createError({ statusCode: 404, statusMessage: '게시물을 찾을 수 없습니다' }) } const commentRows = await sql` SELECT id FROM comments WHERE id = ${input.commentId} AND post_id = ${postId} AND status = 'published' LIMIT 1 ` const comment = commentRows?.[0] if (!comment) { throw createError({ statusCode: 404, message: '댓글을 찾을 수 없습니다.' }) } const likedRows = await sql` SELECT 1 FROM comment_likes WHERE comment_id = ${input.commentId} AND user_id = ${input.userId} LIMIT 1 ` const alreadyLiked = Boolean(likedRows?.[0]) if (alreadyLiked) { await sql` DELETE FROM comment_likes WHERE comment_id = ${input.commentId} AND user_id = ${input.userId} ` } else { await sql` INSERT INTO comment_likes (comment_id, user_id) VALUES (${input.commentId}, ${input.userId}) ON CONFLICT (comment_id, user_id) DO NOTHING ` } const countRows = await sql` SELECT COUNT(*)::INT AS like_count FROM comment_likes WHERE comment_id = ${input.commentId} ` return { liked: !alreadyLiked, likeCount: Number(countRows?.[0]?.like_count || 0) } }