v1.12.22 관리자 멤버 댓글 목록 및 최근 댓글 확인 추가
This commit is contained in:
69
components/admin/AdminCommentList.vue
Normal file
69
components/admin/AdminCommentList.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
memberId: { type: String, default: '' },
|
||||
recent: { type: Boolean, default: false }
|
||||
})
|
||||
const page = ref(1)
|
||||
const query = computed(() => ({
|
||||
...(props.memberId ? { memberId: props.memberId } : {}),
|
||||
page: page.value,
|
||||
pageSize: props.recent ? 5 : 10
|
||||
}))
|
||||
const { data, status, error, refresh } = await useFetch('/admin/api/comments', {
|
||||
query,
|
||||
default: () => ({ items: [], pagination: { page: 1, pageCount: 1, total: 0 } })
|
||||
})
|
||||
watch(() => props.memberId, () => { page.value = 1 })
|
||||
let refreshTimer = null
|
||||
onMounted(() => {
|
||||
if (props.recent) refreshTimer = window.setInterval(() => refresh(), 30000)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer) window.clearInterval(refreshTimer)
|
||||
})
|
||||
|
||||
/**
|
||||
* 댓글 작성 시각을 한국어 날짜로 표시한다.
|
||||
* @param {string} value - 작성 시각
|
||||
* @returns {string} 표시 날짜
|
||||
*/
|
||||
const formatCreatedAt = (value) => new Intl.DateTimeFormat('ko-KR', {
|
||||
dateStyle: 'medium', timeStyle: 'short', timeZone: 'Asia/Seoul'
|
||||
}).format(new Date(value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-comment-list rounded-xl border border-line bg-white p-5 md:p-6" :aria-busy="status === 'pending'">
|
||||
<h2 class="admin-comment-list__heading text-lg font-semibold">
|
||||
{{ recent ? '최근 댓글' : '작성한 댓글' }}
|
||||
<span class="text-sm font-normal text-muted">전체 {{ data?.pagination.total || 0 }}개 · 답글 포함</span>
|
||||
</h2>
|
||||
<div v-if="error" class="admin-comment-list__error mt-4 text-sm text-red-700" role="alert">
|
||||
댓글을 불러오지 못했습니다.
|
||||
<button type="button" class="underline" @click="refresh()">다시 시도</button>
|
||||
</div>
|
||||
<p v-else-if="status === 'pending'" class="admin-comment-list__loading py-6 text-sm text-muted">댓글을 불러오는 중입니다.</p>
|
||||
<p v-else-if="!data?.items.length" class="admin-comment-list__empty py-6 text-sm text-muted">작성한 댓글이 없습니다.</p>
|
||||
<ul v-else class="admin-comment-list__items mt-3 divide-y divide-line">
|
||||
<li v-for="comment in data.items" :key="comment.id" class="admin-comment-list__item py-4">
|
||||
<div class="admin-comment-list__meta flex flex-wrap items-center gap-2 text-xs text-muted">
|
||||
<NuxtLink :to="`/admin/members/${comment.userId}`" class="font-semibold hover:underline">{{ comment.username }}</NuxtLink>
|
||||
<time :datetime="comment.createdAt">{{ formatCreatedAt(comment.createdAt) }}</time>
|
||||
<span v-if="comment.parentId">답글</span>
|
||||
<span v-if="comment.status !== 'published'">{{ comment.status === 'pending' ? '승인 대기' : '차단됨' }}</span>
|
||||
</div>
|
||||
<p class="admin-comment-list__body my-2 whitespace-pre-wrap break-words text-sm">{{ comment.body }}</p>
|
||||
<div class="admin-comment-list__post flex flex-wrap items-center gap-3 text-sm">
|
||||
<NuxtLink :to="`/admin/posts/${comment.postId}`" class="min-w-0 break-words font-medium hover:underline">{{ comment.postTitle || '(제목 없음)' }}</NuxtLink>
|
||||
<NuxtLink v-if="['published', 'members'].includes(comment.postStatus)" :to="`/post/${encodeURIComponent(comment.postSlug)}#comments`" target="_blank" rel="noopener noreferrer" class="shrink-0 text-muted underline">게시물 보기</NuxtLink>
|
||||
<span v-else class="text-xs text-muted">미공개 게시물</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<nav v-if="!recent && data?.pagination.pageCount > 1" class="admin-comment-list__pagination mt-4 flex items-center justify-between gap-3 text-sm" aria-label="댓글 페이지">
|
||||
<button type="button" class="rounded border border-line px-3 py-2 disabled:opacity-40" :disabled="status === 'pending' || data.pagination.page <= 1" @click="page = data.pagination.page - 1">이전</button>
|
||||
<span>{{ data.pagination.page }} / {{ data.pagination.pageCount }} 페이지</span>
|
||||
<button type="button" class="rounded border border-line px-3 py-2 disabled:opacity-40" :disabled="status === 'pending' || data.pagination.page >= data.pagination.pageCount" @click="page = data.pagination.page + 1">다음</button>
|
||||
</nav>
|
||||
</section>
|
||||
</template>
|
||||
@@ -924,6 +924,8 @@ watch(() => props.member, () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<AdminCommentList v-if="!isNewMember && member?.id" :key="member.id" :member-id="member.id" />
|
||||
|
||||
<section v-if="!isNewMember" class="admin-member-form__activity">
|
||||
<h2 class="admin-member-form__section-title mb-4 text-xs font-semibold uppercase tracking-[0.04em] text-[#15171a]">활동</h2>
|
||||
<div class="admin-member-form__activity-card rounded-xl border border-line bg-white px-5 md:px-6">
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
## 2026-10-21 v1.12.22 — 관리자 댓글 목록은 공개 조회와 분리한다
|
||||
|
||||
관리자는 회원별 작성 이력과 최근 댓글을 게시물 상태에 관계없이 확인해야 하므로 관리자 세션 전용 페이지 조회 API를 둔다. 공개 댓글 API의 접근 정책을 유지하고, 공통 목록 컴포넌트를 멤버 상세와 대시보드에서 재사용한다. 댓글 본문은 텍스트로 표시하고 게시물 편집과 공개 댓글 영역 이동을 구분한다.
|
||||
|
||||
## 2026-09-28 v1.12.21 — 미디어 선택은 폴더 정리와 삭제 권한을 분리한다
|
||||
|
||||
기존 현재 페이지 선택은 사용 중인 파일을 제외했지만 논리 폴더 이동은 콘텐츠 URL을 바꾸지 않아 사용 중인 원본도 정리할 수 있다. 전체 선택은 현재 페이지의 모든 항목으로 통일하고 삭제 시에만 기존 사용 중 잠금을 적용한다. 새 폴더 생성 때 선택을 유지하고, 드래그 이동은 선택한 항목 수·대상 폴더·처리 결과를 표시한다. 이동 완료 후 현재 탐색 위치를 유지해 폴더가 갑자기 전환되지 않게 한다. 조회 중 이전 목록을 숨기고 같은 조회 키에서 앞선 요청을 취소해 빠른 폴더 전환에서 늦은 응답이 화면을 되돌리지 않게 한다.
|
||||
|
||||
10
docs/map.md
10
docs/map.md
@@ -312,7 +312,7 @@
|
||||
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함, 후보 없는 슬래시 입력은 일반 줄바꿈·커서 이동 허용)·미디어 모달 선택 결과 본문 삽입(이미지·갤러리·비디오·오디오·파일), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·선택 텍스트 색상 컨텍스트 `block-panel` emit, 소스 모드 입력 직후 textarea 최신 본문 기준 커서 논리 줄·블록 컨텍스트 갱신, 에디터 본문 `Cmd/Ctrl+Z` 히스토리와 선택 위치 복원, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 슬래시 메뉴 위치·슬래시 명령 실행·공통 포커스·블록 패널 옵션 적용·라이브 미리보기 이벤트 적용 composable 연결, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 현재 블록 범위 안에서만 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연·미디어 명령 원문 줄 재반영 방지 |
|
||||
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(선택 텍스트 색상, 단일 이미지 대표 너비 셀렉트(전체·갤러리 2장 1칸·갤러리 3장 1칸·갤러리 3장 2칸·직접 입력)·직접 너비·이미지·갤러리·링크·iframe·HTML·인용 배경색·콜아웃 제목·아이콘 ON/OFF 토글·비활성 아이콘 영역·배경색·코드 언어 셀렉트·줄번호 ON/OFF 토글·토글), 갤러리 선택 이미지 강조 |
|
||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상, 태그 헤더 이미지 업로드·삭제, 오버레이 제목·본문 편집, 수정 화면 변경 저장 버튼 변경사항 기준 활성화) |
|
||||
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 기존 회원 보기/수정 모드 분리, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·Google 로그인 연동 상태·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 소유자 전용 Google 연동 해제 모달, 저장 토스트, 미저장 변경사항 이탈 확인) |
|
||||
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 기존 회원 보기/수정 모드 분리, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·Google 로그인 연동 상태·활동 요약·멤버별 댓글 페이지 목록, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 소유자 전용 Google 연동 해제 모달, 저장 토스트, 미저장 변경사항 이탈 확인) |
|
||||
| components/admin/AdminUnsavedChangesModal.vue | 관리자 편집 화면 공통 미저장 변경사항 이탈 확인 모달(상단 40px 위치) |
|
||||
| components/admin/AdminConfirmModal.vue | 관리자 공통 확인 모달(제목·본문 슬롯·확인/취소 버튼, primary/danger 톤) |
|
||||
| components/admin/AdminModalFrame.vue | 관리자 공통 일반 모달 프레임(오버레이, dialog 접근성 속성, 기본 패널 스타일, 바깥 클릭 닫기) |
|
||||
@@ -366,9 +366,9 @@
|
||||
|
||||
| 파일 | 화면 |
|
||||
|------|------|
|
||||
| pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 방문자·누적 방문자·게시물 수, 일일·주간·월간·년간 선택형 방문자수·평균 체류·50% 스크롤 차트, 차트 막대 클릭 시 하단 선택 기간 상세 갱신, 자동 갱신 중 선택 기간 유지, 선택 기간 기준 왼쪽 인기 게시물·인기 페이지와 오른쪽 유입원·모바일/PC 묶음 디바이스(상위 기기별 방문자·조회·비율 한 줄 요약, OS별 방문자·비율), IP·줄바꿈된 열린 페이지 수·열린 페이지 목록 포함 접속자 목록, CSP 관찰 목적·집계 의미·정책 한계 안내와 상위 보고) |
|
||||
| pages/admin/index.vue | 대시보드(최근 댓글 5개·작성자/게시물 이동·30초 갱신, 상단 요약: 현재 접속자·오늘 방문자·누적 방문자·게시물 수, 일일·주간·월간·년간 선택형 방문자수·평균 체류·50% 스크롤 차트, 차트 막대 클릭 시 하단 선택 기간 상세 갱신, 자동 갱신 중 선택 기간 유지, 선택 기간 기준 왼쪽 인기 게시물·인기 페이지와 오른쪽 유입원·모바일/PC 묶음 디바이스(상위 기기별 방문자·조회·비율 한 줄 요약, OS별 방문자·비율), IP·줄바꿈된 열린 페이지 수·열린 페이지 목록 포함 접속자 목록, CSP 관찰 목적·집계 의미·정책 한계 안내와 상위 보고) |
|
||||
| pages/admin/login.vue | 관리자 로그인, 일반 로그인과 같은 다크 인증 스타일·우측 배치 및 내부 오른쪽 정렬 폼, 이메일·비밀번호 모두 입력 시에만 제출 버튼 활성 |
|
||||
| pages/admin/posts/index.vue | 글 목록, 본문 없는 `/admin/api/posts?paged=true` 페이지 응답 사용, 서버 검색·필터(상태·검색형 태그 combobox·추천·정렬)와 번호형 페이지네이션, `F`/`ㄹ` 검색창 포커스, 관리자 전용 상단 고정 배지·정렬·행 메뉴 토글, 헤더 우측 «새 글» 배치, 예약/초안/공개/멤버십/비공개 텍스트 상태, 추천 표시와 제목 사이 `featuredImageThumbnail` 우선 대표 이미지 썸네일, 제목 옆 댓글 수, 제목 아래 주소·공개 보기 hover 아이콘, 백링크 개수 열과 고정 크기 참조 게시물 목록 모달·내부 검색·이동 아이콘, 별도 조회수 열의 누적 조회수·통계 모달 버튼, 적용 태그 전체 색상 배지, 화면 기준 행 more vert 메뉴(고정 전용 API·추천 전용 API·삭제 전 참조 경고·삭제) |
|
||||
| pages/admin/posts/index.vue | 글 목록, 본문 없는 `/admin/api/posts?paged=true` 페이지 응답 사용, 서버 검색·필터(상태·검색형 태그 combobox·추천·정렬)와 번호형 페이지네이션, `F`/`ㄹ` 검색창 포커스, 관리자 전용 상단 고정 배지·정렬·행 메뉴 토글, 헤더 우측 «새 글» 배치, 예약/초안/공개/멤버십/비공개 텍스트 상태, 추천 표시와 제목 사이 `featuredImageThumbnail` 우선 대표 이미지 썸네일, 별도 댓글 수 열, 제목 아래 주소·공개 보기 hover 아이콘, 백링크 개수 열과 고정 크기 참조 게시물 목록 모달·내부 검색·이동 아이콘, 별도 조회수 열의 누적 조회수·통계 모달 버튼, 적용 태그 전체 색상 배지, 화면 기준 행 more vert 메뉴(고정 전용 API·추천 전용 API·삭제 전 참조 경고·삭제) |
|
||||
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·첫 저장 준비 오버레이·첫 생성 중 추가 입력 PUT 반영·이탈 직전 플러시, 저장 토스트 |
|
||||
| pages/admin/posts/[id].vue | 글 수정, `AdminPostForm`, 저장·초안 디바운스 자동 저장(`autoSaving`)·이탈 직전 플러시·삭제·토스트 |
|
||||
| pages/admin/posts/[id]/analytics.vue | 게시물별 통계, 누적 조회수·기간별 조회/방문자 차트·유입/디바이스/키워드·읽음/체류/스크롤 지표 |
|
||||
@@ -638,3 +638,7 @@
|
||||
| tests/markdown-html-block.test.js | 게시물 HTML sanitizer 공격 우회·허용 HTML 회귀 테스트 |
|
||||
| tests/multipart-file-upload.test.js | 다중 multipart 디스크 스트리밍·파일 크기 제한 회귀 테스트 |
|
||||
| docker-compose.yml | NAS 컨테이너 실행 초안, PostgreSQL 호스트 기본 loopback 바인딩 |
|
||||
| components/admin/AdminCommentList.vue | 멤버별 댓글 10개 페이지 목록·대시보드 최신 댓글 5개·게시물/작성자 이동·조회 오류 재시도 |
|
||||
| server/routes/admin/api/comments.get.js | 관리자 댓글 조회 인증·Zod query 검증 |
|
||||
| server/repositories/comment-repository.js | 공개 댓글 조회·작성·좋아요와 관리자 댓글 최신순 페이지 조회 |
|
||||
| tests/playwright/e2e/admin-comments.spec.js | 멤버 댓글 페이지 이동·게시물 링크·대시보드 최근 댓글·게시글 댓글 열 E2E 검증 |
|
||||
|
||||
@@ -1088,3 +1088,11 @@ APP_PORT=43080
|
||||
- 현재 버전: v0.0.85
|
||||
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
|
||||
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정
|
||||
|
||||
## 관리자 댓글 조회 (v1.12.22)
|
||||
|
||||
- `GET /admin/api/comments`: 관리자 세션 필수. `memberId`(선택 UUID), `page`(기본 1, 1~1000000 정수), `pageSize`(기본 10, 1~100 정수). 잘못된 조건은 400, 미인증은 401.
|
||||
- 응답 `{ items, pagination: { page, pageSize, total, pageCount } }`. 항목은 `id`, `body`, `status`, `parentId`, `createdAt`, `userId`, `username`, `postId`, `postTitle`, `postSlug`, `postStatus` 제공. 모든 상태의 댓글·답글 포함, 작성 시각·ID 내림차순. 범위 초과 페이지는 마지막 페이지로 보정.
|
||||
- 멤버 상세는 10개씩 이전·다음 이동. 대시보드는 전체 최신 댓글 5개를 30초마다 갱신. 조회 오류는 재시도, 조회 중은 로딩 표시.
|
||||
- 댓글 내용은 일반 텍스트로 표시. 작성자는 멤버 상세, 게시물 제목은 관리자 편집으로 이동. 공개·멤버십 글은 새 창의 `/post/:slug#comments` 링크 제공(공개 화면의 기존 접근 권한 적용).
|
||||
- 게시글 목록 댓글 열은 기존 `commentCount` 사용. 공개 상태 댓글·답글만 집계.
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
## 2차 관리자 개발
|
||||
|
||||
- `npm run test:api`의 기존 미디어 카탈로그 회귀 실패 조사: `core-api.spec.js`의 프로필 이미지 `usage`에 `type=user`가 없어 `typeLabel` 확인 실패. 댓글 API 검증은 통과. 격리 DB에서 사용처 계산·카탈로그 캐시 반영 순서 확인.
|
||||
|
||||
## 프론트엔드 개발
|
||||
|
||||
- CSP Report-Only 운영 보고 관찰 후 강제 정책 추가 강화 검토.
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.12.22
|
||||
|
||||
- 관리자 멤버 상세에 최신순 댓글·답글 목록과 서버 페이지네이션 추가.
|
||||
- 관리자 대시보드 최근 댓글 5개·30초 갱신·작성자 및 게시물 이동 추가.
|
||||
- 관리자 게시글 목록의 기존 댓글 수를 별도 열로 이동.
|
||||
- 관리자 댓글 API 인증·조건 검증·페이지·답글·게시글 댓글 수 회귀 검증 추가.
|
||||
- JavaScript 문법 검사·단위 테스트 273건·프로덕션 빌드·Chromium E2E 11건 검증 진행.
|
||||
- API 통합 검증 10건 통과, 미디어 카탈로그 사용처 검증 1건 실패·후속 1건 미실행 기록.
|
||||
|
||||
## v1.12.21
|
||||
|
||||
- 관리자 미디어 다중 선택 테두리·이동 개수 드래그 미리보기·폴더 드롭 강조와 안내 추가.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.12.21",
|
||||
"version": "1.12.22",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.12.21",
|
||||
"version": "1.12.22",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.12.21",
|
||||
"version": "1.12.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -722,6 +722,8 @@ watch(trendRows, () => {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<AdminCommentList recent class="admin-dashboard__recent-comments" />
|
||||
|
||||
<section class="admin-dashboard__adsense">
|
||||
<p
|
||||
v-if="adsenseSummary.configured && !adsenseSummary.ok"
|
||||
|
||||
@@ -1143,6 +1143,7 @@ watch(postPageCount, (pageCount) => {
|
||||
<th class="admin-posts__cell px-4 py-3">상태</th>
|
||||
<th class="admin-posts__cell px-4 py-3">태그</th>
|
||||
<th class="admin-posts__cell px-4 py-3">백링크</th>
|
||||
<th class="admin-posts__cell px-4 py-3">댓글</th>
|
||||
<th class="admin-posts__cell px-4 py-3">조회수</th>
|
||||
<th class="admin-posts__cell px-4 py-3">발행일</th>
|
||||
<th class="admin-posts__cell admin-posts__cell-actions w-12 px-2 py-3 text-right">
|
||||
@@ -1180,9 +1181,6 @@ watch(postPageCount, (pageCount) => {
|
||||
<NuxtLink class="admin-posts__title-link font-semibold hover:opacity-70" :to="`/admin/posts/${post.id}`">
|
||||
{{ post.title || '(제목 없음)' }}
|
||||
</NuxtLink>
|
||||
<span class="admin-posts__comment-count text-xs font-medium text-muted">
|
||||
댓글 {{ post.commentCount || 0 }}
|
||||
</span>
|
||||
<span
|
||||
v-if="post.adminPinned"
|
||||
class="admin-posts__pinned-badge inline-flex h-5 items-center rounded bg-[#15171a] px-1.5 text-[10px] font-semibold text-white"
|
||||
@@ -1241,6 +1239,9 @@ watch(postPageCount, (pageCount) => {
|
||||
</button>
|
||||
<span v-else class="admin-posts__backlink-empty text-muted">0</span>
|
||||
</td>
|
||||
<td class="admin-posts__cell admin-posts__comment-count px-4 py-4 text-sm">
|
||||
{{ formatAdminNumber(post.commentCount || 0) }}
|
||||
</td>
|
||||
<td class="admin-posts__cell admin-posts__cell-views px-4 py-4">
|
||||
<p class="admin-posts__view-count text-[#394047]">
|
||||
{{ formatAdminNumber(post.totalViews) }}
|
||||
|
||||
@@ -329,3 +329,38 @@ export const toggleCommentLike = async (input) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 댓글을 작성자 조건과 최신순 페이지 단위로 조회한다.
|
||||
* @param {{ memberId?: string, page: number, pageSize: number }} options - 조회 조건
|
||||
* @returns {Promise<Object>} 댓글과 페이지 정보
|
||||
*/
|
||||
export const listAdminComments = async ({ memberId = null, page, pageSize }) => {
|
||||
const sql = getSql()
|
||||
const empty = { items: [], pagination: { page: 1, pageSize, total: 0, pageCount: 1 } }
|
||||
if (!sql) return empty
|
||||
|
||||
return sql.begin(async (transaction) => {
|
||||
await transaction`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY`
|
||||
const [count] = await transaction`
|
||||
SELECT COUNT(*)::int AS total FROM comments
|
||||
WHERE (${memberId}::uuid IS NULL OR user_id = ${memberId}::uuid)
|
||||
`
|
||||
const total = Number(count.total)
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
||||
const currentPage = Math.min(page, pageCount)
|
||||
const items = await transaction`
|
||||
SELECT comments.id, comments.body, comments.status,
|
||||
comments.parent_id AS "parentId", comments.created_at AS "createdAt",
|
||||
users.id AS "userId", users.username,
|
||||
posts.id AS "postId", posts.title AS "postTitle", posts.slug AS "postSlug",
|
||||
posts.status AS "postStatus"
|
||||
FROM comments
|
||||
INNER JOIN users ON users.id = comments.user_id
|
||||
INNER JOIN posts ON posts.id = comments.post_id
|
||||
WHERE (${memberId}::uuid IS NULL OR comments.user_id = ${memberId}::uuid)
|
||||
ORDER BY comments.created_at DESC, comments.id DESC
|
||||
LIMIT ${pageSize} OFFSET ${(currentPage - 1) * pageSize}
|
||||
`
|
||||
return { items, pagination: { page: currentPage, pageSize, total, pageCount } }
|
||||
})
|
||||
}
|
||||
|
||||
24
server/routes/admin/api/comments.get.js
Normal file
24
server/routes/admin/api/comments.get.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createError, getQuery } from 'h3'
|
||||
import { z } from 'zod'
|
||||
import { requireAdminSession } from '../../../utils/admin-auth'
|
||||
import { listAdminComments } from '../../../repositories/comment-repository'
|
||||
|
||||
const querySchema = z.object({
|
||||
memberId: z.string().uuid().optional(),
|
||||
page: z.coerce.number().int().min(1).max(1000000).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(10)
|
||||
})
|
||||
|
||||
/**
|
||||
* 관리자 전용 댓글 목록을 조회한다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<Object>} 댓글 페이지
|
||||
*/
|
||||
export default defineEventHandler((event) => {
|
||||
requireAdminSession(event)
|
||||
const result = querySchema.safeParse(getQuery(event))
|
||||
if (!result.success) {
|
||||
throw createError({ statusCode: 400, message: '댓글 조회 조건이 올바르지 않습니다.' })
|
||||
}
|
||||
return listAdminComments(result.data)
|
||||
})
|
||||
@@ -229,6 +229,47 @@ test('CSP 보고는 민감 URL을 제거해 같은 차원으로 집계한다', a
|
||||
expect(JSON.stringify(rows[0])).not.toContain('/private/one.js')
|
||||
})
|
||||
|
||||
test('관리자 댓글 조회는 인증·입력 검증과 멤버별 페이지·답글·댓글 수를 제공한다', async ({ request: anonymousApi }) => {
|
||||
expect((await anonymousApi.get('/admin/api/comments')).status()).toBe(401)
|
||||
for (const query of ['page=0', 'pageSize=101', 'memberId=invalid']) {
|
||||
expect((await adminApi.get(`/admin/api/comments?${query}`)).status()).toBe(400)
|
||||
}
|
||||
const [post] = await database`SELECT id, slug, title FROM posts LIMIT 1`
|
||||
const [other] = await database`
|
||||
INSERT INTO users (username, email, password_hash)
|
||||
VALUES ('댓글 테스트', 'comment-test@example.com', 'unused') RETURNING id
|
||||
`
|
||||
try {
|
||||
const [parent] = await database`
|
||||
INSERT INTO comments (post_id, user_id, body)
|
||||
VALUES (${post.id}, ${other.id}, '다른 회원 댓글') RETURNING id
|
||||
`
|
||||
await database`
|
||||
INSERT INTO comments (post_id, user_id, parent_id, body, created_at)
|
||||
VALUES (${post.id}, ${adminUser.userId}, ${parent.id}, '회원 답글', now() + interval '1 second'),
|
||||
(${post.id}, ${adminUser.userId}, NULL, '회원 댓글', now())
|
||||
`
|
||||
const first = await (await adminApi.get(`/admin/api/comments?memberId=${adminUser.userId}&pageSize=1`)).json()
|
||||
expect(first.pagination).toMatchObject({ page: 1, total: 2, pageCount: 2 })
|
||||
expect(first.items[0]).toMatchObject({ body: '회원 답글', parentId: parent.id, postId: post.id, postTitle: post.title, postSlug: post.slug })
|
||||
const second = await (await adminApi.get(`/admin/api/comments?memberId=${adminUser.userId}&pageSize=1&page=2`)).json()
|
||||
expect(second.items[0].body).toBe('회원 댓글')
|
||||
const last = await (await adminApi.get(`/admin/api/comments?memberId=${adminUser.userId}&pageSize=1&page=999`)).json()
|
||||
expect(last.pagination.page).toBe(2)
|
||||
const recent = await (await adminApi.get('/admin/api/comments?pageSize=5')).json()
|
||||
expect(recent.items[0].body).toBe('회원 답글')
|
||||
const posts = await (await adminApi.get('/admin/api/posts?paged=true')).json()
|
||||
expect(posts.items.find((item) => item.id === post.id).commentCount).toBe(3)
|
||||
await database`DELETE FROM comments WHERE post_id = ${post.id}`
|
||||
const empty = await (await adminApi.get(`/admin/api/comments?memberId=${adminUser.userId}`)).json()
|
||||
expect(empty.pagination.total).toBe(0)
|
||||
expect(empty.items).toEqual([])
|
||||
} finally {
|
||||
await database`DELETE FROM comments WHERE post_id = ${post.id}`
|
||||
await database`DELETE FROM users WHERE id = ${other.id}`
|
||||
}
|
||||
})
|
||||
|
||||
test('CSP 수집은 잘못된 형식과 32KB 초과 본문을 거부한다', async ({ request: anonymousApi }) => {
|
||||
const unsupportedResponse = await anonymousApi.post('/api/csp-report', {
|
||||
headers: {
|
||||
|
||||
45
tests/playwright/e2e/admin-comments.spec.js
Normal file
45
tests/playwright/e2e/admin-comments.spec.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import postgres from 'postgres'
|
||||
|
||||
test('멤버 댓글 페이지 이동·게시물 링크와 대시보드 최근 댓글을 표시한다', async ({ page }) => {
|
||||
const database = postgres(process.env.DATABASE_URL, { max: 1 })
|
||||
let postId
|
||||
try {
|
||||
const login = await page.request.post('/admin/api/auth/login', {
|
||||
data: { email: 'playwright-admin@example.com', password: 'playwright-admin-password' }
|
||||
})
|
||||
expect(login.ok()).toBeTruthy()
|
||||
const admin = await login.json()
|
||||
const created = await page.request.post('/admin/api/posts', {
|
||||
data: { title: '댓글 화면 검증 게시물', slug: 'comments-ui-test', content: '본문', status: 'published', tags: [], tagNames: {} }
|
||||
})
|
||||
expect(created.ok()).toBeTruthy()
|
||||
postId = (await created.json()).id
|
||||
for (let index = 0; index < 11; index += 1) {
|
||||
await database`
|
||||
INSERT INTO comments (post_id, user_id, body, created_at)
|
||||
VALUES (${postId}, ${admin.userId}, ${`화면 댓글 ${index}`}, now() + ${index} * interval '1 second')
|
||||
`
|
||||
}
|
||||
await page.goto(`/admin/members/${admin.userId}`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
const list = page.locator('.admin-comment-list')
|
||||
await expect(list.locator('.admin-comment-list__item')).toHaveCount(10)
|
||||
await expect(list.locator('.admin-comment-list__body').first()).toHaveText('화면 댓글 10')
|
||||
await expect(list.getByRole('link', { name: '게시물 보기' }).first()).toHaveAttribute('href', '/post/comments-ui-test#comments')
|
||||
await list.getByRole('button', { name: '다음' }).click()
|
||||
await expect(list.locator('.admin-comment-list__item')).toHaveCount(1)
|
||||
await expect(list.locator('.admin-comment-list__body')).toHaveText('화면 댓글 0')
|
||||
await list.getByRole('button', { name: '이전' }).click()
|
||||
await expect(list.locator('.admin-comment-list__item')).toHaveCount(10)
|
||||
await page.goto('/admin')
|
||||
await expect(page.locator('.admin-comment-list__item')).toHaveCount(5)
|
||||
await expect(page.locator('.admin-comment-list__body').first()).toHaveText('화면 댓글 10')
|
||||
await page.goto('/admin/posts')
|
||||
const row = page.locator('.admin-posts__row').filter({ hasText: '댓글 화면 검증 게시물' })
|
||||
await expect(row.locator('.admin-posts__comment-count')).toHaveText('11')
|
||||
} finally {
|
||||
if (postId) await database`DELETE FROM posts WHERE id = ${postId}`
|
||||
await database.end()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user