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
16 changes: 16 additions & 0 deletions src/main/java/com/dokdok/meeting/entity/Meeting.java
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ public class Meeting extends BaseTimeEntity {
@Column(name = "meeting_end_date")
private LocalDateTime meetingEndDate;

@Column(name = "retrospective_published")
@Builder.Default
private Boolean retrospectivePublished = false;

@Column(name = "retrospective_published_at")
private LocalDateTime retrospectivePublishedAt;

public static Meeting create(MeetingCreateRequest request, Gathering gathering, Book book, User user,
Integer maxParticipants) {
String meetingName = request.meetingName();
Expand Down Expand Up @@ -143,4 +150,13 @@ public String getFormattedTime() {
return meetingStartDate.format(formatter) + "-" + meetingEndDate.format(formatter);
}

public void publishRetrospective() {
this.retrospectivePublished = true;
this.retrospectivePublishedAt = LocalDateTime.now();
}

public boolean isRetrospectivePublished() {
return Boolean.TRUE.equals(this.retrospectivePublished);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;

@Tag(name = "AI 요약", description = "AI 요약 관련 API")
Expand Down Expand Up @@ -183,4 +184,52 @@ ResponseEntity<ApiResponse<RetrospectiveSummaryResponse>> updateRetrospectiveSum
@PathVariable Long meetingId,
@Valid @RequestBody RetrospectiveSummaryUpdateRequest request
);

@Operation(
summary = "약속 회고 생성 (developer: 오주현)",
description = """
약속 회고를 생성(퍼블리시)합니다.
- 권한: 모임장, 약속장
- 제약: 모임장 또는 약속장만 생성 가능
- 생성 후 모든 약속 참여자가 공동 회고를 조회할 수 있습니다.
""",
parameters = {
@Parameter(name = "meetingId", description = "약속 식별자", in = ParameterIn.PATH, required = true)
}
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "201",
description = "약속 회고 생성 성공",
content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = RetrospectiveSummaryResponse.class),
examples = @ExampleObject(value = """
{
"code": "CREATED",
"message": "약속 회고 생성 성공",
"data": {
"meetingId": 1,
"isPublished": true,
"publishedAt": "2026-02-21T15:00:00",
"topics": [...]
}
}
""")
)
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "접근 권한 없음",
content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE,
examples = @ExampleObject(value = """
{"code": "R105", "message": "회고에 접근할 권한이 없습니다.", "data": null}
"""))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "이미 생성됨",
content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE,
examples = @ExampleObject(value = """
{"code": "R108", "message": "이미 약속 회고가 생성되었습니다.", "data": null}
""")))
})
@PostMapping(value = "/publish", produces = MediaType.APPLICATION_JSON_VALUE)
ResponseEntity<ApiResponse<RetrospectiveSummaryResponse>> publishRetrospective(
@PathVariable Long meetingId
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,14 @@ public ResponseEntity<ApiResponse<RetrospectiveSummaryResponse>> updateRetrospec

return ApiResponse.success(response, "AI 요약 수정 성공");
}

@Override
@PostMapping("/publish")
public ResponseEntity<ApiResponse<RetrospectiveSummaryResponse>> publishRetrospective(
@PathVariable Long meetingId
) {
RetrospectiveSummaryResponse response = retrospectiveSummaryService.publishRetrospective(meetingId);

return ApiResponse.created(response, "약속 회고 생성 성공");
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package com.dokdok.retrospective.dto.response;

import com.dokdok.meeting.entity.Meeting;
import com.dokdok.retrospective.entity.TopicRetrospectiveSummary;
import com.dokdok.topic.entity.Topic;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;

import java.time.LocalDateTime;
import java.util.List;

@Schema(description = "AI 요약 조회 응답")
Expand All @@ -13,12 +15,20 @@ public record RetrospectiveSummaryResponse(
@Schema(description = "약속 ID", example = "1")
Long meetingId,

@Schema(description = "약속 회고 생성 여부", example = "false")
Boolean isPublished,

@Schema(description = "약속 회고 생성 일시", example = "2026-02-21T15:00:00")
LocalDateTime publishedAt,

@Schema(description = "토픽 목록")
List<TopicSummaryResponse> topics
) {
public static RetrospectiveSummaryResponse from(Long meetingId, List<TopicSummaryResponse> topics) {
public static RetrospectiveSummaryResponse from(Meeting meeting, List<TopicSummaryResponse> topics) {
return RetrospectiveSummaryResponse.builder()
.meetingId(meetingId)
.meetingId(meeting.getId())
.isPublished(meeting.isRetrospectivePublished())
.publishedAt(meeting.getRetrospectivePublishedAt())
.topics(topics)
.build();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ public enum RetrospectiveErrorCode implements BaseErrorCode {
RETROSPECTIVE_ALREADY_DELETED("R104", "이미 삭제된 개인 회고입니다.", HttpStatus.NOT_FOUND),
NO_ACCESS_RETROSPECTIVE("R105", "회고에 접근할 권한이 없습니다.", HttpStatus.FORBIDDEN),
SUMMARY_NOT_FOUND("R106", "AI 요약을 찾을 수 없습니다.", HttpStatus.NOT_FOUND),
NOT_AUTHOR_OF_RETROSPECTIVE("R107", "사용자가 작성한 회고가 아닙니다.", HttpStatus.FORBIDDEN);
NOT_AUTHOR_OF_RETROSPECTIVE("R107", "사용자가 작성한 회고가 아닙니다.", HttpStatus.FORBIDDEN),
RETROSPECTIVE_ALREADY_PUBLISHED("R108", "이미 약속 회고가 생성되었습니다.", HttpStatus.CONFLICT),
RETROSPECTIVE_NOT_PUBLISHED("R109", "약속 회고가 아직 생성되지 않았습니다.", HttpStatus.FORBIDDEN);


private final String code;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import com.dokdok.retrospective.exception.RetrospectiveErrorCode;
import com.dokdok.retrospective.exception.RetrospectiveException;
import com.dokdok.retrospective.repository.RetrospectiveRepository;
import com.dokdok.gathering.entity.GatheringRole;
import com.dokdok.retrospective.repository.TopicRetrospectiveSummaryRepository;
import com.dokdok.storage.service.StorageService;
import com.dokdok.topic.entity.Topic;
Expand Down Expand Up @@ -51,7 +52,12 @@ public MeetingRetrospectiveResponse getMeetingRetrospective(Long meetingId){

Meeting meeting = meetingValidator.findMeetingOrThrow(meetingId);

// 권한 검증
// 퍼블리시 여부 확인 (퍼블리시 후에만 접근 가능)
if (!meeting.isRetrospectivePublished()) {
throw new RetrospectiveException(RetrospectiveErrorCode.RETROSPECTIVE_NOT_PUBLISHED);
}

// 권한 검증 (약속 참여자 또는 모임장)
retrospectiveValidator.validateMeetingRetrospectiveAccess(
meeting.getGathering().getId(),
meetingId,
Expand Down Expand Up @@ -85,6 +91,11 @@ public CursorResponse<MeetingRetrospectiveResponse.CommentResponse, CommentCurso
Long userId = SecurityUtil.getCurrentUserId();
Meeting meeting = meetingValidator.findMeetingOrThrow(meetingId);

// 퍼블리시 여부 확인
if (!meeting.isRetrospectivePublished()) {
throw new RetrospectiveException(RetrospectiveErrorCode.RETROSPECTIVE_NOT_PUBLISHED);
}

retrospectiveValidator.validateMeetingRetrospectiveAccess(meeting.getGathering().getId(), meetingId, userId);

return fetchComments(meetingId, pageSize, cursorCreatedAt, cursorCommentId);
Expand All @@ -102,6 +113,11 @@ public MeetingRetrospectiveResponse.CommentResponse createMeetingRetrospective(
// Meeting 조회
Meeting meeting = meetingValidator.findMeetingOrThrow(meetingId);

// 퍼블리시 여부 확인
if (!meeting.isRetrospectivePublished()) {
throw new RetrospectiveException(RetrospectiveErrorCode.RETROSPECTIVE_NOT_PUBLISHED);
}

// 권한 검증
retrospectiveValidator.validateMeetingRetrospectiveAccess(meeting.getGathering().getId(),meetingId,userId);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ public RetrospectiveSummaryResponse getRetrospectiveSummary(Long meetingId) {

Meeting meeting = meetingValidator.findMeetingOrThrow(meetingId);

retrospectiveValidator.validateMeetingRetrospectiveAccess(meeting.getGathering().getId(), meetingId, userId);
// 모임장/약속장만 조회 가능
retrospectiveValidator.validateSummaryUpdatePermission(meeting.getGathering().getId(), meetingId, userId);

// 확정된 토픽 조회
List<Topic> topics = topicRepository.findByMeetingIdAndTopicStatusOrderByConfirmOrderAsc(
Expand All @@ -61,7 +62,7 @@ public RetrospectiveSummaryResponse getRetrospectiveSummary(Long meetingId) {
))
.toList();

return RetrospectiveSummaryResponse.from(meetingId, topicResponses);
return RetrospectiveSummaryResponse.from(meeting, topicResponses);
}

@Transactional
Expand Down Expand Up @@ -97,4 +98,28 @@ public RetrospectiveSummaryResponse updateRetrospectiveSummary(
// 수정된 결과 반환
return getRetrospectiveSummary(meetingId);
}

@Transactional
public RetrospectiveSummaryResponse publishRetrospective(Long meetingId) {
Long userId = SecurityUtil.getCurrentUserId();

Meeting meeting = meetingValidator.findMeetingOrThrow(meetingId);

// 권한 검증 (모임장/약속장만 생성 가능)
retrospectiveValidator.validateSummaryUpdatePermission(
meeting.getGathering().getId(),
meetingId,
userId
);

// 이미 생성된 경우 예외
if (meeting.isRetrospectivePublished()) {
throw new RetrospectiveException(RetrospectiveErrorCode.RETROSPECTIVE_ALREADY_PUBLISHED);
}

// 약속 회고 생성 (퍼블리시)
meeting.publishRetrospective();

return getRetrospectiveSummary(meetingId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,11 @@ void getMeetingRetrospective_throwsWhenNotGatheringMember() {
Long gatheringId = 1L;

Gathering gathering = Gathering.builder().id(gatheringId).build();
Meeting meeting = Meeting.builder().id(meetingId).gathering(gathering).build();
Meeting meeting = Meeting.builder()
.id(meetingId)
.gathering(gathering)
.retrospectivePublished(true)
.build();

try (MockedStatic<SecurityUtil> securityUtilMock = mockStatic(SecurityUtil.class)) {
securityUtilMock.when(SecurityUtil::getCurrentUserId).thenReturn(userId);
Expand All @@ -120,7 +124,11 @@ void getMeetingRetrospective_throwsWhenNotMeetingMember() {
Long gatheringId = 1L;

Gathering gathering = Gathering.builder().id(gatheringId).build();
Meeting meeting = Meeting.builder().id(meetingId).gathering(gathering).build();
Meeting meeting = Meeting.builder()
.id(meetingId)
.gathering(gathering)
.retrospectivePublished(true)
.build();

try (MockedStatic<SecurityUtil> securityUtilMock = mockStatic(SecurityUtil.class)) {
securityUtilMock.when(SecurityUtil::getCurrentUserId).thenReturn(userId);
Expand All @@ -135,6 +143,31 @@ void getMeetingRetrospective_throwsWhenNotMeetingMember() {
}
}

@Test
@DisplayName("퍼블리시되지 않은 약속 회고 조회 시 예외가 발생한다")
void getMeetingRetrospective_throwsWhenNotPublished() {
Long meetingId = 1L;
Long userId = 1L;
Long gatheringId = 1L;

Gathering gathering = Gathering.builder().id(gatheringId).build();
Meeting meeting = Meeting.builder()
.id(meetingId)
.gathering(gathering)
.retrospectivePublished(false)
.build();

try (MockedStatic<SecurityUtil> securityUtilMock = mockStatic(SecurityUtil.class)) {
securityUtilMock.when(SecurityUtil::getCurrentUserId).thenReturn(userId);

when(meetingValidator.findMeetingOrThrow(meetingId)).thenReturn(meeting);

assertThatThrownBy(() -> meetingRetrospectiveService.getMeetingRetrospective(meetingId))
.isInstanceOf(RetrospectiveException.class)
.hasFieldOrPropertyWithValue("errorCode", RetrospectiveErrorCode.RETROSPECTIVE_NOT_PUBLISHED);
}
}

@Test
@DisplayName("코멘트가 없어도 정상적으로 조회한다")
void getMeetingRetrospective_withNoComments_success() {
Expand All @@ -151,6 +184,7 @@ void getMeetingRetrospective_withNoComments_success() {
.meetingName("모임")
.meetingStartDate(LocalDateTime.of(2026, 1, 15, 19, 0))
.meetingEndDate(LocalDateTime.of(2026, 1, 15, 21, 0))
.retrospectivePublished(true)
.build();

Topic topic = Topic.builder().id(1L).meeting(meeting).title("토픽1").build();
Expand Down Expand Up @@ -182,6 +216,39 @@ void getMeetingRetrospective_withNoComments_success() {
}
}

@Test
@DisplayName("퍼블리시되지 않은 약속에 코멘트 작성 시 예외가 발생한다")
void createMeetingRetrospective_throwsWhenNotPublished() {
Long meetingId = 1L;
Long userId = 1L;
Long gatheringId = 1L;

Gathering gathering = Gathering.builder().id(gatheringId).build();
Meeting meeting = Meeting.builder()
.id(meetingId)
.gathering(gathering)
.retrospectivePublished(false)
.build();
User user = User.builder().id(userId).build();
MeetingRetrospectiveRequest request = new MeetingRetrospectiveRequest("코멘트");

CustomOAuth2User customOAuth2User = mock(CustomOAuth2User.class);
when(customOAuth2User.getUser()).thenReturn(user);

try (MockedStatic<SecurityUtil> securityUtilMock = mockStatic(SecurityUtil.class)) {
securityUtilMock.when(SecurityUtil::getCurrentUserId).thenReturn(userId);
securityUtilMock.when(SecurityUtil::getCurrentUser).thenReturn(customOAuth2User);

when(meetingValidator.findMeetingOrThrow(meetingId)).thenReturn(meeting);

assertThatThrownBy(() -> meetingRetrospectiveService.createMeetingRetrospective(meetingId, request))
.isInstanceOf(RetrospectiveException.class)
.hasFieldOrPropertyWithValue("errorCode", RetrospectiveErrorCode.RETROSPECTIVE_NOT_PUBLISHED);

verify(retrospectiveRepository, never()).save(any());
}
}

@Test
@DisplayName("공동 회고를 정상적으로 작성한다")
void createMeetingRetrospective_success() {
Expand All @@ -191,7 +258,11 @@ void createMeetingRetrospective_success() {
Long gatheringId = 1L;

Gathering gathering = Gathering.builder().id(gatheringId).build();
Meeting meeting = Meeting.builder().id(meetingId).gathering(gathering).build();
Meeting meeting = Meeting.builder()
.id(meetingId)
.gathering(gathering)
.retrospectivePublished(true)
.build();
User user = User.builder().id(userId).nickname("사용자1").profileImageUrl("https://image.jpg").build();

MeetingRetrospectiveRequest request = new MeetingRetrospectiveRequest("회고 코멘트입니다.");
Expand Down Expand Up @@ -264,7 +335,11 @@ void createMeetingRetrospective_throwsWhenNoAccess() {
Long gatheringId = 1L;

Gathering gathering = Gathering.builder().id(gatheringId).build();
Meeting meeting = Meeting.builder().id(meetingId).gathering(gathering).build();
Meeting meeting = Meeting.builder()
.id(meetingId)
.gathering(gathering)
.retrospectivePublished(true)
.build();
User user = User.builder().id(userId).build();
MeetingRetrospectiveRequest request = new MeetingRetrospectiveRequest( "코멘트");

Expand Down
Loading