From b82aa89686ce0675d6c567cf746a06f3984b5c7e Mon Sep 17 00:00:00 2001 From: ikae Date: Tue, 1 Sep 2026 20:02:23 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=EB=8C=93=EA=B8=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95(PUT=20/api/v1/comments/{id})=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EB=B0=8F=20=EB=8B=A8=EC=9C=84/=ED=86=B5=ED=95=A9=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../comment/controller/CommentController.java | 10 + .../comment/dto/CommentUpdateRequest.java | 10 + .../comment/dto/CommentUpdateResponse.java | 5 + .../domain/comment/entity/Comment.java | 4 + .../comment/service/CommentService.java | 50 ++++ .../comment/service/CommentUpdateTest.java | 277 ++++++++++++++++++ docs/project/work.md | 25 ++ 7 files changed, 381 insertions(+) create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentUpdateRequest.java create mode 100644 backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentUpdateResponse.java create mode 100644 backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentUpdateTest.java diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/controller/CommentController.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/controller/CommentController.java index 142cd66..3c802a1 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/comment/controller/CommentController.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/controller/CommentController.java @@ -54,6 +54,16 @@ public ResponseEntity getCommentReplies( return ResponseEntity.ok(commentService.getCommentReplies(commentId, cursor, size)); } + @PutMapping("/comments/{commentId}") + public ResponseEntity updateComment( + @PathVariable Long commentId, + @Valid @RequestBody CommentUpdateRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + CommentUpdateResponse response = + commentService.updateComment(commentId, request, userDetails); + return ResponseEntity.ok(response); + } + @DeleteMapping("/comments/{commentId}") public ResponseEntity> deleteComment( @PathVariable Long commentId, diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentUpdateRequest.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentUpdateRequest.java new file mode 100644 index 0000000..cc9575b --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentUpdateRequest.java @@ -0,0 +1,10 @@ +package com.ikae.snowthing.domain.comment.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CommentUpdateRequest( + @NotBlank(message = "댓글 내용은 필수 입력값입니다.") + @Size(max = 1000, message = "댓글은 최대 1000자까지 입력 가능합니다.") + String content, + String anonymousPassword) {} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentUpdateResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentUpdateResponse.java new file mode 100644 index 0000000..3313352 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentUpdateResponse.java @@ -0,0 +1,5 @@ +package com.ikae.snowthing.domain.comment.dto; + +import java.time.LocalDateTime; + +public record CommentUpdateResponse(Long commentId, String content, LocalDateTime updatedAt) {} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/entity/Comment.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/entity/Comment.java index e82b92e..3545c8a 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/comment/entity/Comment.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/entity/Comment.java @@ -104,4 +104,8 @@ public void softDelete() { this.isDeleted = true; this.deletedAt = LocalDateTime.now(); } + + public void updateContent(String newContent) { + this.content = newContent; + } } diff --git a/backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentService.java b/backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentService.java index 58c1c24..d72a19b 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentService.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentService.java @@ -212,6 +212,56 @@ private void validateReadSize(int size) { } } + @Transactional + public CommentUpdateResponse updateComment( + Long commentId, CommentUpdateRequest request, CustomUserDetails userDetails) { + Comment comment = + commentRepository + .findById(commentId) + .orElseThrow(() -> new CustomAuthException(ErrorCode.COMMENT_NOT_FOUND)); + + if (comment.isDeleted()) { + throw new CustomAuthException(ErrorCode.COMMENT_NOT_FOUND); + } + + validateUpdatePermission(comment, request.anonymousPassword(), userDetails); + + comment.updateContent(request.content()); + + return new CommentUpdateResponse( + comment.getId(), comment.getContent(), comment.getUpdatedAt()); + } + + private void validateUpdatePermission( + Comment comment, String anonymousPassword, CustomUserDetails userDetails) { + if (comment.isAnonymous()) { + if (userDetails != null + && comment.getMember() != null + && comment.getMember().getPublicId().equals(userDetails.getPublicId())) { + return; + } + + if (anonymousPassword == null + || !passwordEncoder.matches( + anonymousPassword, comment.getAnonymousPassword())) { + throw new CustomAuthException(ErrorCode.INVALID_ANON_PASSWORD); + } + return; + } + + if (userDetails == null) { + throw new CustomAuthException(ErrorCode.ACCESS_DENIED); + } + + boolean isWriter = + comment.getMember() != null + && comment.getMember().getPublicId().equals(userDetails.getPublicId()); + + if (!isWriter) { + throw new CustomAuthException(ErrorCode.ACCESS_DENIED); + } + } + @Transactional public void deleteComment( Long commentId, String anonymousPassword, CustomUserDetails userDetails) { diff --git a/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentUpdateTest.java b/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentUpdateTest.java new file mode 100644 index 0000000..a4b7b62 --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentUpdateTest.java @@ -0,0 +1,277 @@ +package com.ikae.snowthing.domain.comment.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.UUID; + +import jakarta.persistence.EntityManager; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.annotation.Transactional; + +import com.ikae.snowthing.domain.comment.dto.CommentCreateRequest; +import com.ikae.snowthing.domain.comment.dto.CommentResponse; +import com.ikae.snowthing.domain.comment.dto.CommentUpdateRequest; +import com.ikae.snowthing.domain.comment.dto.CommentUpdateResponse; +import com.ikae.snowthing.domain.comment.entity.Comment; +import com.ikae.snowthing.domain.comment.repository.CommentRepository; +import com.ikae.snowthing.domain.member.entity.Member; +import com.ikae.snowthing.domain.member.entity.MemberStatus; +import com.ikae.snowthing.domain.member.entity.Role; +import com.ikae.snowthing.domain.member.repository.MemberRepository; +import com.ikae.snowthing.domain.post.dto.PostCreateRequest; +import com.ikae.snowthing.domain.post.dto.PostResponse; +import com.ikae.snowthing.domain.post.entity.PostCategory; +import com.ikae.snowthing.domain.post.repository.PostCategoryRepository; +import com.ikae.snowthing.domain.post.service.PostService; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomAuthException; +import com.ikae.snowthing.global.security.CustomUserDetails; + +@SpringBootTest +@Transactional +class CommentUpdateTest { + + @DynamicPropertySource + static void useRealMySql(DynamicPropertyRegistry registry) { + String testDbUrl = System.getenv("SNOWTHING_TEST_DB_URL"); + if (testDbUrl == null || testDbUrl.isBlank()) { + return; + } + registry.add("spring.datasource.url", () -> testDbUrl); + registry.add( + "spring.datasource.username", + () -> requiredEnvironmentVariable("SNOWTHING_TEST_DB_USERNAME")); + registry.add( + "spring.datasource.password", + () -> requiredEnvironmentVariable("SNOWTHING_TEST_DB_PASSWORD")); + registry.add("spring.datasource.driver-class-name", () -> "com.mysql.cj.jdbc.Driver"); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.MySQLDialect"); + registry.add( + "spring.jpa.properties.hibernate.dialect", + () -> "org.hibernate.dialect.MySQLDialect"); + } + + private static String requiredEnvironmentVariable(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) { + throw new CustomAuthException(ErrorCode.INVALID_INPUT); + } + return value; + } + + @Autowired private CommentService commentService; + @Autowired private CommentRepository commentRepository; + @Autowired private PostService postService; + @Autowired private MemberRepository memberRepository; + @Autowired private PostCategoryRepository categoryRepository; + @Autowired private PasswordEncoder passwordEncoder; + @Autowired private EntityManager entityManager; + + private CustomUserDetails writerDetails; + private CustomUserDetails otherDetails; + private PostResponse postResponse; + + @BeforeEach + void setUp() { + String fixtureId = UUID.randomUUID().toString().substring(0, 8); + categoryRepository + .findByCode("FREE") + .orElseGet(() -> categoryRepository.save(new PostCategory("자유게시판", "FREE"))); + + Member writer = + memberRepository.save( + new Member( + null, + "update-writer-" + fixtureId + "@example.com", + passwordEncoder.encode("Password123!"), + "수정작성자-" + fixtureId, + null, + null, + null, + null, + null, + Role.ROLE_USER, + MemberStatus.ACTIVE)); + writerDetails = new CustomUserDetails(writer); + + Member other = + memberRepository.save( + new Member( + null, + "update-other-" + fixtureId + "@example.com", + passwordEncoder.encode("Password123!"), + "타인회원-" + fixtureId, + null, + null, + null, + null, + null, + Role.ROLE_USER, + MemberStatus.ACTIVE)); + otherDetails = new CustomUserDetails(other); + + postResponse = + postService.createPost( + new PostCreateRequest( + "FREE", "수정 테스트 게시글", "게시글 본문", false, null, List.of()), + writerDetails, + "127.0.0.1"); + } + + @Nested + @DisplayName("성공 케이스") + class SuccessCase { + + @Test + @DisplayName("[성공 1] 일반 회원 본인 댓글 수정 성공") + void updateOwnCommentAsMember() { + CommentResponse created = createMemberComment("수정 전 내용"); + + CommentUpdateResponse updated = + commentService.updateComment( + created.commentId(), + new CommentUpdateRequest("수정 후 내용", null), + writerDetails); + + assertThat(updated.commentId()).isEqualTo(created.commentId()); + assertThat(updated.content()).isEqualTo("수정 후 내용"); + assertThat(updated.updatedAt()).isNotNull(); + + entityManager.flush(); + entityManager.clear(); + Comment savedComment = commentRepository.findById(created.commentId()).orElseThrow(); + assertThat(savedComment.getContent()).isEqualTo("수정 후 내용"); + } + + @Test + @DisplayName("[성공 2] 비회원 익명 댓글 올바른 비밀번호 입력 시 수정 성공") + void updateAnonymousCommentWithCorrectPassword() { + CommentResponse created = createGuestAnonymousComment("익명 수정 전", "mypass1234"); + + CommentUpdateResponse updated = + commentService.updateComment( + created.commentId(), + new CommentUpdateRequest("익명 수정 후", "mypass1234"), + null); + + assertThat(updated.content()).isEqualTo("익명 수정 후"); + + entityManager.flush(); + entityManager.clear(); + Comment savedComment = commentRepository.findById(created.commentId()).orElseThrow(); + assertThat(savedComment.getContent()).isEqualTo("익명 수정 후"); + } + } + + @Nested + @DisplayName("실패 케이스") + class FailureCase { + + @Test + @DisplayName("[실패 1] 로그인 회원이 타인의 댓글 수정 시도 시 ACCESS_DENIED (403)") + void rejectUpdateByOtherMember() { + CommentResponse created = createMemberComment("원본 댓글"); + + assertThatThrownBy( + () -> + commentService.updateComment( + created.commentId(), + new CommentUpdateRequest("타인이 수정", null), + otherDetails)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.ACCESS_DENIED); + } + + @Test + @DisplayName("[실패 2] 비회원 익명 댓글에 잘못된 비밀번호 입력 시 INVALID_ANON_PASSWORD (403)") + void rejectUpdateWithWrongPassword() { + CommentResponse created = createGuestAnonymousComment("익명 원본", "correct1234"); + + assertThatThrownBy( + () -> + commentService.updateComment( + created.commentId(), + new CommentUpdateRequest("수정 시도", "wrong9999"), + null)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_ANON_PASSWORD); + } + + @Test + @DisplayName("[실패 3] 이미 Soft Delete된 댓글 수정 시도 시 COMMENT_NOT_FOUND (404)") + void rejectUpdateOnDeletedComment() { + CommentResponse created = createMemberComment("삭제할 댓글"); + commentService.deleteComment(created.commentId(), null, writerDetails); + + assertThatThrownBy( + () -> + commentService.updateComment( + created.commentId(), + new CommentUpdateRequest("삭제 후 수정", null), + writerDetails)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.COMMENT_NOT_FOUND); + } + + @Test + @DisplayName("[실패 4] 존재하지 않는 댓글 ID로 수정 시도 시 COMMENT_NOT_FOUND (404)") + void rejectUpdateOnNonExistentComment() { + assertThatThrownBy( + () -> + commentService.updateComment( + Long.MAX_VALUE, + new CommentUpdateRequest("수정 시도", null), + writerDetails)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.COMMENT_NOT_FOUND); + } + + @Test + @DisplayName("[실패 5] 비회원 익명 댓글에 비밀번호 누락 시 INVALID_ANON_PASSWORD (403)") + void rejectUpdateWithNullPassword() { + CommentResponse created = createGuestAnonymousComment("익명 원본", "pass1234"); + + assertThatThrownBy( + () -> + commentService.updateComment( + created.commentId(), + new CommentUpdateRequest("비밀번호 없이 수정", null), + null)) + .isInstanceOf(CustomAuthException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_ANON_PASSWORD); + } + } + + private CommentResponse createMemberComment(String content) { + return commentService.createComment( + postResponse.publicId(), + new CommentCreateRequest(null, content, false, null), + writerDetails, + "127.0.0.1"); + } + + private CommentResponse createGuestAnonymousComment(String content, String password) { + return commentService.createComment( + postResponse.publicId(), + new CommentCreateRequest(null, content, true, password), + null, + "127.0.0.1"); + } +} diff --git a/docs/project/work.md b/docs/project/work.md index c481049..3a2798e 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -1,3 +1,28 @@ +- **Sprint 03 댓글 수정(PUT /api/v1/comments/{commentId}) 기능 및 테스트 전담 개발 완결 (2026-09-01)**: + 1. **작업명**: 댓글 수정(Update) 기능 구현 및 권한/유효성 검증 테스트 + 2. **현재 상태**: 완료 + 3. **완료된 항목**: + - `CommentUpdateRequest.java` DTO 신설 (`content` @NotBlank/@Size(max=1000), `anonymousPassword` 선택). + - `CommentUpdateResponse.java` DTO 신설 (`commentId`, `content`, `updatedAt`). + - `Comment.java` 엔티티 내 본문 갱신용 `updateContent(String newContent)` 더티 체킹 메서드 추가. + - `CommentService.java` 내 `updateComment` 및 `validateUpdatePermission` 구현 (수정 권한은 오직 작성자 본인만 가능하도록 관리자 우회 제외). + - `CommentController.java` 내 `PUT /api/v1/comments/{commentId}` 엔드포인트 연동. + - `CommentUpdateTest.java` 단위/통합 테스트 7건 작성 (성공 2건 + 실패 5건). + 4. **남은 항목**: 없음 (Update 전담 완료) + 5. **발견된 이슈 및 사용자 결정**: + - 이슈: 삭제(DELETE)와 달리 수정(PUT) 작업 시 관리자(`ROLE_ADMIN`)의 타인 댓글 본문 수정 허용 여부 정책 확인 필요. + - 사용자 결정: 수정은 오직 작성자 본인만 가능하도록 확정 (`validateUpdatePermission`에 관리자 우회 로직 배제). + 6. **검증 결과**: + - `spotlessApply` 서식 포맷팅 완료. + - `gradle test --tests "*CommentUpdateTest*"` 총 7개 테스트 케이스 100% PASS (BUILD SUCCESSFUL in 18s). + - [성공 1] 일반 회원 본인 댓글 수정 성공 + - [성공 2] 비회원 익명 댓글 올바른 비밀번호 입력 시 수정 성공 + - [실패 1] 로그인 회원이 타인 댓글 수정 시도 시 ACCESS_DENIED (403) + - [실패 2] 비회원 익명 댓글에 잘못된 비밀번호 입력 시 INVALID_ANON_PASSWORD (403) + - [실패 3] 이미 Soft Delete된 댓글 수정 시도 시 COMMENT_NOT_FOUND (404) + - [실패 4] 존재하지 않는 댓글 ID로 수정 시도 시 COMMENT_NOT_FOUND (404) + - [실패 5] 비회원 익명 댓글에 비밀번호 누락 시 INVALID_ANON_PASSWORD (403) + - **Sprint 03 댓글 도메인 공식 API 명세서(comment_api_spec.md) 작성 (2026-09-01)**: 1. **5대 CRUD 엔드포인트 계약 명세화**: `docs/conception/sprint03/comment_api_spec.md`에 댓글 작성(`POST`), 루트 댓글 Batch+Top-5 프리뷰 조회(`GET`), 대댓글 분리 페이징 조회(`GET`), 댓글 수정(`PUT`), Soft Delete 삭제(`DELETE`)의 Request/Response DTO, Header, 에러 코드 매핑을 100% 명세화. From 3c624c65b565f913494e64cb07706185f39ebce8 Mon Sep 17 00:00:00 2001 From: ikae Date: Tue, 1 Sep 2026 20:51:27 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(comment):=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=9D=B8=EB=9D=BC=EC=9D=B8=20=EC=88=98=EC=A0=95=20UI=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/project/work.md | 29 +++ frontend/app/posts/[publicId]/page.tsx | 307 +++++++++++++++++++++++-- 2 files changed, 318 insertions(+), 18 deletions(-) diff --git a/docs/project/work.md b/docs/project/work.md index 3a2798e..83b4bc6 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -752,3 +752,32 @@ 6. 댓글·대댓글 응답 병합 시 `commentId` 중복을 방어하고, 삭제된 루트 placeholder 아래의 대댓글과 답글 작성 기능은 유지. 7. 검증 결과: 변경 파일 대상 ESLint 오류 0건(기존 `` 최적화 경고 1건), `npm run build` 및 TypeScript 검사 통과. 8. 확인 이슈: 전체 `npm run lint`는 이번 변경과 무관한 기존 `ToastEditor.tsx`, `ToastViewer.tsx`, 게시글 작성·목록 페이지의 오류 6건 때문에 실패. 브라우저 수동 검증은 백엔드와 테스트 데이터가 실행된 환경에서 추가 확인 필요. + +## Sprint 03 댓글 수정 프론트엔드 UI + +- 상태: DONE +- 시작일: 2026-09-01 + +### 계획 +- 루트 댓글과 대댓글에 한 번에 하나만 열리는 인라인 수정 폼을 적용한다. +- 일반 회원 댓글은 `writer.publicId`가 현재 사용자와 같을 때 수정 버튼을 노출한다. +- 익명 댓글은 소유권 응답 필드가 없어 버튼 노출 후 세션 또는 비밀번호를 서버에서 최종 검증하는 방안 A를 적용한다. +- 삭제 UI 변경은 `feature/sprint03-comment-d`로 분리하고 이 브랜치에는 포함하지 않는다. + +### 완료 +- 공백, 1,000자 제한, 변경 없음, 비로그인 익명 비밀번호를 검증하는 인라인 수정 폼 구현. +- PUT 성공 시 루트 또는 대댓글의 해당 `commentId` 본문만 불변 업데이트하도록 구현. +- 답글 작성 폼과 수정 폼이 동시에 열리지 않도록 수정 시작 시 답글 상태 초기화. + +### 남은 작업 +- 백엔드 Update API와 실제 브라우저 통합 검증. + +### 이슈 +- 익명 댓글 응답에 `canEdit`, `requiresPassword`가 없어 프론트만으로 정확한 소유권 버튼 노출은 불가능하다. + +### 결정 필요 +- 방안 A 적용을 사용자 승인받았으며 서버를 최종 권한 검증 주체로 사용한다. + +### 검증 +- 변경 파일 대상 ESLint 오류 0건. 기존 게시글 이미지 `` 최적화 경고 1건만 확인. +- `npm run build` 성공 및 TypeScript 오류 0건 확인. diff --git a/frontend/app/posts/[publicId]/page.tsx b/frontend/app/posts/[publicId]/page.tsx index 3fecf1a..76af083 100644 --- a/frontend/app/posts/[publicId]/page.tsx +++ b/frontend/app/posts/[publicId]/page.tsx @@ -73,6 +73,12 @@ interface ReplyPagingState { loading: boolean; } +interface CommentUpdateResponse { + commentId: number; + content: string; + updatedAt: string; +} + export default function PostDetailPage({ params }: { params: Promise<{ publicId: string }> }) { const router = useRouter(); const { publicId } = use(params); @@ -93,6 +99,11 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: const [replyMentionName, setReplyMentionName] = useState(null); const [replyText, setReplyText] = useState(""); const [replyAnonPassword, setReplyAnonPassword] = useState(""); + const [activeEditCommentId, setActiveEditCommentId] = useState(null); + const [editCommentText, setEditCommentText] = useState(""); + const [editCommentPassword, setEditCommentPassword] = useState(""); + const [editCommentError, setEditCommentError] = useState(""); + const [submittingEditComment, setSubmittingEditComment] = useState(false); const [currentUserPublicId, setCurrentUserPublicId] = useState(null); const [isAdmin, setIsAdmin] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -389,6 +400,72 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: } }; + const handleStartEditComment = (comment: CommentItem) => { + setActiveReplyParentId(null); + setReplyMentionName(null); + setActiveEditCommentId(comment.commentId); + setEditCommentText(comment.content); + setEditCommentPassword(""); + setEditCommentError(""); + }; + + const handleCancelEditComment = () => { + if (submittingEditComment) return; + setActiveEditCommentId(null); + setEditCommentText(""); + setEditCommentPassword(""); + setEditCommentError(""); + }; + + const handleUpdateComment = async (comment: CommentItem) => { + const content = editCommentText.trim(); + const requiresPassword = comment.isAnonymous && !currentUserPublicId; + if (!content) { + setEditCommentError("댓글 내용을 입력해주세요."); + return; + } + if (content.length > 1000) { + setEditCommentError("댓글은 1,000자 이하로 입력해주세요."); + return; + } + if (requiresPassword && !editCommentPassword.trim()) { + setEditCommentError("익명 댓글 비밀번호를 입력해주세요."); + return; + } + if (content === comment.content) { + setEditCommentError("변경된 내용이 없습니다."); + return; + } + + setSubmittingEditComment(true); + setEditCommentError(""); + try { + const res = await csrfFetch(API_ENDPOINTS.comments.delete(comment.commentId), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + content, + anonymousPassword: editCommentPassword.trim() || null, + }), + }); + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.message || "댓글 수정에 실패했습니다."); + } + + const updated: CommentUpdateResponse = await res.json(); + setComments((current) => updateCommentContent(current, updated.commentId, updated.content)); + setActiveEditCommentId(null); + setEditCommentText(""); + setEditCommentPassword(""); + setEditCommentError(""); + } catch (error) { + setEditCommentError(error instanceof Error ? error.message : "서버 통신 중 오류가 발생했습니다."); + } finally { + setSubmittingEditComment(false); + } + }; + const handleDeleteComment = async (commentId: number, isAnonymousWriter: boolean) => { let anonymousPassword = ""; if (isAnonymousWriter) { @@ -403,9 +480,7 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: const res = await csrfFetch(API_ENDPOINTS.comments.delete(commentId), { method: "DELETE", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - anonymousPassword: anonymousPassword || null, - }), + body: JSON.stringify({ anonymousPassword: anonymousPassword || null }), }); if (res.ok) { await fetchComments(); @@ -575,6 +650,16 @@ export default function PostDetailPage({ params }: { params: Promise<{ publicId: replyAnonPassword={replyAnonPassword} setReplyAnonPassword={setReplyAnonPassword} handleCreateComment={handleCreateComment} + activeEditCommentId={activeEditCommentId} + editCommentText={editCommentText} + setEditCommentText={setEditCommentText} + editCommentPassword={editCommentPassword} + setEditCommentPassword={setEditCommentPassword} + editCommentError={editCommentError} + submittingEditComment={submittingEditComment} + handleStartEditComment={handleStartEditComment} + handleCancelEditComment={handleCancelEditComment} + handleUpdateComment={handleUpdateComment} handleDeleteComment={handleDeleteComment} handleLoadMoreReplies={handleLoadMoreReplies} isLoadingReplies={Boolean(replyPagingByRootId[comment.commentId]?.loading)} @@ -623,6 +708,16 @@ function CommentRow({ replyAnonPassword, setReplyAnonPassword, handleCreateComment, + activeEditCommentId, + editCommentText, + setEditCommentText, + editCommentPassword, + setEditCommentPassword, + editCommentError, + submittingEditComment, + handleStartEditComment, + handleCancelEditComment, + handleUpdateComment, handleDeleteComment, handleLoadMoreReplies, isLoadingReplies, @@ -639,10 +734,22 @@ function CommentRow({ replyAnonPassword: string; setReplyAnonPassword: (value: string) => void; handleCreateComment: (parentId: number | null) => Promise; + activeEditCommentId: number | null; + editCommentText: string; + setEditCommentText: (text: string) => void; + editCommentPassword: string; + setEditCommentPassword: (password: string) => void; + editCommentError: string; + submittingEditComment: boolean; + handleStartEditComment: (comment: CommentItem) => void; + handleCancelEditComment: () => void; + handleUpdateComment: (comment: CommentItem) => Promise; handleDeleteComment: (commentId: number, isAnonymousWriter: boolean) => Promise; handleLoadMoreReplies: (rootCommentId: number) => Promise; isLoadingReplies: boolean; }) { + const canEdit = canEditComment(item, currentUserPublicId); + const isEditing = activeEditCommentId === item.commentId; const openReplyEditor = (target: CommentItem) => { if (activeReplyParentId === item.commentId && replyMentionName === getWriterName(target)) { setActiveReplyParentId(null); @@ -660,18 +767,39 @@ function CommentRow({ {getWriterName(item)} {new Date(item.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} -

{item.content}

- -
- - {!item.isDeleted && ( - - )} -
+ {isEditing ? ( + void handleUpdateComment(item)} + /> + ) : ( + <> +

{item.content}

+
+ + {canEdit && ( + + )} + {!item.isDeleted && ( + + )} +
+ + )} {item.previewReplies.length > 0 && (
@@ -680,6 +808,17 @@ function CommentRow({ key={reply.commentId} item={reply} onReply={() => openReplyEditor(reply)} + isEditing={activeEditCommentId === reply.commentId} + editCommentText={editCommentText} + setEditCommentText={setEditCommentText} + editCommentPassword={editCommentPassword} + setEditCommentPassword={setEditCommentPassword} + editCommentError={editCommentError} + submittingEditComment={submittingEditComment} + handleStartEditComment={handleStartEditComment} + handleCancelEditComment={handleCancelEditComment} + handleUpdateComment={handleUpdateComment} + currentUserPublicId={currentUserPublicId} handleDeleteComment={handleDeleteComment} /> ))} @@ -748,12 +887,36 @@ function CommentRow({ function ReplyRow({ item, onReply, + currentUserPublicId, + isEditing, + editCommentText, + setEditCommentText, + editCommentPassword, + setEditCommentPassword, + editCommentError, + submittingEditComment, + handleStartEditComment, + handleCancelEditComment, + handleUpdateComment, handleDeleteComment, }: { item: CommentItem; onReply: () => void; + currentUserPublicId: string | null; + isEditing: boolean; + editCommentText: string; + setEditCommentText: (text: string) => void; + editCommentPassword: string; + setEditCommentPassword: (password: string) => void; + editCommentError: string; + submittingEditComment: boolean; + handleStartEditComment: (comment: CommentItem) => void; + handleCancelEditComment: () => void; + handleUpdateComment: (comment: CommentItem) => Promise; handleDeleteComment: (commentId: number, isAnonymousWriter: boolean) => Promise; }) { + const canEdit = canEditComment(item, currentUserPublicId); + return (
@@ -762,13 +925,33 @@ function ReplyRow({ {new Date(item.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
-

{item.content}

- {!item.isDeleted && ( + {isEditing ? ( + void handleUpdateComment(item)} + /> + ) : ( +

{item.content}

+ )} + {!item.isDeleted && !isEditing && (
- + )} +
@@ -777,7 +960,95 @@ function ReplyRow({ ); } +function CommentEditForm({ + comment, + content, + setContent, + password, + setPassword, + error, + submitting, + requiresPassword, + onCancel, + onSubmit, +}: { + comment: CommentItem; + content: string; + setContent: (content: string) => void; + password: string; + setPassword: (password: string) => void; + error: string; + submitting: boolean; + requiresPassword: boolean; + onCancel: () => void; + onSubmit: () => void; +}) { + const contentId = `comment-edit-content-${comment.commentId}`; + const passwordId = `comment-edit-password-${comment.commentId}`; + + return ( +
+ +