import { createError } from 'h3' import { getPostgresClient } from './postgres-client' /** * @typedef {Object} MemberUser * @property {string} id - 사용자 ID * @property {string} username - 사용자명 * @property {string} email - 이메일 * @property {string} passwordHash - 비밀번호 해시 * @property {string} avatarUrl - 아바타 URL * @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", created_at AS "createdAt", updated_at AS "updatedAt", last_seen_at AS "lastSeenAt", last_seen_ip AS "lastSeenIp" FROM users WHERE email = ${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", 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", 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` INSERT INTO users (username, email, password_hash, avatar_url) VALUES (${input.username}, ${input.email}, ${input.passwordHash}, '') RETURNING id, username, email, avatar_url AS "avatarUrl", 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", created_at AS "createdAt", updated_at AS "updatedAt", last_seen_at AS "lastSeenAt", last_seen_ip AS "lastSeenIp" ` return rows?.[0] || null } /** * 회원 비밀번호 변경 * @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} ` } /** * 사용자명 중복 확인 * @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]) } /** * 관리자용 회원 목록 조회(댓글 활동 포함) * @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.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((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 return { id: row.id, username: row.username, email: row.email, avatarUrl: row.avatarUrl || '', createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), lastSeenAt, lastSeenIp: row.lastSeenIp || '', commentCount: Number(row.commentCount || 0), activityStatus: isActive ? '활성' : '비활성' } }) }