댓글 시스템 복구

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

@@ -12,6 +12,7 @@ const { ensureData } = require('./src/db')
const authRoutes = require('./src/routes/auth')
const topicsRoutes = require('./src/routes/topics')
const tierListsRoutes = require('./src/routes/tierlists')
const commentsRoutes = require('./src/routes/comments')
const usersRoutes = require('./src/routes/users')
const adminRoutes = require('./src/routes/admin')
const shareRoutes = require('./src/routes/share')
@@ -87,6 +88,7 @@ app.use(async (req, res, next) => {
app.use('/api/auth', authRoutes)
app.use('/api/topics', topicsRoutes)
app.use('/api/tierlists', tierListsRoutes)
app.use('/api/comments', commentsRoutes)
app.use('/api/users', usersRoutes)
app.use('/api/admin', adminRoutes)
app.use('/share', shareRoutes)

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,

View File

@@ -0,0 +1,49 @@
const express = require('express')
const { z } = require('zod')
const {
listCommentNotifications,
countUnreadCommentNotifications,
markCommentNotificationsRead,
} = require('../db')
const { requireAuth } = require('../middleware/auth')
const router = express.Router()
router.get('/inbox', requireAuth, async (req, res) => {
const schema = z.object({
unreadOnly: z
.union([z.literal('1'), z.literal('0'), z.literal('true'), z.literal('false')])
.optional()
.default('0'),
})
const parsed = schema.safeParse(req.query)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const unreadOnly = ['1', 'true'].includes(parsed.data.unreadOnly)
const notifications = await listCommentNotifications(req.session.userId, { unreadOnly })
res.json({ notifications })
})
router.get('/inbox/unread-count', requireAuth, async (req, res) => {
const unreadCount = await countUnreadCommentNotifications(req.session.userId)
res.json({ unreadCount })
})
router.post('/inbox/read', requireAuth, async (req, res) => {
const schema = z.object({
all: z.boolean().optional().default(false),
notificationIds: z.array(z.string().min(1).max(64)).max(100).optional().default([]),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
await markCommentNotificationsRead(req.session.userId, {
all: parsed.data.all,
notificationIds: parsed.data.notificationIds,
})
const unreadCount = await countUnreadCommentNotifications(req.session.userId)
res.json({ ok: true, unreadCount })
})
module.exports = router

View File

@@ -16,6 +16,11 @@ const {
favoriteTierList,
unfavoriteTierList,
duplicateTierListForUser,
listTierListComments,
findTierListCommentById,
createTierListComment,
deleteTierListComment,
createCommentNotificationsForComment,
} = require('../db')
const { requireAuth } = require('../middleware/auth')
const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage')
@@ -122,6 +127,22 @@ const tierListUpsertSchema = z.object({
}
})
const tierListCommentSchema = z.object({
content: z.string().trim().min(1).max(2000),
parentCommentId: z.string().trim().max(64).optional().default(''),
})
async function getTierListAccessContext(req, tierListId) {
const tierList = await findTierListById(tierListId, req.session?.userId || '')
if (!tierList) return { error: 'not_found' }
if (tierList.isPublic) return { tierList, canRead: true, canEdit: req.session?.userId === tierList.authorId }
if (!req.session?.userId) return { error: 'forbidden' }
if (req.session.userId === tierList.authorId) return { tierList, canRead: true, canEdit: true }
const currentUser = await findUserById(req.session.userId)
if (!currentUser?.isAdmin) return { error: 'forbidden' }
return { tierList, canRead: true, canEdit: true, isAdmin: true }
}
router.get('/public', async (req, res) => {
const topicId = typeof req.query.topicId === 'string' ? req.query.topicId : ''
const queryText = typeof req.query.q === 'string' ? req.query.q : ''
@@ -142,14 +163,10 @@ router.get('/favorites/me', requireAuth, async (req, res) => {
})
router.get('/:id', async (req, res) => {
const t = await findTierListById(req.params.id, req.session?.userId || '')
if (!t) return res.status(404).json({ error: 'not_found' })
if (!t.isPublic) {
if (!req.session?.userId) return res.status(403).json({ error: 'forbidden' })
const currentUser = req.session.userId === t.authorId ? { isAdmin: false } : await findUserById(req.session.userId)
if (req.session.userId !== t.authorId && !currentUser?.isAdmin) return res.status(403).json({ error: 'forbidden' })
}
res.json({ tierList: normalizeTierList(t) })
const access = await getTierListAccessContext(req, req.params.id)
if (access.error === 'not_found') return res.status(404).json({ error: 'not_found' })
if (access.error === 'forbidden') return res.status(403).json({ error: 'forbidden' })
res.json({ tierList: normalizeTierList(access.tierList) })
})
router.post('/:id/duplicate', requireAuth, async (req, res) => {
@@ -189,6 +206,62 @@ router.delete('/:id/favorite', requireAuth, async (req, res) => {
res.json({ tierList: normalizeTierList(updated) })
})
router.get('/:id/comments', async (req, res) => {
const access = await getTierListAccessContext(req, req.params.id)
if (access.error === 'not_found') return res.status(404).json({ error: 'not_found' })
if (access.error === 'forbidden') return res.status(403).json({ error: 'forbidden' })
const comments = await listTierListComments(access.tierList.id)
res.json({ comments })
})
router.post('/:id/comments', requireAuth, async (req, res) => {
const parsed = tierListCommentSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const access = await getTierListAccessContext(req, req.params.id)
if (access.error === 'not_found') return res.status(404).json({ error: 'not_found' })
if (access.error === 'forbidden') return res.status(403).json({ error: 'forbidden' })
try {
const commentId = await createTierListComment({
tierListId: access.tierList.id,
authorId: req.session.userId,
parentCommentId: parsed.data.parentCommentId,
content: parsed.data.content,
})
await createCommentNotificationsForComment(commentId)
const comments = await listTierListComments(access.tierList.id)
res.json({ comments, createdCommentId: commentId })
} catch (error) {
if (error?.code === 'COMMENT_PARENT_INVALID') {
return res.status(400).json({ error: 'comment_parent_invalid' })
}
if (error?.code === 'COMMENT_REPLY_DEPTH_INVALID') {
return res.status(400).json({ error: 'comment_reply_depth_invalid' })
}
throw error
}
})
router.delete('/:id/comments/:commentId', requireAuth, async (req, res) => {
const access = await getTierListAccessContext(req, req.params.id)
if (access.error === 'not_found') return res.status(404).json({ error: 'not_found' })
if (access.error === 'forbidden') return res.status(403).json({ error: 'forbidden' })
const comment = await findTierListCommentById(req.params.commentId)
if (!comment || comment.tierListId !== access.tierList.id) return res.status(404).json({ error: 'not_found' })
const currentUser = req.session.userId === comment.authorId ? { isAdmin: false } : await findUserById(req.session.userId)
if (req.session.userId !== comment.authorId && !currentUser?.isAdmin) {
return res.status(403).json({ error: 'forbidden' })
}
await deleteTierListComment(comment.id)
const comments = await listTierListComments(access.tierList.id)
res.json({ comments })
})
router.post('/custom-items', requireAuth, upload.single('image'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'file_required' })