태그를 관리용/일반용으로 분리하고 관리자 드래그 정렬을 추가.
댓글/회원/관리자 인증·프로필 흐름 보완과 관련 마이그레이션 및 문서를 함께 반영해 운영 동선을 안정화. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,14 @@
|
||||
import { createError } from 'h3'
|
||||
import { getPostgresClient } from './postgres-client'
|
||||
|
||||
export const MEMBER_ROLE = {
|
||||
OWNER: 'owner',
|
||||
ADMIN: 'admin',
|
||||
MEMBER: 'member'
|
||||
}
|
||||
|
||||
const PRIVILEGED_ROLES = [MEMBER_ROLE.OWNER, MEMBER_ROLE.ADMIN]
|
||||
|
||||
/**
|
||||
* @typedef {Object} MemberUser
|
||||
* @property {string} id - 사용자 ID
|
||||
@@ -8,6 +16,8 @@ import { getPostgresClient } from './postgres-client'
|
||||
* @property {string} email - 이메일
|
||||
* @property {string} passwordHash - 비밀번호 해시
|
||||
* @property {string} avatarUrl - 아바타 URL
|
||||
* @property {boolean} isAdmin - 관리자 여부
|
||||
* @property {'owner' | 'admin' | 'member'} role - 권한 코드
|
||||
* @property {string} createdAt - 생성 시각(ISO)
|
||||
* @property {string} updatedAt - 수정 시각(ISO)
|
||||
* @property {string | null} lastSeenAt - 최근 접속 시각(ISO)
|
||||
@@ -43,6 +53,8 @@ export const getUserByEmail = async (email) => {
|
||||
email,
|
||||
password_hash AS "passwordHash",
|
||||
avatar_url AS "avatarUrl",
|
||||
is_admin AS "isAdmin",
|
||||
user_role AS "role",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
@@ -68,6 +80,8 @@ export const getUserById = async (id) => {
|
||||
username,
|
||||
email,
|
||||
avatar_url AS "avatarUrl",
|
||||
is_admin AS "isAdmin",
|
||||
user_role AS "role",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
@@ -94,6 +108,8 @@ export const getUserByIdWithPassword = async (id) => {
|
||||
email,
|
||||
password_hash AS "passwordHash",
|
||||
avatar_url AS "avatarUrl",
|
||||
is_admin AS "isAdmin",
|
||||
user_role AS "role",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
@@ -115,13 +131,25 @@ export const createUser = async (input) => {
|
||||
const sql = requireSql()
|
||||
|
||||
const rows = await sql`
|
||||
INSERT INTO users (username, email, password_hash, avatar_url)
|
||||
VALUES (${input.username}, ${input.email}, ${input.passwordHash}, '')
|
||||
INSERT INTO users (username, email, password_hash, avatar_url, is_admin, user_role)
|
||||
VALUES (
|
||||
${input.username},
|
||||
${input.email},
|
||||
${input.passwordHash},
|
||||
'',
|
||||
NOT EXISTS (SELECT 1 FROM users),
|
||||
CASE
|
||||
WHEN NOT EXISTS (SELECT 1 FROM users) THEN ${MEMBER_ROLE.OWNER}
|
||||
ELSE ${MEMBER_ROLE.MEMBER}
|
||||
END
|
||||
)
|
||||
RETURNING
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
avatar_url AS "avatarUrl",
|
||||
is_admin AS "isAdmin",
|
||||
user_role AS "role",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
@@ -175,6 +203,8 @@ export const updateMemberProfile = async (input) => {
|
||||
username,
|
||||
email,
|
||||
avatar_url AS "avatarUrl",
|
||||
is_admin AS "isAdmin",
|
||||
user_role AS "role",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
@@ -240,7 +270,7 @@ export const isUsernameTaken = async (input) => {
|
||||
|
||||
/**
|
||||
* 관리자용 회원 목록 조회(댓글 활동 포함)
|
||||
* @returns {Promise<Array<{ id: string, username: string, email: string, avatarUrl: string, createdAt: string, updatedAt: string, lastSeenAt: string | null, lastSeenIp: string, commentCount: number, activityStatus: string }>>} 회원 목록
|
||||
* @returns {Promise<Array<{ id: string, username: string, email: string, avatarUrl: string, isAdmin: boolean, roleCode: string, createdAt: string, updatedAt: string, lastSeenAt: string | null, lastSeenIp: string, commentCount: number, activityStatus: string, role: string }>>} 회원 목록
|
||||
*/
|
||||
export const listMembersForAdmin = async () => {
|
||||
const sql = requireSql()
|
||||
@@ -250,6 +280,8 @@ export const listMembersForAdmin = async () => {
|
||||
users.username,
|
||||
users.email,
|
||||
users.avatar_url AS "avatarUrl",
|
||||
users.is_admin AS "isAdmin",
|
||||
users.user_role AS "roleCode",
|
||||
users.created_at AS "createdAt",
|
||||
users.updated_at AS "updatedAt",
|
||||
users.last_seen_at AS "lastSeenAt",
|
||||
@@ -272,13 +304,181 @@ export const listMembersForAdmin = async () => {
|
||||
username: row.username,
|
||||
email: row.email,
|
||||
avatarUrl: row.avatarUrl || '',
|
||||
isAdmin: Boolean(row.isAdmin),
|
||||
roleCode: String(row.roleCode || MEMBER_ROLE.MEMBER),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
lastSeenAt,
|
||||
lastSeenIp: row.lastSeenIp || '',
|
||||
commentCount: Number(row.commentCount || 0),
|
||||
activityStatus: isActive ? '활성' : '비활성'
|
||||
activityStatus: isActive ? '활성' : '비활성',
|
||||
role: row.roleCode === MEMBER_ROLE.OWNER
|
||||
? '소유자'
|
||||
: row.roleCode === MEMBER_ROLE.ADMIN
|
||||
? '관리자'
|
||||
: '멤버'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 이메일 기준 관리자 회원 조회
|
||||
* @param {string} email - 이메일
|
||||
* @returns {Promise<MemberUser | null>} 관리자 회원
|
||||
*/
|
||||
export const getAdminUserByEmail = async (email) => {
|
||||
const sql = requireSql()
|
||||
const rows = await sql`
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
password_hash AS "passwordHash",
|
||||
avatar_url AS "avatarUrl",
|
||||
is_admin AS "isAdmin",
|
||||
user_role AS "role",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
last_seen_ip AS "lastSeenIp"
|
||||
FROM users
|
||||
WHERE lower(email) = lower(${email})
|
||||
AND user_role = ANY(${PRIVILEGED_ROLES})
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
return rows?.[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 최초 관리자 등록 필요 여부를 확인한다.
|
||||
* @returns {Promise<{ hasUsers: boolean, needsAdminSetup: boolean }>} 부트스트랩 상태
|
||||
*/
|
||||
export const getMemberBootstrapState = async () => {
|
||||
const sql = requireSql()
|
||||
const rows = await sql`
|
||||
SELECT COUNT(*)::int AS "userCount"
|
||||
FROM users
|
||||
`
|
||||
|
||||
const userCount = Number(rows?.[0]?.userCount || 0)
|
||||
|
||||
return {
|
||||
hasUsers: userCount > 0,
|
||||
needsAdminSetup: userCount === 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 권한을 가진 회원 여부를 확인한다.
|
||||
* @param {string} userId - 사용자 ID
|
||||
* @returns {Promise<boolean>} 관리자 권한 여부
|
||||
*/
|
||||
export const isPrivilegedMember = async (userId) => {
|
||||
const sql = requireSql()
|
||||
const rows = await sql`
|
||||
SELECT id
|
||||
FROM users
|
||||
WHERE id = ${userId}
|
||||
AND user_role = ANY(${PRIVILEGED_ROLES})
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
return Boolean(rows?.[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 화면에서 회원 권한을 변경한다.
|
||||
* @param {{ actorUserId: string, targetUserId: string, role: 'owner' | 'admin' | 'member' }} input - 변경 정보
|
||||
* @returns {Promise<{ id: string, roleCode: string, role: string, isAdmin: boolean }>} 변경 결과
|
||||
*/
|
||||
export const updateMemberRoleByAdmin = async (input) => {
|
||||
const sql = requireSql()
|
||||
const normalizedRole = String(input.role || '').trim()
|
||||
const allowedRoles = [MEMBER_ROLE.OWNER, MEMBER_ROLE.ADMIN, MEMBER_ROLE.MEMBER]
|
||||
|
||||
if (!allowedRoles.includes(normalizedRole)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '유효하지 않은 권한 값입니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const actorCanManage = await isPrivilegedMember(input.actorUserId)
|
||||
if (!actorCanManage) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '권한 변경 권한이 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const targetRows = await sql`
|
||||
SELECT id, user_role AS "roleCode"
|
||||
FROM users
|
||||
WHERE id = ${input.targetUserId}
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
const target = targetRows?.[0]
|
||||
if (!target) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '대상 회원을 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (target.id === input.actorUserId && normalizedRole === MEMBER_ROLE.MEMBER) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '본인 계정을 멤버로 변경할 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (target.roleCode === MEMBER_ROLE.OWNER && normalizedRole !== MEMBER_ROLE.OWNER) {
|
||||
const ownerRows = await sql`
|
||||
SELECT COUNT(*)::int AS "ownerCount"
|
||||
FROM users
|
||||
WHERE user_role = ${MEMBER_ROLE.OWNER}
|
||||
`
|
||||
|
||||
if (Number(ownerRows?.[0]?.ownerCount || 0) <= 1) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '최소 1명의 소유자 권한은 유지되어야 합니다.'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const updatedRows = await sql`
|
||||
UPDATE users
|
||||
SET
|
||||
user_role = ${normalizedRole},
|
||||
is_admin = ${normalizedRole === MEMBER_ROLE.OWNER || normalizedRole === MEMBER_ROLE.ADMIN},
|
||||
updated_at = now()
|
||||
WHERE id = ${input.targetUserId}
|
||||
RETURNING
|
||||
id,
|
||||
user_role AS "roleCode",
|
||||
is_admin AS "isAdmin"
|
||||
`
|
||||
|
||||
const updated = updatedRows?.[0]
|
||||
if (!updated) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '권한 변경에 실패했습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
roleCode: updated.roleCode,
|
||||
role: updated.roleCode === MEMBER_ROLE.OWNER
|
||||
? '소유자'
|
||||
: updated.roleCode === MEMBER_ROLE.ADMIN
|
||||
? '관리자'
|
||||
: '멤버',
|
||||
isAdmin: Boolean(updated.isAdmin)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user