댓글 시스템 복구

This commit is contained in:
2026-04-07 12:44:24 +09:00
parent 2c0b5268fa
commit 6bac13006a
16 changed files with 1403 additions and 15 deletions

View File

@@ -241,6 +241,52 @@ function mapTierListRow(row) {
}
}
function mapTierListCommentRow(row) {
if (!row) return null
return {
id: row.id,
tierListId: row.tierlist_id,
parentCommentId: row.parent_comment_id || '',
authorId: row.author_id,
authorName: getUserDisplayName(row),
authorAccountName: getUserAccountName(row),
authorAvatarSrc: row.avatar_src || '',
content: row.content || '',
createdAt: Number(row.created_at || 0),
updatedAt: Number(row.updated_at || 0),
replies: [],
}
}
function mapCommentNotificationRow(row) {
if (!row) return null
return {
id: row.id,
userId: row.user_id,
tierListId: row.tierlist_id,
topicId: row.topic_id,
topicSlug: row.topic_slug || row.topic_id,
topicName: row.topic_name || '',
tierListTitle: row.tierlist_title || '',
commentId: row.comment_id,
parentCommentId: row.parent_comment_id || '',
notificationType: row.notification_type || 'tierlist_comment',
isRead: !!row.is_read,
readAt: Number(row.read_at || 0),
createdAt: Number(row.created_at || 0),
actorId: row.actor_user_id,
actorName: getUserDisplayName({
nickname: row.actor_nickname,
email: row.actor_email,
}),
actorAccountName: getUserAccountName({
email: row.actor_email,
}),
actorAvatarSrc: row.actor_avatar_src || '',
commentContent: row.comment_content || '',
}
}
function mapTemplateRequestRow(row) {
if (!row) return null
return {
@@ -499,6 +545,43 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await query(`
CREATE TABLE IF NOT EXISTS tierlist_comments (
id VARCHAR(64) PRIMARY KEY,
tierlist_id VARCHAR(64) NOT NULL,
author_id VARCHAR(64) NOT NULL,
parent_comment_id VARCHAR(64) NULL DEFAULT NULL,
content TEXT NOT NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
INDEX idx_tierlist_comments_tierlist_created (tierlist_id, created_at),
INDEX idx_tierlist_comments_parent (parent_comment_id),
CONSTRAINT fk_tierlist_comments_tierlist FOREIGN KEY (tierlist_id) REFERENCES tierlists(id) ON DELETE CASCADE,
CONSTRAINT fk_tierlist_comments_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_tierlist_comments_parent FOREIGN KEY (parent_comment_id) REFERENCES tierlist_comments(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await query(`
CREATE TABLE IF NOT EXISTS comment_notifications (
id VARCHAR(64) PRIMARY KEY,
user_id VARCHAR(64) NOT NULL,
tierlist_id VARCHAR(64) NOT NULL,
comment_id VARCHAR(64) NOT NULL,
actor_user_id VARCHAR(64) NOT NULL,
notification_type VARCHAR(32) NOT NULL,
is_read TINYINT(1) NOT NULL DEFAULT 0,
read_at BIGINT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
INDEX idx_comment_notifications_user_read_created (user_id, is_read, created_at),
INDEX idx_comment_notifications_comment (comment_id),
CONSTRAINT fk_comment_notifications_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_comment_notifications_tierlist FOREIGN KEY (tierlist_id) REFERENCES tierlists(id) ON DELETE CASCADE,
CONSTRAINT fk_comment_notifications_comment FOREIGN KEY (comment_id) REFERENCES tierlist_comments(id) ON DELETE CASCADE,
CONSTRAINT fk_comment_notifications_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await query(`
CREATE TABLE IF NOT EXISTS user_follows (
follower_id VARCHAR(64) NOT NULL,
@@ -2612,6 +2695,187 @@ async function listFollowingTierLists(userId, queryText = '') {
return applyFavoriteMetaToTierLists(tierLists, favoriteStats)
}
async function listTierListComments(tierListId) {
const rows = await query(
`
SELECT
c.id,
c.tierlist_id,
c.author_id,
c.parent_comment_id,
c.content,
c.created_at,
c.updated_at,
u.nickname,
u.email,
u.avatar_src
FROM tierlist_comments c
INNER JOIN users u ON u.id = c.author_id
WHERE c.tierlist_id = ?
ORDER BY c.created_at ASC, c.id ASC
`,
[tierListId]
)
const comments = rows.map(mapTierListCommentRow)
const byId = new Map(comments.map((comment) => [comment.id, comment]))
const roots = []
comments.forEach((comment) => {
if (comment.parentCommentId) {
const parent = byId.get(comment.parentCommentId)
if (parent) {
parent.replies.push(comment)
return
}
}
roots.push(comment)
})
return roots
}
async function findTierListCommentById(commentId) {
const rows = await query(
`
SELECT id, tierlist_id, author_id, parent_comment_id, content, created_at, updated_at
FROM tierlist_comments
WHERE id = ?
LIMIT 1
`,
[commentId]
)
const row = rows[0]
if (!row) return null
return {
id: row.id,
tierListId: row.tierlist_id,
authorId: row.author_id,
parentCommentId: row.parent_comment_id || '',
content: row.content || '',
createdAt: Number(row.created_at || 0),
updatedAt: Number(row.updated_at || 0),
}
}
async function createTierListComment({ tierListId, authorId, parentCommentId = '', content }) {
let parentComment = null
if (parentCommentId) {
parentComment = await findTierListCommentById(parentCommentId)
if (!parentComment || parentComment.tierListId !== tierListId) {
const error = new Error('comment_parent_invalid')
error.code = 'COMMENT_PARENT_INVALID'
throw error
}
if (parentComment.parentCommentId) {
const error = new Error('comment_reply_depth_invalid')
error.code = 'COMMENT_REPLY_DEPTH_INVALID'
throw error
}
}
const commentId = nanoid()
const createdAt = now()
await query(
`
INSERT INTO tierlist_comments (id, tierlist_id, author_id, parent_comment_id, content, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`,
[commentId, tierListId, authorId, parentComment ? parentComment.id : null, String(content || '').trim(), createdAt, createdAt]
)
return commentId
}
async function deleteTierListComment(commentId) {
await query('DELETE FROM tierlist_comments WHERE id = ?', [commentId])
}
async function createCommentNotificationsForComment(commentId) {
const comment = await findTierListCommentById(commentId)
if (!comment) return
const tierList = await findTierListById(comment.tierListId)
if (!tierList) return
const recipientMap = new Map()
if (tierList.authorId && tierList.authorId !== comment.authorId) {
recipientMap.set(tierList.authorId, 'tierlist_comment')
}
if (comment.parentCommentId) {
const parentComment = await findTierListCommentById(comment.parentCommentId)
if (parentComment && parentComment.authorId && parentComment.authorId !== comment.authorId) {
recipientMap.set(parentComment.authorId, 'comment_reply')
}
}
await Promise.all(
Array.from(recipientMap.entries()).map(([userId, notificationType]) =>
query(
`
INSERT INTO comment_notifications (id, user_id, tierlist_id, comment_id, actor_user_id, notification_type, is_read, read_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?)
`,
[nanoid(), userId, comment.tierListId, comment.id, comment.authorId, notificationType, now()]
)
)
)
}
async function listCommentNotifications(userId, { unreadOnly = false } = {}) {
const rows = await query(
`
SELECT
n.id,
n.user_id,
n.tierlist_id,
n.comment_id,
n.notification_type,
n.is_read,
n.read_at,
n.created_at,
n.actor_user_id,
c.parent_comment_id,
c.content AS comment_content,
t.topic_id,
tp.slug AS topic_slug,
tp.name AS topic_name,
t.title AS tierlist_title,
actor.nickname AS actor_nickname,
actor.email AS actor_email,
actor.avatar_src AS actor_avatar_src
FROM comment_notifications n
INNER JOIN tierlist_comments c ON c.id = n.comment_id
INNER JOIN tierlists t ON t.id = n.tierlist_id
INNER JOIN topics tp ON tp.id = t.topic_id
INNER JOIN users actor ON actor.id = n.actor_user_id
WHERE n.user_id = ?
${unreadOnly ? 'AND n.is_read = 0' : ''}
ORDER BY n.is_read ASC, n.created_at DESC
LIMIT 300
`,
[userId]
)
return rows.map(mapCommentNotificationRow)
}
async function countUnreadCommentNotifications(userId) {
const rows = await query('SELECT COUNT(*) AS count FROM comment_notifications WHERE user_id = ? AND is_read = 0', [userId])
return Number(rows[0]?.count || 0)
}
async function markCommentNotificationsRead(userId, { notificationIds = [], all = false } = {}) {
if (all) {
await query('UPDATE comment_notifications SET is_read = 1, read_at = ? WHERE user_id = ? AND is_read = 0', [now(), userId])
return
}
const ids = Array.from(new Set((notificationIds || []).filter(Boolean))).slice(0, 100)
if (!ids.length) return
await query(
`UPDATE comment_notifications SET is_read = 1, read_at = ? WHERE user_id = ? AND id IN (${ids.map(() => '?').join(', ')}) AND is_read = 0`,
[now(), userId, ...ids]
)
}
function uniqueTierListItems(poolItems) {
const map = new Map()
;(poolItems || []).forEach((item) => {
@@ -3275,6 +3539,14 @@ module.exports = {
updateTierListFeaturedStatus,
favoriteTopic,
unfavoriteTopic,
listTierListComments,
findTierListCommentById,
createTierListComment,
deleteTierListComment,
createCommentNotificationsForComment,
listCommentNotifications,
countUnreadCommentNotifications,
markCommentNotificationsRead,
followUser,
unfollowUser,
favoriteTierList,