본인 티어표 복사 복구와 팔로우 피드 추가
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 usersRoutes = require('./src/routes/users')
|
||||
const adminRoutes = require('./src/routes/admin')
|
||||
|
||||
const app = express()
|
||||
@@ -85,6 +86,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/users', usersRoutes)
|
||||
app.use('/api/admin', adminRoutes)
|
||||
|
||||
app.listen(PORT, () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
77
backend/src/routes/users.js
Normal file
77
backend/src/routes/users.js
Normal 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
|
||||
Reference in New Issue
Block a user