본인 티어표 복사 복구와 팔로우 피드 추가

This commit is contained in:
2026-04-03 12:34:14 +09:00
parent 9847b4dd8f
commit f9767624d1
16 changed files with 1199 additions and 13 deletions

View File

@@ -426,6 +426,18 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await query(`
CREATE TABLE IF NOT EXISTS user_follows (
follower_id VARCHAR(64) NOT NULL,
following_id VARCHAR(64) NOT NULL,
created_at BIGINT NOT NULL,
PRIMARY KEY (follower_id, following_id),
INDEX idx_user_follows_following (following_id, created_at),
CONSTRAINT fk_user_follows_follower FOREIGN KEY (follower_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_follows_following FOREIGN KEY (following_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await query(`
CREATE TABLE IF NOT EXISTS image_assets (
id VARCHAR(64) PRIMARY KEY,
@@ -2182,6 +2194,198 @@ async function listUserTierLists(userId) {
return applyFavoriteMetaToTierLists(tierLists, favoriteStats)
}
async function findUserProfileById(userId, currentUserId = '') {
const rows = await query(
`
SELECT
u.id,
u.email,
u.nickname,
u.avatar_src,
u.created_at,
(
SELECT COUNT(*)
FROM tierlists t
WHERE t.author_id = u.id AND t.is_public = 1
) AS public_tierlist_count,
(
SELECT COUNT(*)
FROM user_follows uf
WHERE uf.following_id = u.id
) AS follower_count,
(
SELECT COUNT(*)
FROM user_follows uf
WHERE uf.follower_id = u.id
) AS following_count,
${
currentUserId
? `EXISTS(
SELECT 1
FROM user_follows uf
WHERE uf.follower_id = ? AND uf.following_id = u.id
)`
: '0'
} AS is_following
FROM users u
WHERE u.id = ?
LIMIT 1
`,
currentUserId ? [currentUserId, userId] : [userId]
)
const row = rows[0]
if (!row) return null
return {
id: row.id,
nickname: row.nickname || '',
accountName: getUserAccountName(row),
avatarSrc: row.avatar_src || '',
createdAt: Number(row.created_at || 0),
publicTierListCount: Number(row.public_tierlist_count || 0),
followerCount: Number(row.follower_count || 0),
followingCount: Number(row.following_count || 0),
isFollowing: !!row.is_following,
isSelf: !!currentUserId && currentUserId === row.id,
}
}
async function followUser({ followerId, followingId }) {
await query('INSERT IGNORE INTO user_follows (follower_id, following_id, created_at) VALUES (?, ?, ?)', [
followerId,
followingId,
now(),
])
}
async function unfollowUser({ followerId, followingId }) {
await query('DELETE FROM user_follows WHERE follower_id = ? AND following_id = ?', [followerId, followingId])
}
async function listPublicTierListsByAuthor(authorId, currentUserId = '', queryText = '') {
const params = [authorId]
let whereClause = 'WHERE t.author_id = ? AND t.is_public = 1'
if ((queryText || '').trim()) {
const search = `%${queryText.trim()}%`
whereClause += ' AND (t.title LIKE ? OR tp.name LIKE ?)'
params.push(search, search)
}
const rows = await query(
`
SELECT
t.id,
t.topic_id,
tp.name AS topic_name,
t.title,
t.thumbnail_src,
t.is_public,
t.is_featured,
t.featured_at,
t.featured_by,
t.created_at,
t.updated_at,
t.author_id,
u.nickname,
u.email,
u.avatar_src
FROM tierlists t
INNER JOIN users u ON u.id = t.author_id
INNER JOIN topics tp ON tp.id = t.topic_id
${whereClause}
ORDER BY t.is_featured DESC, t.featured_at DESC, t.updated_at DESC
LIMIT 200
`,
params
)
const tierLists = rows.map((row) => ({
id: row.id,
topicId: row.topic_id,
topicName: row.topic_name || '',
title: row.title,
thumbnailSrc: row.thumbnail_src || '',
isPublic: !!row.is_public,
isFeatured: !!row.is_featured,
featuredAt: Number(row.featured_at || 0),
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
authorId: row.author_id,
authorName: getUserDisplayName(row),
authorAccountName: getUserAccountName(row),
authorAvatarSrc: row.avatar_src || '',
}))
const favoriteStats = await getFavoriteStatsForTierListIds(
tierLists.map((tierList) => tierList.id),
currentUserId
)
return applyFavoriteMetaToTierLists(tierLists, favoriteStats)
}
async function listFollowingTierLists(userId, queryText = '') {
const params = [userId]
let whereClause = 'WHERE uf.follower_id = ? AND t.is_public = 1'
if ((queryText || '').trim()) {
const search = `%${queryText.trim()}%`
whereClause += ' AND (t.title LIKE ? OR tp.name LIKE ? OR u.nickname LIKE ? OR u.email LIKE ?)'
params.push(search, search, search, search)
}
const rows = await query(
`
SELECT
t.id,
t.topic_id,
tp.name AS topic_name,
t.title,
t.thumbnail_src,
t.is_public,
t.is_featured,
t.featured_at,
t.featured_by,
t.created_at,
t.updated_at,
t.author_id,
uf.created_at AS followed_at,
u.nickname,
u.email,
u.avatar_src
FROM user_follows uf
INNER JOIN tierlists t ON t.author_id = uf.following_id
INNER JOIN users u ON u.id = t.author_id
INNER JOIN topics tp ON tp.id = t.topic_id
${whereClause}
ORDER BY t.updated_at DESC, uf.created_at DESC
LIMIT 200
`,
params
)
const tierLists = rows.map((row) => ({
id: row.id,
topicId: row.topic_id,
topicName: row.topic_name || '',
title: row.title,
thumbnailSrc: row.thumbnail_src || '',
isPublic: !!row.is_public,
isFeatured: !!row.is_featured,
featuredAt: Number(row.featured_at || 0),
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
authorId: row.author_id,
authorName: getUserDisplayName(row),
authorAccountName: getUserAccountName(row),
authorAvatarSrc: row.avatar_src || '',
isFavorited: false,
}))
const favoriteStats = await getFavoriteStatsForTierListIds(
tierLists.map((tierList) => tierList.id),
userId
)
return applyFavoriteMetaToTierLists(tierLists, favoriteStats)
}
function uniqueTierListItems(poolItems) {
const map = new Map()
;(poolItems || []).forEach((item) => {
@@ -2726,6 +2930,7 @@ module.exports = {
findUserByEmail,
findUserByNickname,
findUserById,
findUserProfileById,
createUser,
updateUserPassword,
verifyUserEmail,
@@ -2779,6 +2984,8 @@ module.exports = {
listCustomItems,
findUnusedCustomItems,
listPublicTierLists,
listPublicTierListsByAuthor,
listFollowingTierLists,
listFavoriteTierLists,
listUserTierLists,
listAdminTierLists,
@@ -2788,6 +2995,8 @@ module.exports = {
updateTierListFeaturedStatus,
favoriteTopic,
unfavoriteTopic,
followUser,
unfollowUser,
favoriteTierList,
unfavoriteTierList,
deleteTierList,

View File

@@ -0,0 +1,77 @@
const express = require('express')
const { z } = require('zod')
const {
findUserProfileById,
followUser,
unfollowUser,
listPublicTierListsByAuthor,
listFollowingTierLists,
} = require('../db')
const { requireAuth } = require('../middleware/auth')
const router = express.Router()
router.get('/following-feed', requireAuth, async (req, res) => {
const schema = z.object({
q: z.string().trim().max(120).optional().default(''),
})
const parsed = schema.safeParse(req.query)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const tierLists = await listFollowingTierLists(req.session.userId, parsed.data.q)
res.json({ tierLists })
})
router.get('/:userId', async (req, res) => {
const user = await findUserProfileById(req.params.userId, req.session?.userId || '')
if (!user) return res.status(404).json({ error: 'not_found' })
res.json({ user })
})
router.get('/:userId/tierlists', async (req, res) => {
const schema = z.object({
q: z.string().trim().max(120).optional().default(''),
})
const parsed = schema.safeParse(req.query)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const user = await findUserProfileById(req.params.userId, req.session?.userId || '')
if (!user) return res.status(404).json({ error: 'not_found' })
const tierLists = await listPublicTierListsByAuthor(
req.params.userId,
req.session?.userId || '',
parsed.data.q
)
res.json({ tierLists })
})
router.post('/:userId/follow', requireAuth, async (req, res) => {
const targetUserId = req.params.userId || ''
if (!targetUserId || targetUserId === req.session.userId) {
return res.status(400).json({ error: 'self_follow_not_allowed' })
}
const user = await findUserProfileById(targetUserId, req.session.userId)
if (!user) return res.status(404).json({ error: 'not_found' })
await followUser({ followerId: req.session.userId, followingId: targetUserId })
const updated = await findUserProfileById(targetUserId, req.session.userId)
res.json({ user: updated })
})
router.delete('/:userId/follow', requireAuth, async (req, res) => {
const targetUserId = req.params.userId || ''
if (!targetUserId || targetUserId === req.session.userId) {
return res.status(400).json({ error: 'self_follow_not_allowed' })
}
const user = await findUserProfileById(targetUserId, req.session.userId)
if (!user) return res.status(404).json({ error: 'not_found' })
await unfollowUser({ followerId: req.session.userId, followingId: targetUserId })
const updated = await findUserProfileById(targetUserId, req.session.userId)
res.json({ user: updated })
})
module.exports = router