Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions commitlint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ module.exports = {
"search",
"stats",
"friend",
"social",
"notify",
"infra",
"db",
Expand Down Expand Up @@ -123,6 +124,9 @@ module.exports = {
friend: {
description: '👥 친구 도메인 (예: 친구 신청, 수락, 목록 관리)'
},
social: {
description: '💬 소셜 도메인 (예: 댓글, 좋아요, 피드 공유)'
},
notify: {
description: '📧 알림/이메일 전송 (예: 질문 알림, 인증 이메일 전송)'
},
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.devkor.ifive.nadab.domain.comment.api.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

@Schema(description = "댓글 작성 요청")
public record CreateCommentRequest(

@Schema(description = "리포트 ID")
@NotNull(message = "리포트 ID를 입력해주세요")
Long dailyReportId,

@Schema(description = "댓글 내용 (1~500자)", example = "공감해요!")
@NotBlank(message = "댓글 내용을 입력해주세요")
@Size(max = 500, message = "댓글은 500자 이하로 입력해주세요")
String content,

@Schema(description = "비밀 댓글 여부", example = "false")
boolean isSecret
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.devkor.ifive.nadab.domain.comment.api.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

@Schema(description = "대댓글 작성 요청")
public record CreateSubCommentRequest(

@Schema(description = "댓글 내용 (1~500자)", example = "공감해요!")
@NotBlank(message = "댓글 내용을 입력해주세요")
@Size(max = 500, message = "댓글은 500자 이하로 입력해주세요")
String content,

@Schema(description = "비밀 댓글 여부", example = "false")
boolean isSecret
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.devkor.ifive.nadab.domain.comment.api.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

@Schema(description = "댓글 수정 요청")
public record UpdateCommentRequest(

@Schema(description = "수정할 댓글 내용 (1~500자)", example = "수정된 내용이에요")
@NotBlank(message = "댓글 내용을 입력해주세요")
@Size(max = 500, message = "댓글은 500자 이하로 입력해주세요")
String content
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.devkor.ifive.nadab.domain.comment.api.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;

import java.util.List;

@Schema(description = "댓글/대댓글 목록 응답")
public record CommentListResponse(

@Schema(description = "댓글 목록")
List<CommentResponse> comments,

@Schema(description = "다음 페이지 커서 (없으면 null)")
Long nextCursor,

@Schema(description = "다음 페이지 존재 여부")
boolean hasNext
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.devkor.ifive.nadab.domain.comment.api.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;

import java.time.OffsetDateTime;

@Schema(description = "댓글/대댓글 응답")
public record CommentResponse(

@Schema(description = "댓글 ID")
Long commentId,

@Schema(description = "작성자 프로필 이미지 URL (canViewContent=false이면 null)")
String authorProfileImageUrl,

@Schema(description = "작성자 닉네임 (canViewContent=false이면 null)")
String authorNickname,

@Schema(description = "댓글 내용 (canViewContent=false이면 null)")
String content,

@Schema(description = "작성 시각 (ISO 8601, 예: 2024-05-11T10:30:00+09:00) — 프론트에서 현재 시각 기준으로 '3분 전' 등으로 변환하여 표시")
OffsetDateTime createdAt,

@Schema(description = "내가 좋아요 눌렀는지 여부")
boolean isLiked,

@Schema(description = "좋아요가 1개 이상인지 여부")
boolean hasLikes,

@Schema(description = "보이는 대댓글 수 (canViewContent=false이거나 대댓글에서는 null)")
Integer visibleSubCommentCount,

@Schema(description = "비밀 댓글 여부")
boolean isSecret,

@Schema(description = "비밀 댓글 열람 권한 여부 (false이면 authorProfileImageUrl·authorNickname·content가 null)")
boolean canViewContent,

@Schema(description = "내 댓글 여부")
boolean isMine,

@Schema(description = "삭제 가능 여부 (본인 또는 리포트 당사자)")
boolean canDelete
) {
public static CommentResponse from(
Long commentId,
String authorProfileImageUrl,
String authorNickname,
String content,
OffsetDateTime createdAt,
boolean isLiked,
boolean hasLikes,
Integer visibleSubCommentCount,
boolean isSecret,
boolean canViewContent,
boolean isMine,
boolean canDelete
) {
return new CommentResponse(
commentId,
authorProfileImageUrl,
authorNickname,
content,
createdAt,
isLiked,
hasLikes,
visibleSubCommentCount,
isSecret,
canViewContent,
isMine,
canDelete
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package com.devkor.ifive.nadab.domain.comment.application;

import com.devkor.ifive.nadab.domain.comment.application.event.CommentCreatedEvent;
import com.devkor.ifive.nadab.domain.comment.application.event.SubCommentCreatedEvent;
import com.devkor.ifive.nadab.domain.comment.core.entity.Comment;
import com.devkor.ifive.nadab.domain.comment.core.repository.CommentRepository;
import com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReport;
import com.devkor.ifive.nadab.domain.dailyreport.core.repository.DailyReportRepository;
import com.devkor.ifive.nadab.domain.friend.core.repository.FriendshipRepository;
import com.devkor.ifive.nadab.domain.moderation.application.SharingSuspensionService;
import com.devkor.ifive.nadab.domain.user.core.entity.User;
import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository;
import com.devkor.ifive.nadab.global.core.response.ErrorCode;
import com.devkor.ifive.nadab.global.exception.BadRequestException;
import com.devkor.ifive.nadab.global.exception.ConflictException;
import com.devkor.ifive.nadab.global.exception.ForbiddenException;
import com.devkor.ifive.nadab.global.exception.NotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.OffsetDateTime;

@Service
@RequiredArgsConstructor
@Transactional
public class CommentCommandService {

private final CommentRepository commentRepository;
private final DailyReportRepository dailyReportRepository;
private final FriendshipRepository friendshipRepository;
private final UserRepository userRepository;
private final ApplicationEventPublisher eventPublisher;
private final SharingSuspensionService sharingSuspensionService;

public Long createComment(Long dailyReportId, Long authorId, String content, boolean isSecret) {
checkNotSuspended(authorId);
Long reportOwnerId = dailyReportRepository.findReportOwnerIdById(dailyReportId)
.orElseThrow(() -> new NotFoundException(ErrorCode.DAILY_REPORT_NOT_FOUND));
checkCommentWriteAccess(dailyReportId, reportOwnerId, authorId);

DailyReport dailyReport = dailyReportRepository.getReferenceById(dailyReportId);
User author = userRepository.getReferenceById(authorId);

Comment comment = Comment.createTopLevel(dailyReport, author, content, isSecret);
commentRepository.save(comment);

eventPublisher.publishEvent(
new CommentCreatedEvent(comment.getId(), dailyReportId, authorId, reportOwnerId, content));

return comment.getId();
}

public Long createSubComment(Long parentCommentId, Long authorId, String content, boolean isSecret) {
checkNotSuspended(authorId);
Comment parentComment = findActiveCommentOrThrow(parentCommentId);

if (!parentComment.isTopLevel()) {
throw new BadRequestException(ErrorCode.COMMENT_NOT_TOP_LEVEL);
}

// 비밀 댓글의 하위 대댓글은 강제 비밀 처리
boolean finalIsSecret = parentComment.isSecret() || isSecret;

Long dailyReportId = parentComment.getDailyReport().getId();
Long reportOwnerId = dailyReportRepository.findReportOwnerIdById(dailyReportId)
.orElseThrow(() -> new NotFoundException(ErrorCode.DAILY_REPORT_NOT_FOUND));
checkCommentWriteAccess(dailyReportId, reportOwnerId, authorId);

if (parentComment.isSecret()) {
boolean canViewParent = parentComment.getAuthor().getId().equals(authorId)
|| reportOwnerId.equals(authorId);
if (!canViewParent) {
throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED);
}
}

User author = userRepository.getReferenceById(authorId);

Comment subComment = Comment.createSubComment(author, parentComment, content, finalIsSecret);
commentRepository.save(subComment);
eventPublisher.publishEvent(new SubCommentCreatedEvent(
subComment.getId(),
dailyReportId,
authorId,
parentCommentId,
parentComment.getAuthor().getId(),
reportOwnerId,
content
));

return subComment.getId();
}

private void checkNotSuspended(Long userId) {
if (sharingSuspensionService.isSharingSuspended(userId)) {
throw new BadRequestException(ErrorCode.SOCIAL_SUSPENDED);
}
}

private void checkCommentWriteAccess(Long dailyReportId, Long reportOwnerId, Long currentUserId) {
if (currentUserId.equals(reportOwnerId)) return;
if (!dailyReportRepository.existsByIdAndIsSharedTrue(dailyReportId)) {
throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED);
}
long smallerId = Math.min(currentUserId, reportOwnerId);
long largerId = Math.max(currentUserId, reportOwnerId);
if (!friendshipRepository.existsAcceptedByUserIds(smallerId, largerId)) {
throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED);
}
}

public void updateComment(Long commentId, Long userId, String content) {
checkNotSuspended(userId);
Comment comment = findActiveCommentOrThrow(commentId);

if (!comment.getAuthor().getId().equals(userId)) {
throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED);
}

comment.updateContent(content);
}

public void deleteComment(Long commentId, Long userId) {
checkNotSuspended(userId);
Comment comment = findActiveCommentOrThrow(commentId);

Long authorId = comment.getAuthor().getId();
Long reportOwnerId = dailyReportRepository.findReportOwnerIdById(comment.getDailyReport().getId())
.orElseThrow(() -> new NotFoundException(ErrorCode.DAILY_REPORT_NOT_FOUND));

if (!userId.equals(authorId) && !userId.equals(reportOwnerId)) {
throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED);
}

OffsetDateTime now = OffsetDateTime.now();
if (comment.isTopLevel()) {
commentRepository.softDeleteSubCommentsByParentId(commentId, now);
}
comment.softDelete();
}

private Comment findActiveCommentOrThrow(Long commentId) {
return commentRepository.findByIdWithAuthorAndDailyReport(commentId)
.orElseThrow(() -> commentRepository.existsById(commentId)
? new ConflictException(ErrorCode.COMMENT_DELETED)
: new NotFoundException(ErrorCode.COMMENT_NOT_FOUND));
}
}
Loading
Loading