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] /** * 회원 권한 표시 문자열을 반환한다. * @param {string} roleCode - 권한 코드 * @returns {string} 권한 표시 문자열 */ const getMemberRoleLabel = (roleCode) => roleCode === MEMBER_ROLE.OWNER ? '소유자' : roleCode === MEMBER_ROLE.ADMIN ? '관리자' : '멤버' /** * 관리자 회원 행을 응답 객체로 변환한다. * @param {Object} row - DB 회원 행 * @returns {Object} 관리자 회원 응답 */ const mapAdminMemberRow = (row) => { const lastSeenAt = row.lastSeenAt ? row.lastSeenAt.toISOString() : null const isActive = row.lastSeenAt ? Date.now() - new Date(row.lastSeenAt).getTime() <= 1000 * 60 * 60 * 24 * 30 : false const roleCode = String(row.roleCode || MEMBER_ROLE.MEMBER) return { id: row.id, username: row.username, email: row.email, avatarUrl: row.avatarUrl || '', labels: Array.isArray(row.labels) ? row.labels : [], note: row.note || '', isAdmin: Boolean(row.isAdmin), roleCode, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), lastSeenAt, lastSeenIp: row.lastSeenIp || '', commentCount: Number(row.commentCount || 0), activityStatus: isActive ? '활성' : '비활성', role: getMemberRoleLabel(roleCode) } } /** * @typedef {Object} MemberUser * @property {string} id - 사용자 ID * @property {string} username - 사용자명 * @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) * @property {string} lastSeenIp - 최근 접속 IP */ /** * DB 클라이언트 조회 (필수) * @returns {ReturnType} postgres sql 클라이언트 */ const requireSql = () => { const sql = getPostgresClient() if (!sql) { throw createError({ statusCode: 500, message: '데이터베이스 설정이 필요합니다.' }) } return sql } /** * 이메일로 회원 조회 * @param {string} email - 이메일 * @returns {Promise} 회원 */ export const getUserByEmail = 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}) LIMIT 1 ` return rows?.[0] || null } /** * ID로 회원 조회 * @param {string} id - 사용자 ID * @returns {Promise | null>} 회원 */ export const getUserById = async (id) => { const sql = requireSql() const rows = await sql` SELECT 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", last_seen_ip AS "lastSeenIp" FROM users WHERE id = ${id} LIMIT 1 ` return rows?.[0] || null } /** * ID로 회원 조회(비밀번호 포함) * @param {string} id - 사용자 ID * @returns {Promise} 회원 */ export const getUserByIdWithPassword = async (id) => { 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 id = ${id} LIMIT 1 ` return rows?.[0] || null } /** * 회원 생성 * @param {{ username: string, email: string, passwordHash: string }} input - 입력 * @returns {Promise>} 생성된 회원 */ export const createUser = async (input) => { const sql = requireSql() const rows = await sql.begin(async (tx) => { await tx`LOCK TABLE users IN SHARE ROW EXCLUSIVE MODE` return tx` 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", last_seen_ip AS "lastSeenIp" ` }) const created = rows?.[0] if (!created) { throw createError({ statusCode: 500, message: '회원 생성에 실패했습니다.' }) } return created } /** * 회원 최근 활동 정보를 기록한다. * @param {{ userId: string, ip: string }} input - 사용자 ID와 접속 IP * @returns {Promise} */ export const touchUserActivity = async (input) => { const sql = requireSql() await sql` UPDATE users SET last_seen_at = now(), last_seen_ip = ${input.ip}, updated_at = now() WHERE id = ${input.userId} ` } /** * 회원 프로필 수정 * @param {{ userId: string, username: string, avatarUrl: string }} input - 수정 값 * @returns {Promise | null>} 수정된 회원 */ export const updateMemberProfile = async (input) => { const sql = requireSql() const rows = await sql` UPDATE users SET username = ${input.username}, avatar_url = ${input.avatarUrl}, updated_at = now() WHERE id = ${input.userId} 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", last_seen_ip AS "lastSeenIp" ` return rows?.[0] || null } /** * 이메일(대소문자 무시)로 비밀번호 해시를 갱신한다. * @param {{ email: string, passwordHash: string }} input - 입력 * @returns {Promise} 갱신된 행이 있으면 true */ export const updateMemberPasswordByEmail = async (input) => { const sql = requireSql() const rows = await sql` UPDATE users SET password_hash = ${input.passwordHash}, updated_at = now() WHERE lower(email) = lower(${input.email}) RETURNING id ` return Boolean(rows?.[0]) } /** * 회원 비밀번호 변경 * @param {{ userId: string, passwordHash: string }} input - 수정 값 * @returns {Promise} */ export const updateMemberPassword = async (input) => { const sql = requireSql() await sql` UPDATE users SET password_hash = ${input.passwordHash}, updated_at = now() WHERE id = ${input.userId} ` } /** * 회원 탈퇴 * @param {string} userId - 사용자 ID * @returns {Promise} */ export const deleteMember = async (userId) => { const sql = requireSql() await sql` DELETE FROM users WHERE id = ${userId} ` } /** * 소유자 권한 회원 수를 조회한다. * @returns {Promise} 소유자 회원 수 */ export const countOwnerMembers = async () => { const sql = requireSql() const rows = await sql` SELECT COUNT(*)::int AS "ownerCount" FROM users WHERE user_role = ${MEMBER_ROLE.OWNER} ` return Number(rows?.[0]?.ownerCount || 0) } /** * 사용자명 중복 확인 * @param {{ username: string, excludeUserId?: string }} input - 사용자명과 제외 사용자 ID * @returns {Promise} 중복 여부 */ export const isUsernameTaken = async (input) => { const sql = requireSql() const rows = input.excludeUserId ? await sql` SELECT id FROM users WHERE lower(username) = lower(${input.username}) AND id <> ${input.excludeUserId} LIMIT 1 ` : await sql` SELECT id FROM users WHERE lower(username) = lower(${input.username}) LIMIT 1 ` return Boolean(rows?.[0]) } /** * 이메일 중복 확인 * @param {{ email: string, excludeUserId?: string }} input - 이메일과 제외 사용자 ID * @returns {Promise} 중복 여부 */ export const isEmailTaken = async (input) => { const sql = requireSql() const rows = input.excludeUserId ? await sql` SELECT id FROM users WHERE lower(email) = lower(${input.email}) AND id <> ${input.excludeUserId} LIMIT 1 ` : await sql` SELECT id FROM users WHERE lower(email) = lower(${input.email}) LIMIT 1 ` return Boolean(rows?.[0]) } /** * 관리자용 회원 목록 조회(댓글 활동 포함) * @returns {Promise>} 회원 목록 */ export const listMembersForAdmin = async () => { const sql = requireSql() const rows = await sql` SELECT users.id, users.username, users.email, users.avatar_url AS "avatarUrl", users.member_labels AS "labels", users.member_note AS "note", 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", users.last_seen_ip AS "lastSeenIp", COALESCE(count(comments.id), 0)::int AS "commentCount" FROM users LEFT JOIN comments ON comments.user_id = users.id AND comments.status = 'published' GROUP BY users.id ORDER BY users.created_at DESC ` return rows.map(mapAdminMemberRow) } /** * 관리자용 회원 상세 조회 * @param {string} memberId - 회원 ID * @returns {Promise} 회원 상세 */ export const getMemberForAdmin = async (memberId) => { const sql = requireSql() const rows = await sql` SELECT users.id, users.username, users.email, users.avatar_url AS "avatarUrl", users.member_labels AS "labels", users.member_note AS "note", 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", users.last_seen_ip AS "lastSeenIp", COALESCE(count(comments.id), 0)::int AS "commentCount" FROM users LEFT JOIN comments ON comments.user_id = users.id AND comments.status = 'published' WHERE users.id = ${memberId} GROUP BY users.id LIMIT 1 ` return rows?.[0] ? mapAdminMemberRow(rows[0]) : null } /** * 관리자 화면에서 회원을 생성한다. * @param {{ username: string, email: string, passwordHash: string, avatarUrl: string, labels: string[], note: string }} input - 생성 값 * @returns {Promise} 생성된 회원 */ export const createMemberByAdmin = async (input) => { const sql = requireSql() const rows = await sql` INSERT INTO users ( username, email, password_hash, avatar_url, member_labels, member_note, is_admin, user_role ) VALUES ( ${input.username}, ${input.email}, ${input.passwordHash}, ${input.avatarUrl}, ${input.labels}, ${input.note}, false, ${MEMBER_ROLE.MEMBER} ) RETURNING id ` const created = rows?.[0] if (!created) { throw createError({ statusCode: 500, message: '회원 생성에 실패했습니다.' }) } return getMemberForAdmin(created.id) } /** * 관리자 화면에서 회원 기본 정보를 수정한다. * @param {{ memberId: string, username: string, email: string, avatarUrl: string, labels: string[], note: string }} input - 수정 값 * @returns {Promise} 수정된 회원 */ export const updateMemberByAdmin = async (input) => { const sql = requireSql() const rows = await sql` UPDATE users SET username = ${input.username}, email = ${input.email}, avatar_url = ${input.avatarUrl}, member_labels = ${input.labels}, member_note = ${input.note}, updated_at = now() WHERE id = ${input.memberId} RETURNING id ` return rows?.[0] ? getMemberForAdmin(rows[0].id) : null } /** * 이메일 기준 관리자 회원 조회 * @param {string} email - 이메일 * @returns {Promise} 관리자 회원 */ 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} 관리자 권한 여부 */ 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) } }