댓글 시스템 복구
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
49
backend/src/routes/comments.js
Normal file
49
backend/src/routes/comments.js
Normal 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
|
||||
@@ -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' })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user