diff --git a/docs/action-item-api.md b/docs/action-item-api.md new file mode 100644 index 00000000..771f72eb --- /dev/null +++ b/docs/action-item-api.md @@ -0,0 +1,244 @@ +# 실행목표 API 변경 사항 + +> **Base URL**: `https://stgapi.layerapp.io` (스테이징) / `https://api.layerapp.io` (프로덕션) +> **인증**: 모든 API는 `Authorization: Bearer {accessToken}` 헤더 필요 + +--- + +## 변경: 팀 실행목표 응답에 `createdAt` 추가 + +기존 팀 실행목표 조회 API(`GET /api/action-item/space/{spaceId}`, `GET /api/action-item/space/{spaceId}/recent`, `GET /api/action-item/member`) 응답의 각 실행목표 항목에 `createdAt` 필드가 추가됩니다. + +**Before** +```json +{ + "actionItemId": 42, + "content": "테스트 커버리지 80% 달성" +} +``` + +**After** +```json +{ + "actionItemId": 42, + "content": "테스트 커버리지 80% 달성", + "createdAt": "2024-03-15T10:30:00" +} +``` + +--- + +## 신규: 개인 실행목표 API + +> 스페이스 리더가 아닌 일반 멤버도 사용 가능하며, 본인의 개인 실행목표만 조회됩니다. + +--- + +### 스페이스의 개인 실행목표 전체 조회 (회고별) + +팀 실행목표의 `GET /api/action-item/space/{spaceId}`와 동일한 구조로 회고별로 묶어 반환합니다. + +``` +GET /api/action-item/personal/space/{spaceId} +``` + +**Response** `200 OK` +```json +{ + "spaceId": 430, + "spaceName": "세트스", + "personalActionItemList": [ + { + "retrospectId": 310, + "retrospectTitle": "테스트", + "deadline": "2025-03-19T05:00:00", + "status": "PROCEEDING", + "actionItemList": [ + { + "actionItemId": 55, + "content": "개인 학습 2시간씩 투자하기", + "createdAt": "2024-03-15T10:30:00" + } + ] + }, + { + "retrospectId": 309, + "retrospectTitle": "이전 회고", + "deadline": "2025-03-12T04:00:00", + "status": "DONE", + "actionItemList": [] + } + ] +} +``` + +> `status`: `PROCEEDING` (가장 최근 회고), `DONE` (이전 회고) +> 개인 실행목표가 없는 회고는 `actionItemList`가 빈 배열로 반환 + +--- + +### 스페이스의 최근 회고 개인 실행목표 조회 + +팀 실행목표의 `GET /api/action-item/space/{spaceId}/recent`에 대응합니다. + +``` +GET /api/action-item/personal/space/{spaceId}/recent +``` + +**Response** `200 OK` +```json +{ + "spaceId": 430, + "spaceName": "세트스", + "personalActionItemList": [ + { + "actionItemId": 55, + "content": "개인 학습 2시간씩 투자하기", + "retrospectId": 310, + "retrospectTitle": "테스트", + "createdAt": "2024-03-15T10:30:00" + } + ] +} +``` + +> 완료된 회고가 없거나 개인 실행목표가 없으면 `personalActionItemList`는 빈 배열 반환 + +--- + +### 내 모든 스페이스의 개인 실행목표 조회 + +팀 실행목표의 `GET /api/action-item/member`에 대응합니다. + +``` +GET /api/action-item/personal/member +``` + +**Response** `200 OK` +```json +{ + "actionItems": [ + { + "spaceId": 430, + "spaceName": "세트스", + "retrospectId": 310, + "retrospectTitle": "테스트", + "deadline": "2025-03-19T05:00:00", + "status": "PROCEEDING", + "actionItemList": [ + { + "actionItemId": 55, + "content": "개인 학습 2시간씩 투자하기", + "createdAt": "2024-03-15T10:30:00" + } + ] + } + ] +} +``` + +--- + +### 개인 실행목표 생성 + +``` +POST /api/action-item/personal/space/{spaceId}/retrospect/{retrospectId} +``` + +**Request Body** +```json +{ + "content": "다음 회고 전까지 개인 학습 2시간씩 투자하기" +} +``` + +**Response** `201 Created` +```json +{ + "actionItemId": 55 +} +``` + +--- + +### 개인 실행목표 조회 + +``` +GET /api/action-item/personal/space/{spaceId}/retrospect/{retrospectId} +``` + +**Response** `200 OK` +```json +{ + "actionItems": [ + { + "actionItemId": 55, + "content": "다음 회고 전까지 개인 학습 2시간씩 투자하기", + "actionItemOrder": 1, + "createdAt": "2024-03-15T10:30:00" + }, + { + "actionItemId": 56, + "content": "매일 회고 일기 쓰기", + "actionItemOrder": 2, + "createdAt": "2024-03-15T11:00:00" + } + ] +} +``` + +> 개인 실행목표가 없는 경우 `actionItems`는 빈 배열 반환 + +--- + +### 개인 실행목표 편집 (일괄) + +순서 변경·내용 수정·신규 추가·삭제를 한 번에 처리합니다. 요청 리스트의 순서가 최종 순서가 됩니다. + +``` +PATCH /api/action-item/personal/space/{spaceId}/retrospect/{retrospectId}/update +``` + +**Request Body** +```json +{ + "actionItems": [ + { + "id": 55, + "content": "개인 학습 2시간 (수정됨)" + }, + { + "id": null, + "content": "새로운 개인 실행목표" + } + ] +} +``` + +> - `id`가 있으면 기존 항목 수정 +> - `id`가 `null`이면 신규 생성 +> - 요청에 포함되지 않은 기존 항목은 **삭제** + +**Response** `200 OK` + +--- + +### 개인 실행목표 삭제 + +``` +DELETE /api/action-item/personal/{actionItemId} +``` + +**Response** `200 OK` + +> 본인이 생성한 개인 실행목표만 삭제 가능. 타인의 항목 삭제 시 `403 Forbidden` + +--- + +## 에러 응답 + +| HTTP Status | name | 설명 | +|-------------|------|------| +| `403` | `FORBIDDEN_ACTION_ITEM` | 타인의 개인 실행목표 삭제 시도 | +| `404` | `NOT_FOUND_ACTION_ITEM` | 실행목표가 존재하지 않음 | +| `404` | `NOT_FOUND_MEMBER_SPACE_RELATION` | 해당 스페이스의 멤버가 아님 | diff --git a/docs/reaction-api.md b/docs/reaction-api.md new file mode 100644 index 00000000..5522ac44 --- /dev/null +++ b/docs/reaction-api.md @@ -0,0 +1,237 @@ +# 회고 반응 API + +회고 답변에 이모지 반응을 달고 조회하는 API 모음입니다. + +> **Base URL** `https://stgapi.layerapp.io` +> **Auth** 모든 요청에 `Authorization: Bearer {accessToken}` 헤더 필요 (반응 목록 조회 제외) + +--- + +## 목차 + +1. [사용 가능한 모든 반응 조회](#1-사용-가능한-모든-반응-조회) +2. [최근 사용한 반응 조회](#2-최근-사용한-반응-조회) +3. [회고 반응 생성](#3-회고-반응-생성) +4. [회고 반응 삭제](#4-회고-반응-삭제) +5. [회고 전체 반응 조회](#5-회고-전체-반응-조회) + +--- + +## 1. 사용 가능한 모든 반응 조회 + +반응 선택 UI에서 사용할 수 있는 전체 반응 목록을 가져옵니다. + +``` +GET /api/reaction +``` + +### Response `200` + +```json +{ + "reactions": [ + { + "id": 1, + "imgUrl": "https://example.com/reaction/thumbs-up.png" + }, + { + "id": 2, + "imgUrl": "https://example.com/reaction/heart.png" + } + ] +} +``` + +| 필드 | 타입 | 설명 | +|---|---|---| +| `reactions` | `array` | 반응 목록 | +| `reactions[].id` | `number` | 반응 ID | +| `reactions[].imgUrl` | `string` | 반응 이미지 URL | + +--- + +## 2. 최근 사용한 반응 조회 + +내가 최근에 사용한 반응을 중복 없이 최신순으로 N개 가져옵니다. +반응 선택 UI 상단 "최근 사용" 영역에 활용합니다. + +``` +GET /api/reaction/recent?limit={N} +``` + +### Query Parameters + +| 파라미터 | 타입 | 필수 | 기본값 | 설명 | +|---|---|---|---|---| +| `limit` | `number` | N | `8` | 가져올 반응 개수 | + +### Request 예시 + +``` +GET /api/reaction/recent?limit=8 +``` + +### Response `200` + +```json +{ + "reactions": [ + { + "id": 3, + "imgUrl": "https://example.com/reaction/fire.png" + }, + { + "id": 1, + "imgUrl": "https://example.com/reaction/thumbs-up.png" + } + ] +} +``` + +> 최신순 정렬, 한 번도 반응을 남기지 않은 경우 빈 배열 반환 + +--- + +## 3. 회고 반응 생성 + +특정 회고 답변에 반응을 답니다. +**하나의 답변에 하나의 반응만 가능합니다.** 이미 반응을 달았다면 `400` 에러가 반환됩니다. + +``` +POST /space/{spaceId}/retrospect/{retrospectId}/reaction +``` + +### Path Parameters + +| 파라미터 | 타입 | 설명 | +|---|---|---| +| `spaceId` | `number` | 스페이스 ID | +| `retrospectId` | `number` | 회고 ID | + +### Request Body + +```json +{ + "reactionId": 1, + "answerId": 5 +} +``` + +| 필드 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `reactionId` | `number` | Y | 사용할 반응 ID (반응 목록 조회 API에서 가져온 값) | +| `answerId` | `number` | Y | 반응을 달 답변 ID | + +### Response + +| 상태코드 | 설명 | +|---|---| +| `201` | 반응 생성 성공 | +| `400` | 이미 해당 답변에 반응을 달았음 | +| `403` | 해당 스페이스 멤버가 아님 | +| `404` | 존재하지 않는 반응 ID | + +--- + +## 4. 회고 반응 삭제 + +내가 단 반응을 삭제합니다. **본인이 단 반응만 삭제 가능합니다.** + +``` +DELETE /space/{spaceId}/retrospect/{retrospectId}/reaction/{retrospectReactionId} +``` + +### Path Parameters + +| 파라미터 | 타입 | 설명 | +|---|---|---| +| `spaceId` | `number` | 스페이스 ID | +| `retrospectId` | `number` | 회고 ID | +| `retrospectReactionId` | `number` | 삭제할 회고 반응 ID (반응 조회 API의 `reactions[].retrospectReactionId`) | + +### Response + +| 상태코드 | 설명 | +|---|---| +| `200` | 반응 삭제 성공 | +| `403` | 본인의 반응이 아님 | +| `404` | 존재하지 않는 회고 반응 ID | + +--- + +## 5. 회고 전체 반응 조회 + +특정 회고의 **모든 답변**에 달린 반응을 한 번에 가져옵니다. +`memberId` 비교를 통해 내가 단 반응과 다른 사람의 반응을 구분할 수 있습니다. + +``` +GET /space/{spaceId}/retrospect/{retrospectId}/reaction +``` + +### Path Parameters + +| 파라미터 | 타입 | 설명 | +|---|---|---| +| `spaceId` | `number` | 스페이스 ID | +| `retrospectId` | `number` | 회고 ID | + +### Response `200` + +```json +{ + "answerReactions": [ + { + "answerId": 10, + "reactions": [ + { + "retrospectReactionId": 1, + "reactionId": 2, + "memberId": 29 + }, + { + "retrospectReactionId": 2, + "reactionId": 5, + "memberId": 31 + } + ] + }, + { + "answerId": 11, + "reactions": [] + } + ] +} +``` + +| 필드 | 타입 | 설명 | +|---|---|---| +| `answerReactions` | `array` | 답변별 반응 목록 | +| `answerReactions[].answerId` | `number` | 답변 ID | +| `answerReactions[].reactions` | `array` | 해당 답변에 달린 반응 목록 (없으면 빈 배열) | +| `reactions[].retrospectReactionId` | `number` | 회고 반응 ID — **삭제 시 이 값을 사용** | +| `reactions[].reactionId` | `number` | 어떤 반응인지 (imgUrl 매핑에 사용) | +| `reactions[].memberId` | `number` | 반응을 단 멤버 ID — **내 반응 여부 판단에 사용** | + +--- + +## 전형적인 사용 흐름 + +``` +1. GET /api/reaction → 전체 반응 목록 가져오기 (반응 선택 UI용) +2. GET /api/reaction/recent?limit=8 → 최근 사용 반응 가져오기 (UI 상단 영역) +3. GET /space/{id}/retrospect/{id}/reaction → 현재 회고의 반응 상태 가져오기 +4. POST .../reaction → 반응 생성 +5. DELETE .../reaction/{id} → 반응 취소 +``` + +## 내가 단 반응 판단 방법 + +```js +const myReaction = reactions.find(r => r.memberId === currentMemberId); +const hasMyReaction = !!myReaction; + +// 반응 취소 시 +if (hasMyReaction) { + await deleteReaction(myReaction.retrospectReactionId); +} +``` diff --git a/layer-api/src/main/java/org/layer/domain/actionItem/controller/ActionItemApi.java b/layer-api/src/main/java/org/layer/domain/actionItem/controller/ActionItemApi.java index e8604731..b392bea0 100644 --- a/layer-api/src/main/java/org/layer/domain/actionItem/controller/ActionItemApi.java +++ b/layer-api/src/main/java/org/layer/domain/actionItem/controller/ActionItemApi.java @@ -10,6 +10,7 @@ import org.layer.domain.actionItem.controller.dto.request.ActionItemCreateBySpaceIdRequest; import org.layer.domain.actionItem.controller.dto.request.ActionItemCreateRequest; import org.layer.domain.actionItem.controller.dto.request.ActionItemUpdateRequest; +import org.layer.domain.actionItem.controller.dto.request.PersonalActionItemCreateRequest; import org.layer.domain.actionItem.controller.dto.response.*; import org.layer.domain.actionItem.dto.MemberActionItemResponse; import org.springframework.http.ResponseEntity; @@ -113,4 +114,72 @@ ResponseEntity updateActionItem(@MemberId Long memberId, ) ResponseEntity createActionItemBySpaceId(@MemberId Long memberId, @Validated @RequestBody ActionItemCreateBySpaceIdRequest actionItemCreateRequest); + + @Operation(summary = "스페이스의 개인 실행 목표 전체 조회 (회고별)", method = "GET", description = """ + 스페이스 내 완료된 모든 회고를 회고별로 묶어 나의 개인 실행목표를 조회합니다. + """) + @ApiResponses({ + @ApiResponse(responseCode = "200", + content = {@Content(mediaType = "application/json", + schema = @Schema(implementation = PersonalSpaceRetrospectActionItemGetResponse.class))}) + }) + ResponseEntity getPersonalSpaceActionItems(@MemberId Long memberId, + @PathVariable Long spaceId); + + @Operation(summary = "스페이스의 최근 회고 개인 실행 목표 조회", method = "GET", description = """ + 스페이스에서 가장 최근에 완료된 회고의 나의 개인 실행목표를 조회합니다. + """) + @ApiResponses({ + @ApiResponse(responseCode = "200", + content = {@Content(mediaType = "application/json", + schema = @Schema(implementation = PersonalSpaceActionItemGetResponse.class))}) + }) + ResponseEntity getPersonalSpaceRecentActionItems(@MemberId Long memberId, + @PathVariable Long spaceId); + + @Operation(summary = "멤버의 전체 스페이스 개인 실행 목표 조회", method = "GET", description = """ + 내가 속한 모든 스페이스의 개인 실행목표를 회고별로 조회합니다. + """) + @ApiResponses({ + @ApiResponse(responseCode = "200", + content = {@Content(mediaType = "application/json", + schema = @Schema(implementation = MemberActionItemGetResponse.class))}) + }) + ResponseEntity getPersonalMemberActionItems(@MemberId Long memberId); + + @Operation(summary = "개인 실행 목표 생성", method = "POST", description = """ + 특정 회고에 대해 개인 실행 목표를 생성합니다. 스페이스 리더가 아닌 일반 멤버도 생성 가능합니다. + """) + @ApiResponses({@ApiResponse(responseCode = "201")}) + ResponseEntity createPersonalActionItem(@MemberId Long memberId, + @PathVariable Long spaceId, + @PathVariable Long retrospectId, + @Validated @RequestBody PersonalActionItemCreateRequest request); + + @Operation(summary = "개인 실행 목표 조회", method = "GET", description = """ + 특정 회고에 대한 나의 개인 실행 목표 목록을 조회합니다. 생성 시각도 함께 반환됩니다. + """) + @ApiResponses({ + @ApiResponse(responseCode = "200", + content = {@Content(mediaType = "application/json", + schema = @Schema(implementation = PersonalActionItemGetResponse.class))}) + }) + ResponseEntity getPersonalActionItems(@MemberId Long memberId, + @PathVariable Long spaceId, + @PathVariable Long retrospectId); + + @Operation(summary = "개인 실행 목표 편집", method = "PATCH", description = """ + 특정 회고의 나의 개인 실행 목표 리스트를 편집합니다. 요청 리스트의 순서를 편집된 순서와 일치하게 넘겨주세요! + """) + @ApiResponses({@ApiResponse(responseCode = "200")}) + ResponseEntity updatePersonalActionItems(@MemberId Long memberId, + @PathVariable Long spaceId, + @PathVariable Long retrospectId, + @RequestBody ActionItemUpdateRequest request); + + @Operation(summary = "개인 실행 목표 삭제", method = "DELETE", description = """ + 개인 실행 목표를 삭제합니다. 본인이 생성한 실행 목표만 삭제할 수 있습니다. + """) + @ApiResponses({@ApiResponse(responseCode = "200")}) + ResponseEntity deletePersonalActionItem(@MemberId Long memberId, @PathVariable Long actionItemId); } \ No newline at end of file diff --git a/layer-api/src/main/java/org/layer/domain/actionItem/controller/ActionItemController.java b/layer-api/src/main/java/org/layer/domain/actionItem/controller/ActionItemController.java index d3e85f88..16d20117 100644 --- a/layer-api/src/main/java/org/layer/domain/actionItem/controller/ActionItemController.java +++ b/layer-api/src/main/java/org/layer/domain/actionItem/controller/ActionItemController.java @@ -6,8 +6,12 @@ import org.layer.domain.actionItem.controller.dto.request.ActionItemCreateBySpaceIdRequest; import org.layer.domain.actionItem.controller.dto.request.ActionItemCreateRequest; import org.layer.domain.actionItem.controller.dto.request.ActionItemUpdateRequest; +import org.layer.domain.actionItem.controller.dto.request.PersonalActionItemCreateRequest; import org.layer.domain.actionItem.controller.dto.response.ActionItemCreateResponse; import org.layer.domain.actionItem.controller.dto.response.MemberActionItemGetResponse; +import org.layer.domain.actionItem.controller.dto.response.PersonalActionItemGetResponse; +import org.layer.domain.actionItem.controller.dto.response.PersonalSpaceActionItemGetResponse; +import org.layer.domain.actionItem.controller.dto.response.PersonalSpaceRetrospectActionItemGetResponse; import org.layer.domain.actionItem.controller.dto.response.SpaceActionItemGetResponse; import org.layer.domain.actionItem.controller.dto.response.SpaceRetrospectActionItemGetResponse; import org.layer.domain.actionItem.service.ActionItemService; @@ -82,4 +86,61 @@ public ResponseEntity createActionItemBySpaceId(@Membe return new ResponseEntity<>(response, HttpStatus.CREATED); } + + @Override + @GetMapping("/personal/space/{spaceId}") + public ResponseEntity getPersonalSpaceActionItems(@MemberId Long memberId, + @PathVariable Long spaceId) { + return new ResponseEntity<>(actionItemService.getPersonalSpaceActionItemList(memberId, spaceId), HttpStatus.OK); + } + + @Override + @GetMapping("/personal/space/{spaceId}/recent") + public ResponseEntity getPersonalSpaceRecentActionItems(@MemberId Long memberId, + @PathVariable Long spaceId) { + return new ResponseEntity<>(actionItemService.getPersonalSpaceRecentActionItems(memberId, spaceId), HttpStatus.OK); + } + + @Override + @GetMapping("/personal/member") + public ResponseEntity getPersonalMemberActionItems(@MemberId Long memberId) { + return new ResponseEntity<>(actionItemService.getPersonalMemberActionItemList(memberId), HttpStatus.OK); + } + + @Override + @PostMapping("/personal/space/{spaceId}/retrospect/{retrospectId}") + public ResponseEntity createPersonalActionItem(@MemberId Long memberId, + @PathVariable Long spaceId, + @PathVariable Long retrospectId, + @Validated @RequestBody PersonalActionItemCreateRequest request) { + ActionItemCreateResponse response = actionItemService.createPersonalActionItem(memberId, spaceId, retrospectId, request.content()); + return new ResponseEntity<>(response, HttpStatus.CREATED); + } + + @Override + @GetMapping("/personal/space/{spaceId}/retrospect/{retrospectId}") + public ResponseEntity getPersonalActionItems(@MemberId Long memberId, + @PathVariable Long spaceId, + @PathVariable Long retrospectId) { + PersonalActionItemGetResponse response = actionItemService.getPersonalActionItems(memberId, spaceId, retrospectId); + return new ResponseEntity<>(response, HttpStatus.OK); + } + + @Override + @PatchMapping("/personal/space/{spaceId}/retrospect/{retrospectId}/update") + public ResponseEntity updatePersonalActionItems(@MemberId Long memberId, + @PathVariable Long spaceId, + @PathVariable Long retrospectId, + @RequestBody ActionItemUpdateRequest request) { + actionItemService.updatePersonalActionItems(memberId, spaceId, retrospectId, request); + return ResponseEntity.ok().build(); + } + + @Override + @DeleteMapping("/personal/{actionItemId}") + public ResponseEntity deletePersonalActionItem(@MemberId Long memberId, + @PathVariable Long actionItemId) { + actionItemService.deletePersonalActionItem(memberId, actionItemId); + return ResponseEntity.ok().build(); + } } diff --git a/layer-api/src/main/java/org/layer/domain/actionItem/controller/dto/request/PersonalActionItemCreateRequest.java b/layer-api/src/main/java/org/layer/domain/actionItem/controller/dto/request/PersonalActionItemCreateRequest.java new file mode 100644 index 00000000..18858545 --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/actionItem/controller/dto/request/PersonalActionItemCreateRequest.java @@ -0,0 +1,11 @@ +package org.layer.domain.actionItem.controller.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "개인 실행 목표 생성 요청 DTO") +public record PersonalActionItemCreateRequest( + @Schema(description = "실행 목표 내용", example = "다음 회고까지 문서 정리하기") + @NotBlank + String content +) {} diff --git a/layer-api/src/main/java/org/layer/domain/actionItem/controller/dto/response/PersonalActionItemGetResponse.java b/layer-api/src/main/java/org/layer/domain/actionItem/controller/dto/response/PersonalActionItemGetResponse.java new file mode 100644 index 00000000..98d5f420 --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/actionItem/controller/dto/response/PersonalActionItemGetResponse.java @@ -0,0 +1,43 @@ +package org.layer.domain.actionItem.controller.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.layer.domain.actionItem.entity.ActionItem; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(name = "PersonalActionItemGetResponse", description = "개인 실행 목표 조회 응답 DTO") +public record PersonalActionItemGetResponse( + @Schema(description = "개인 실행 목표 목록") + List actionItems +) { + public static PersonalActionItemGetResponse from(List items) { + return new PersonalActionItemGetResponse( + items.stream().map(PersonalActionItemElement::from).toList() + ); + } + + @Schema(name = "PersonalActionItemElement", description = "개인 실행 목표 요소") + public record PersonalActionItemElement( + @Schema(description = "실행 목표 ID", example = "1") + Long actionItemId, + + @Schema(description = "실행 목표 내용", example = "다음 회고까지 문서 정리하기") + String content, + + @Schema(description = "순서", example = "1") + int actionItemOrder, + + @Schema(description = "생성 시각", example = "2024-01-01T12:00:00") + LocalDateTime createdAt + ) { + public static PersonalActionItemElement from(ActionItem item) { + return new PersonalActionItemElement( + item.getId(), + item.getContent(), + item.getActionItemOrder(), + item.getCreatedAt() + ); + } + } +} diff --git a/layer-api/src/main/java/org/layer/domain/actionItem/controller/dto/response/PersonalSpaceActionItemGetResponse.java b/layer-api/src/main/java/org/layer/domain/actionItem/controller/dto/response/PersonalSpaceActionItemGetResponse.java new file mode 100644 index 00000000..107e8bb2 --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/actionItem/controller/dto/response/PersonalSpaceActionItemGetResponse.java @@ -0,0 +1,74 @@ +package org.layer.domain.actionItem.controller.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import org.layer.domain.actionItem.entity.ActionItem; +import org.layer.domain.retrospect.entity.Retrospect; +import org.layer.domain.space.entity.Space; + +import java.time.LocalDateTime; +import java.util.List; + +@Builder +public record PersonalSpaceActionItemGetResponse( + @NotNull + @Schema(description = "스페이스 ID") + Long spaceId, + + @NotNull + @Schema(description = "스페이스 이름") + String spaceName, + + @NotNull + @Schema(description = "가장 최근 회고의 개인 실행목표 리스트") + List personalActionItemList +) { + public static PersonalSpaceActionItemGetResponse of(Space space, Retrospect retrospect, List items) { + List elements = items.stream() + .map(item -> PersonalSpaceActionItemElement.of(item, retrospect)) + .toList(); + + return PersonalSpaceActionItemGetResponse.builder() + .spaceId(space.getId()) + .spaceName(space.getName()) + .personalActionItemList(elements) + .build(); + } + + public static PersonalSpaceActionItemGetResponse empty(Space space) { + return PersonalSpaceActionItemGetResponse.builder() + .spaceId(space.getId()) + .spaceName(space.getName()) + .personalActionItemList(List.of()) + .build(); + } + + @Builder + public record PersonalSpaceActionItemElement( + @Schema(description = "실행목표 ID") + Long actionItemId, + + @Schema(description = "실행목표 내용") + String content, + + @Schema(description = "회고 ID") + Long retrospectId, + + @Schema(description = "회고 제목") + String retrospectTitle, + + @Schema(description = "생성 시각") + LocalDateTime createdAt + ) { + public static PersonalSpaceActionItemElement of(ActionItem item, Retrospect retrospect) { + return PersonalSpaceActionItemElement.builder() + .actionItemId(item.getId()) + .content(item.getContent()) + .retrospectId(retrospect.getId()) + .retrospectTitle(retrospect.getTitle()) + .createdAt(item.getCreatedAt()) + .build(); + } + } +} diff --git a/layer-api/src/main/java/org/layer/domain/actionItem/controller/dto/response/PersonalSpaceRetrospectActionItemGetResponse.java b/layer-api/src/main/java/org/layer/domain/actionItem/controller/dto/response/PersonalSpaceRetrospectActionItemGetResponse.java new file mode 100644 index 00000000..0e325857 --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/actionItem/controller/dto/response/PersonalSpaceRetrospectActionItemGetResponse.java @@ -0,0 +1,31 @@ +package org.layer.domain.actionItem.controller.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import org.layer.domain.space.entity.Space; + +import java.util.List; + +@Builder +public record PersonalSpaceRetrospectActionItemGetResponse( + @NotNull + @Schema(description = "스페이스 ID") + Long spaceId, + + @NotNull + @Schema(description = "스페이스 이름") + String spaceName, + + @NotNull + @Schema(description = "회고별 개인 실행목표 리스트") + List personalActionItemList +) { + public static PersonalSpaceRetrospectActionItemGetResponse of(Space space, List list) { + return PersonalSpaceRetrospectActionItemGetResponse.builder() + .spaceId(space.getId()) + .spaceName(space.getName()) + .personalActionItemList(list) + .build(); + } +} diff --git a/layer-api/src/main/java/org/layer/domain/actionItem/service/ActionItemService.java b/layer-api/src/main/java/org/layer/domain/actionItem/service/ActionItemService.java index 1ebc327f..11579ed6 100644 --- a/layer-api/src/main/java/org/layer/domain/actionItem/service/ActionItemService.java +++ b/layer-api/src/main/java/org/layer/domain/actionItem/service/ActionItemService.java @@ -7,6 +7,7 @@ import org.layer.domain.actionItem.dto.ActionItemResponse; import org.layer.domain.actionItem.dto.MemberActionItemResponse; import org.layer.domain.actionItem.entity.ActionItem; +import org.layer.domain.actionItem.entity.ActionItemType; import org.layer.domain.actionItem.enums.ActionItemStatus; import org.layer.domain.actionItem.exception.ActionItemException; import org.layer.domain.actionItem.repository.ActionItemRepository; @@ -24,6 +25,8 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; +import static org.layer.domain.actionItem.entity.ActionItemType.PERSONAL; +import static org.layer.domain.actionItem.entity.ActionItemType.TEAM; import static org.layer.domain.retrospect.entity.RetrospectStatus.DONE; import static org.layer.global.exception.ApiActionItemExceptionType.*; import static org.layer.global.exception.ApiMemberSpaceRelationExceptionType.*; @@ -56,6 +59,7 @@ public ActionItemCreateResponse createActionItem(Long memberId, Long retrospectI .memberId(memberId) .content(content) .actionItemOrder(actionItemCount + 1) + .type(TEAM) .build()); return new ActionItemCreateResponse(savedActionItem.getId()); @@ -90,6 +94,7 @@ public ActionItemCreateResponse createActionItemBySpaceId(Long memberId, Long sp .memberId(memberId) .content(content) .actionItemOrder(actionItemCount + 1) + .type(TEAM) .build()); return new ActionItemCreateResponse(savedActionItem.getId()); @@ -228,6 +233,185 @@ public MemberActionItemGetResponse getMemberActionItemList(Long currentMemberId) return new MemberActionItemGetResponse(dtoList); } + //== 개인 실행 목표 - 스페이스 전체 회고별 조회 ==// + public PersonalSpaceRetrospectActionItemGetResponse getPersonalSpaceActionItemList(Long memberId, Long spaceId) { + Space space = spaceRepository.findByIdOrThrow(spaceId); + memberSpaceRelationRepository.findBySpaceIdAndMemberId(spaceId, memberId) + .orElseThrow(() -> new MemberSpaceRelationException(NOT_FOUND_MEMBER_SPACE_RELATION)); + + List doneRetrospects = retrospectRepository.findAllBySpaceId(spaceId).stream() + .filter(r -> r.getRetrospectStatus().equals(DONE)) + .sorted((a, b) -> b.getDeadline().compareTo(a.getDeadline())) + .toList(); + + List doneRetrospectIds = doneRetrospects.stream().map(Retrospect::getId).toList(); + List personalItems = actionItemRepository.findAllByRetrospectIdInAndMemberIdAndType(doneRetrospectIds, memberId, PERSONAL); + + List responses = new ArrayList<>(); + for (int i = 0; i < doneRetrospects.size(); i++) { + Retrospect retrospect = doneRetrospects.get(i); + ActionItemStatus status = (i == 0) ? ActionItemStatus.PROCEEDING : ActionItemStatus.DONE; + + List items = personalItems.stream() + .filter(ai -> ai.getRetrospectId().equals(retrospect.getId())) + .sorted(Comparator.comparingInt(ActionItem::getActionItemOrder)) + .map(ActionItemResponse::of) + .toList(); + + responses.add(RetrospectActionItemResponse.builder() + .retrospectId(retrospect.getId()) + .retrospectTitle(retrospect.getTitle()) + .deadline(retrospect.getDeadline()) + .status(status) + .actionItemList(items) + .build()); + } + + return PersonalSpaceRetrospectActionItemGetResponse.of(space, responses); + } + + //== 개인 실행 목표 - 스페이스 최근 회고 조회 ==// + public PersonalSpaceActionItemGetResponse getPersonalSpaceRecentActionItems(Long memberId, Long spaceId) { + Space space = spaceRepository.findByIdOrThrow(spaceId); + memberSpaceRelationRepository.findBySpaceIdAndMemberId(spaceId, memberId) + .orElseThrow(() -> new MemberSpaceRelationException(NOT_FOUND_MEMBER_SPACE_RELATION)); + + Optional recentOpt = retrospectRepository.findAllBySpaceId(spaceId).stream() + .filter(r -> r.getRetrospectStatus().equals(DONE)) + .sorted(Comparator.comparing(Retrospect::getDeadline, + Comparator.nullsLast(Comparator.naturalOrder())).reversed()) + .findFirst(); + + if (recentOpt.isEmpty()) { + return PersonalSpaceActionItemGetResponse.empty(space); + } + + Retrospect recent = recentOpt.get(); + List items = actionItemRepository + .findAllByRetrospectIdAndMemberIdAndType(recent.getId(), memberId, PERSONAL) + .stream() + .sorted(Comparator.comparingInt(ActionItem::getActionItemOrder)) + .toList(); + + return PersonalSpaceActionItemGetResponse.of(space, recent, items); + } + + //== 개인 실행 목표 - 멤버의 전체 스페이스 조회 ==// + public MemberActionItemGetResponse getPersonalMemberActionItemList(Long memberId) { + List dtoList = retrospectRepository.findAllMemberActionItemResponsesByMemberId(memberId); + + List retrospectIds = dtoList.stream().map(MemberActionItemResponse::getRetrospectId).toList(); + List personalItems = actionItemRepository.findAllByRetrospectIdInAndMemberIdAndType(retrospectIds, memberId, PERSONAL); + + Set spaceIdSet = new HashSet<>(); + for (MemberActionItemResponse dto : dtoList) { + List items = personalItems.stream() + .filter(ai -> ai.getRetrospectId().equals(dto.getRetrospectId())) + .sorted(Comparator.comparingInt(ActionItem::getActionItemOrder)) + .map(ActionItemResponse::of) + .toList(); + + ActionItemStatus status = spaceIdSet.contains(dto.getSpaceId()) ? ActionItemStatus.DONE : ActionItemStatus.PROCEEDING; + spaceIdSet.add(dto.getSpaceId()); + + dto.updateActionItemList(items); + dto.updateStatus(status); + } + + return new MemberActionItemGetResponse(dtoList); + } + + //== 개인 실행 목표 생성 ==// + @Transactional + public ActionItemCreateResponse createPersonalActionItem(Long memberId, Long spaceId, Long retrospectId, String content) { + memberSpaceRelationRepository.findBySpaceIdAndMemberId(spaceId, memberId) + .orElseThrow(() -> new MemberSpaceRelationException(NOT_FOUND_MEMBER_SPACE_RELATION)); + + retrospectRepository.findByIdOrThrow(retrospectId); + + int count = actionItemRepository.countByRetrospectIdAndMemberIdAndType(retrospectId, memberId, PERSONAL); + + ActionItem saved = actionItemRepository.save(ActionItem.builder() + .retrospectId(retrospectId) + .spaceId(spaceId) + .memberId(memberId) + .content(content) + .actionItemOrder(count + 1) + .type(PERSONAL) + .build()); + + return new ActionItemCreateResponse(saved.getId()); + } + + //== 개인 실행 목표 조회 ==// + public PersonalActionItemGetResponse getPersonalActionItems(Long memberId, Long spaceId, Long retrospectId) { + memberSpaceRelationRepository.findBySpaceIdAndMemberId(spaceId, memberId) + .orElseThrow(() -> new MemberSpaceRelationException(NOT_FOUND_MEMBER_SPACE_RELATION)); + + retrospectRepository.findByIdOrThrow(retrospectId); + + List items = actionItemRepository + .findAllByRetrospectIdAndMemberIdAndType(retrospectId, memberId, PERSONAL) + .stream() + .sorted(Comparator.comparingInt(ActionItem::getActionItemOrder)) + .toList(); + + return PersonalActionItemGetResponse.from(items); + } + + //== 개인 실행 목표 수정 ==// + @Transactional + public void updatePersonalActionItems(Long memberId, Long spaceId, Long retrospectId, ActionItemUpdateRequest updateDto) { + memberSpaceRelationRepository.findBySpaceIdAndMemberId(spaceId, memberId) + .orElseThrow(() -> new MemberSpaceRelationException(NOT_FOUND_MEMBER_SPACE_RELATION)); + + retrospectRepository.findByIdOrThrow(retrospectId); + + List dbItems = actionItemRepository.findAllByRetrospectIdAndMemberIdAndType(retrospectId, memberId, PERSONAL); + + Set requestIds = updateDto.actionItems().stream() + .map(ActionItemUpdateRequest.ActionItemUpdateElementRequest::id) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + actionItemRepository.deleteAll(dbItems.stream() + .filter(item -> !requestIds.contains(item.getId())) + .toList()); + + Map itemMap = dbItems.stream() + .collect(Collectors.toMap(ActionItem::getId, item -> item)); + + int order = 1; + for (ActionItemUpdateRequest.ActionItemUpdateElementRequest req : updateDto.actionItems()) { + if (req.id() != null && itemMap.containsKey(req.id())) { + ActionItem item = itemMap.get(req.id()); + item.updateContent(req.content()); + item.updateActionItemOrder(order++); + } else { + actionItemRepository.save(ActionItem.builder() + .retrospectId(retrospectId) + .spaceId(spaceId) + .memberId(memberId) + .content(req.content()) + .actionItemOrder(order++) + .type(PERSONAL) + .build()); + } + } + } + + //== 개인 실행 목표 삭제 ==// + @Transactional + public void deletePersonalActionItem(Long memberId, Long actionItemId) { + ActionItem item = actionItemRepository.findByIdOrThrow(actionItemId); + + if (!item.getMemberId().equals(memberId)) { + throw new ActionItemException(FORBIDDEN_ACTION_ITEM); + } + + actionItemRepository.delete(item); + } + //== 실행 목표 수정 ==// @Transactional public void updateActionItems(Long memberId, Long retrospectId, ActionItemUpdateRequest updateDto) { @@ -273,6 +457,7 @@ public void updateActionItems(Long memberId, Long retrospectId, ActionItemUpdate .memberId(memberId) .content(requestItem.content()) .actionItemOrder(order++) + .type(TEAM) .build(); actionItemRepository.save(newActionItem); } diff --git a/layer-api/src/main/java/org/layer/domain/answer/controller/dto/response/PersonAndAnswerGetResponse.java b/layer-api/src/main/java/org/layer/domain/answer/controller/dto/response/PersonAndAnswerGetResponse.java index 81230146..015215ef 100644 --- a/layer-api/src/main/java/org/layer/domain/answer/controller/dto/response/PersonAndAnswerGetResponse.java +++ b/layer-api/src/main/java/org/layer/domain/answer/controller/dto/response/PersonAndAnswerGetResponse.java @@ -4,6 +4,8 @@ @Schema(name = "PersonAndAnswerGetResponse", description = "개인-답변 응답 Dto") public record PersonAndAnswerGetResponse( + @Schema(description = "답변 ID", example = "1") + Long answerId, @Schema(description = "답변자", example = "홍길동") String name, @Schema(description = "탈퇴 여부. 탈퇴시 true", example = "false") diff --git a/layer-api/src/main/java/org/layer/domain/answer/controller/dto/response/QuestionAndAnswerGetResponse.java b/layer-api/src/main/java/org/layer/domain/answer/controller/dto/response/QuestionAndAnswerGetResponse.java index f6087d93..c123993b 100644 --- a/layer-api/src/main/java/org/layer/domain/answer/controller/dto/response/QuestionAndAnswerGetResponse.java +++ b/layer-api/src/main/java/org/layer/domain/answer/controller/dto/response/QuestionAndAnswerGetResponse.java @@ -4,6 +4,8 @@ @Schema(name = "QuestionAndAnswerGetResponse", description = "질문-답변 Dto") public record QuestionAndAnswerGetResponse( + @Schema(description = "답변 ID", example = "1") + Long answerId, @Schema(description = "질문 내용", example = "질문 내용입니다.") String questionContent, @Schema(description = "질문 타입", example = "plain_text") diff --git a/layer-api/src/main/java/org/layer/domain/answer/service/AnswerService.java b/layer-api/src/main/java/org/layer/domain/answer/service/AnswerService.java index 5cb4cf27..a6dbd71a 100644 --- a/layer-api/src/main/java/org/layer/domain/answer/service/AnswerService.java +++ b/layer-api/src/main/java/org/layer/domain/answer/service/AnswerService.java @@ -262,9 +262,11 @@ private List getAnswerByPersonGetResponses(Answers an return members.getMembers().stream() .map(member -> { List questionAndAnswer = questions.stream() - .map(question -> new QuestionAndAnswerGetResponse(question.getContent(), - question.getQuestionType().getStyle(), answers.getAnswerToQuestion( - question.getId(), member.getId()))) + .map(question -> new QuestionAndAnswerGetResponse( + answers.getAnswerIdToQuestion(question.getId(), member.getId()), + question.getContent(), + question.getQuestionType().getStyle(), + answers.getAnswerToQuestion(question.getId(), member.getId()))) .toList(); return new AnswerByPersonGetResponse(member.getName(), member.getDeletedAt() != null, @@ -280,8 +282,11 @@ private List getAnswerByQuestionGetResponses(Answer .map(question -> { List personAndAnswer = answers.getAnswers().stream() .filter(answer -> answer.getQuestionId().equals(question.getId())) - .map(answer -> new PersonAndAnswerGetResponse(members.getName(answer.getMemberId()), - members.getDeleted(answer.getMemberId()), answer.getContent())) + .map(answer -> new PersonAndAnswerGetResponse( + answer.getId(), + members.getName(answer.getMemberId()), + members.getDeleted(answer.getMemberId()), + answer.getContent())) .toList(); return new AnswerByQuestionGetResponse(question.getContent(), question.getQuestionType().getStyle(), diff --git a/layer-api/src/main/java/org/layer/domain/auth/controller/AuthController.java b/layer-api/src/main/java/org/layer/domain/auth/controller/AuthController.java index 83a8a1d7..18fe1cc0 100644 --- a/layer-api/src/main/java/org/layer/domain/auth/controller/AuthController.java +++ b/layer-api/src/main/java/org/layer/domain/auth/controller/AuthController.java @@ -70,4 +70,5 @@ public ResponseEntity reissueToken(@RequestHeader(value = public MemberInfoResponse getMemberInfo(@MemberId Long memberId) { return authService.getMemberInfo(memberId); } + } diff --git a/layer-api/src/main/java/org/layer/domain/auth/controller/CreateTokenController.java b/layer-api/src/main/java/org/layer/domain/auth/controller/CreateTokenController.java new file mode 100644 index 00000000..b6eed5f2 --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/auth/controller/CreateTokenController.java @@ -0,0 +1,30 @@ +package org.layer.domain.auth.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.layer.domain.auth.controller.dto.CreateTokenRequest; +import org.layer.domain.auth.controller.dto.CreateTokenResponse; +import org.layer.domain.auth.service.AuthService; +import org.springframework.context.annotation.Profile; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Profile("!prod") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/auth") +@Tag(name = "인증", description = "인증 관련 API") +public class CreateTokenController { + + private final AuthService authService; + + @PostMapping("/create-token") + @Operation(summary = "[인증 불필요] 테스트용 토큰 생성", description = "memberId를 전달하면 100년 유효한 액세스 토큰을 발급합니다. 개발/스테이징 환경 전용이며 prod에서는 비활성화됩니다.") + public ResponseEntity createToken(@RequestBody CreateTokenRequest request) { + return ResponseEntity.ok(authService.createToken(request.memberId())); + } +} diff --git a/layer-api/src/main/java/org/layer/domain/auth/controller/dto/CreateTokenRequest.java b/layer-api/src/main/java/org/layer/domain/auth/controller/dto/CreateTokenRequest.java new file mode 100644 index 00000000..ff89cf4b --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/auth/controller/dto/CreateTokenRequest.java @@ -0,0 +1,12 @@ +package org.layer.domain.auth.controller.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(name = "CreateTokenRequest", description = "테스트용 토큰 생성 요청 DTO") +public record CreateTokenRequest( + @NotNull + @Schema(description = "멤버 ID", example = "1") + Long memberId +) { +} diff --git a/layer-api/src/main/java/org/layer/domain/auth/controller/dto/CreateTokenResponse.java b/layer-api/src/main/java/org/layer/domain/auth/controller/dto/CreateTokenResponse.java new file mode 100644 index 00000000..dd4cb9a1 --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/auth/controller/dto/CreateTokenResponse.java @@ -0,0 +1,10 @@ +package org.layer.domain.auth.controller.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "CreateTokenResponse", description = "테스트용 토큰 생성 응답 DTO") +public record CreateTokenResponse( + @Schema(description = "액세스 토큰 (100년 유효)") + String accessToken +) { +} diff --git a/layer-api/src/main/java/org/layer/domain/auth/service/AuthService.java b/layer-api/src/main/java/org/layer/domain/auth/service/AuthService.java index b314f180..0b1a97b6 100644 --- a/layer-api/src/main/java/org/layer/domain/auth/service/AuthService.java +++ b/layer-api/src/main/java/org/layer/domain/auth/service/AuthService.java @@ -142,6 +142,10 @@ public MemberInfoResponse getMemberInfo(final Long memberId) { return MemberInfoResponse.of(member); } + public CreateTokenResponse createToken(Long memberId) { + return new CreateTokenResponse(jwtService.createLongLivedToken(memberId)); + } + //== private methods ==// diff --git a/layer-api/src/main/java/org/layer/domain/reaction/controller/ReactionApi.java b/layer-api/src/main/java/org/layer/domain/reaction/controller/ReactionApi.java new file mode 100644 index 00000000..99b69654 --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/reaction/controller/ReactionApi.java @@ -0,0 +1,35 @@ +package org.layer.domain.reaction.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.layer.annotation.MemberId; +import org.layer.domain.reaction.controller.dto.response.ReactionListGetResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "반응", description = "사용 가능한 반응 관련 API") +public interface ReactionApi { + + @Operation(summary = "사용 가능한 모든 반응 조회", description = "사용 가능한 모든 회고 반응을 조회합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = ReactionListGetResponse.class)) + }) + }) + ResponseEntity getAllReactions(); + + @Operation(summary = "최근 사용한 N개의 unique 반응 조회", description = "내가 최근에 사용한 N개의 중복 없는 반응을 조회합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = ReactionListGetResponse.class)) + }) + }) + ResponseEntity getRecentReactions( + @MemberId Long memberId, + @RequestParam(name = "limit", defaultValue = "8") int limit + ); +} diff --git a/layer-api/src/main/java/org/layer/domain/reaction/controller/ReactionController.java b/layer-api/src/main/java/org/layer/domain/reaction/controller/ReactionController.java new file mode 100644 index 00000000..b178bedc --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/reaction/controller/ReactionController.java @@ -0,0 +1,34 @@ +package org.layer.domain.reaction.controller; + +import lombok.RequiredArgsConstructor; +import org.layer.annotation.MemberId; +import org.layer.domain.reaction.controller.dto.response.ReactionListGetResponse; +import org.layer.domain.reaction.service.RetrospectReactionService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/reaction") +public class ReactionController implements ReactionApi { + + private final RetrospectReactionService retrospectReactionService; + + @Override + @GetMapping + public ResponseEntity getAllReactions() { + return ResponseEntity.ok(retrospectReactionService.getAllReactions()); + } + + @Override + @GetMapping("/recent") + public ResponseEntity getRecentReactions( + @MemberId Long memberId, + @RequestParam(name = "limit", defaultValue = "8") int limit + ) { + return ResponseEntity.ok(retrospectReactionService.getRecentReactions(memberId, limit)); + } +} diff --git a/layer-api/src/main/java/org/layer/domain/reaction/controller/RetrospectReactionApi.java b/layer-api/src/main/java/org/layer/domain/reaction/controller/RetrospectReactionApi.java new file mode 100644 index 00000000..351f8b75 --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/reaction/controller/RetrospectReactionApi.java @@ -0,0 +1,53 @@ +package org.layer.domain.reaction.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.layer.annotation.MemberId; +import org.layer.domain.reaction.controller.dto.request.RetrospectReactionCreateRequest; +import org.layer.domain.reaction.controller.dto.response.RetrospectReactionListGetResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "회고 반응", description = "회고 답변에 대한 반응 관련 API") +public interface RetrospectReactionApi { + + @Operation(summary = "회고 반응 생성", description = "특정 회고 답변에 대한 반응을 생성합니다. 하나의 답변에 하나의 반응만 가능합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "회고 반응 생성 성공") + }) + ResponseEntity createReaction( + @PathVariable("spaceId") Long spaceId, + @PathVariable("retrospectId") Long retrospectId, + @RequestBody @Valid RetrospectReactionCreateRequest request, + @MemberId Long memberId + ); + + @Operation(summary = "회고 반응 삭제", description = "본인이 반응한 회고 반응을 삭제합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "회고 반응 삭제 성공") + }) + ResponseEntity deleteReaction( + @PathVariable("spaceId") Long spaceId, + @PathVariable("retrospectId") Long retrospectId, + @PathVariable("retrospectReactionId") Long retrospectReactionId, + @MemberId Long memberId + ); + + @Operation(summary = "회고 반응 조회", description = "특정 회고의 모든 답변에 대한 반응을 조회합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = RetrospectReactionListGetResponse.class)) + }) + }) + ResponseEntity getRetrospectReactions( + @PathVariable("spaceId") Long spaceId, + @PathVariable("retrospectId") Long retrospectId, + @MemberId Long memberId + ); +} diff --git a/layer-api/src/main/java/org/layer/domain/reaction/controller/RetrospectReactionController.java b/layer-api/src/main/java/org/layer/domain/reaction/controller/RetrospectReactionController.java new file mode 100644 index 00000000..a65345df --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/reaction/controller/RetrospectReactionController.java @@ -0,0 +1,54 @@ +package org.layer.domain.reaction.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.layer.annotation.MemberId; +import org.layer.domain.reaction.controller.dto.request.RetrospectReactionCreateRequest; +import org.layer.domain.reaction.controller.dto.response.RetrospectReactionListGetResponse; +import org.layer.domain.reaction.service.RetrospectReactionService; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/space/{spaceId}/retrospect/{retrospectId}/reaction") +public class RetrospectReactionController implements RetrospectReactionApi { + + private final RetrospectReactionService retrospectReactionService; + + @Override + @PostMapping + public ResponseEntity createReaction( + @PathVariable("spaceId") Long spaceId, + @PathVariable("retrospectId") Long retrospectId, + @RequestBody @Valid RetrospectReactionCreateRequest request, + @MemberId Long memberId + ) { + retrospectReactionService.createReaction(spaceId, retrospectId, request, memberId); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @Override + @DeleteMapping("/{retrospectReactionId}") + public ResponseEntity deleteReaction( + @PathVariable("spaceId") Long spaceId, + @PathVariable("retrospectId") Long retrospectId, + @PathVariable("retrospectReactionId") Long retrospectReactionId, + @MemberId Long memberId + ) { + retrospectReactionService.deleteReaction(spaceId, retrospectId, retrospectReactionId, memberId); + return ResponseEntity.ok().build(); + } + + @Override + @GetMapping + public ResponseEntity getRetrospectReactions( + @PathVariable("spaceId") Long spaceId, + @PathVariable("retrospectId") Long retrospectId, + @MemberId Long memberId + ) { + RetrospectReactionListGetResponse response = retrospectReactionService.getRetrospectReactions(spaceId, retrospectId, memberId); + return ResponseEntity.ok(response); + } +} diff --git a/layer-api/src/main/java/org/layer/domain/reaction/controller/dto/request/RetrospectReactionCreateRequest.java b/layer-api/src/main/java/org/layer/domain/reaction/controller/dto/request/RetrospectReactionCreateRequest.java new file mode 100644 index 00000000..8a4ddb1e --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/reaction/controller/dto/request/RetrospectReactionCreateRequest.java @@ -0,0 +1,16 @@ +package org.layer.domain.reaction.controller.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(name = "RetrospectReactionCreateRequest", description = "회고 반응 생성 요청 DTO") +public record RetrospectReactionCreateRequest( + @NotNull + @Schema(description = "반응 ID", example = "1") + Long reactionId, + + @NotNull + @Schema(description = "답변 ID", example = "1") + Long answerId +) { +} diff --git a/layer-api/src/main/java/org/layer/domain/reaction/controller/dto/response/AnswerReactionGetResponse.java b/layer-api/src/main/java/org/layer/domain/reaction/controller/dto/response/AnswerReactionGetResponse.java new file mode 100644 index 00000000..7022774d --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/reaction/controller/dto/response/AnswerReactionGetResponse.java @@ -0,0 +1,18 @@ +package org.layer.domain.reaction.controller.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(name = "AnswerReactionGetResponse", description = "특정 답변의 회고 반응 조회 응답 DTO") +public record AnswerReactionGetResponse( + @Schema(description = "답변 ID", example = "1") + Long answerId, + + @Schema(description = "해당 답변의 반응 목록") + List reactions +) { + public static AnswerReactionGetResponse of(Long answerId, List reactions) { + return new AnswerReactionGetResponse(answerId, reactions); + } +} diff --git a/layer-api/src/main/java/org/layer/domain/reaction/controller/dto/response/ReactionGetResponse.java b/layer-api/src/main/java/org/layer/domain/reaction/controller/dto/response/ReactionGetResponse.java new file mode 100644 index 00000000..8ac02f3a --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/reaction/controller/dto/response/ReactionGetResponse.java @@ -0,0 +1,17 @@ +package org.layer.domain.reaction.controller.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.layer.domain.reaction.entity.Reaction; + +@Schema(name = "ReactionGetResponse", description = "반응 조회 응답 DTO") +public record ReactionGetResponse( + @Schema(description = "반응 ID", example = "1") + Long id, + + @Schema(description = "반응 이미지 URL", example = "https://example.com/emoji.png") + String imgUrl +) { + public static ReactionGetResponse from(Reaction reaction) { + return new ReactionGetResponse(reaction.getId(), reaction.getImgUrl()); + } +} diff --git a/layer-api/src/main/java/org/layer/domain/reaction/controller/dto/response/ReactionListGetResponse.java b/layer-api/src/main/java/org/layer/domain/reaction/controller/dto/response/ReactionListGetResponse.java new file mode 100644 index 00000000..5c97edb2 --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/reaction/controller/dto/response/ReactionListGetResponse.java @@ -0,0 +1,15 @@ +package org.layer.domain.reaction.controller.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(name = "ReactionListGetResponse", description = "반응 목록 조회 응답 DTO") +public record ReactionListGetResponse( + @Schema(description = "반응 목록") + List reactions +) { + public static ReactionListGetResponse from(List reactions) { + return new ReactionListGetResponse(reactions); + } +} diff --git a/layer-api/src/main/java/org/layer/domain/reaction/controller/dto/response/RetrospectReactionElementResponse.java b/layer-api/src/main/java/org/layer/domain/reaction/controller/dto/response/RetrospectReactionElementResponse.java new file mode 100644 index 00000000..b7398846 --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/reaction/controller/dto/response/RetrospectReactionElementResponse.java @@ -0,0 +1,32 @@ +package org.layer.domain.reaction.controller.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.layer.domain.reaction.entity.RetrospectReaction; + +@Schema(name = "RetrospectReactionElementResponse", description = "회고 반응 요소 응답 DTO") +public record RetrospectReactionElementResponse( + @Schema(description = "회고 반응 ID", example = "1") + Long retrospectReactionId, + + @Schema(description = "반응 ID", example = "2") + Long reactionId, + + @Schema(description = "반응한 멤버 ID", example = "3") + Long memberId, + + @Schema(description = "반응한 멤버 이름", example = "홍길동") + String memberName, + + @Schema(description = "반응한 멤버 프로필 이미지 URL", example = "https://example.com/profile.png") + String memberProfileImgUrl +) { + public static RetrospectReactionElementResponse of(RetrospectReaction retrospectReaction, String memberName, String memberProfileImgUrl) { + return new RetrospectReactionElementResponse( + retrospectReaction.getId(), + retrospectReaction.getReactionId(), + retrospectReaction.getMemberId(), + memberName, + memberProfileImgUrl + ); + } +} diff --git a/layer-api/src/main/java/org/layer/domain/reaction/controller/dto/response/RetrospectReactionListGetResponse.java b/layer-api/src/main/java/org/layer/domain/reaction/controller/dto/response/RetrospectReactionListGetResponse.java new file mode 100644 index 00000000..ffa09963 --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/reaction/controller/dto/response/RetrospectReactionListGetResponse.java @@ -0,0 +1,15 @@ +package org.layer.domain.reaction.controller.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(name = "RetrospectReactionListGetResponse", description = "회고의 모든 답변에 대한 반응 조회 응답 DTO") +public record RetrospectReactionListGetResponse( + @Schema(description = "답변별 반응 목록") + List answerReactions +) { + public static RetrospectReactionListGetResponse from(List answerReactions) { + return new RetrospectReactionListGetResponse(answerReactions); + } +} diff --git a/layer-api/src/main/java/org/layer/domain/reaction/service/RetrospectReactionService.java b/layer-api/src/main/java/org/layer/domain/reaction/service/RetrospectReactionService.java new file mode 100644 index 00000000..7cd381c9 --- /dev/null +++ b/layer-api/src/main/java/org/layer/domain/reaction/service/RetrospectReactionService.java @@ -0,0 +1,143 @@ +package org.layer.domain.reaction.service; + +import lombok.RequiredArgsConstructor; +import org.layer.domain.answer.entity.Answer; +import org.layer.domain.answer.repository.AnswerRepository; +import org.layer.domain.reaction.controller.dto.request.RetrospectReactionCreateRequest; +import org.layer.domain.reaction.controller.dto.response.AnswerReactionGetResponse; +import org.layer.domain.reaction.controller.dto.response.ReactionGetResponse; +import org.layer.domain.reaction.controller.dto.response.ReactionListGetResponse; +import org.layer.domain.reaction.controller.dto.response.RetrospectReactionElementResponse; +import org.layer.domain.reaction.controller.dto.response.RetrospectReactionListGetResponse; +import org.layer.domain.reaction.entity.Reaction; +import org.layer.domain.reaction.entity.RetrospectReaction; +import org.layer.domain.reaction.exception.ReactionException; +import org.layer.domain.reaction.repository.ReactionRepository; +import org.layer.domain.reaction.repository.RetrospectReactionRepository; +import org.layer.domain.retrospect.repository.RetrospectRepository; +import org.layer.domain.member.entity.Members; +import org.layer.domain.member.repository.MemberRepository; +import org.layer.domain.space.entity.Team; +import org.layer.domain.space.repository.MemberSpaceRelationRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.layer.global.exception.ReactionExceptionType.ALREADY_REACTED; +import static org.layer.global.exception.ReactionExceptionType.FORBIDDEN_REACTION; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class RetrospectReactionService { + + private final ReactionRepository reactionRepository; + private final RetrospectReactionRepository retrospectReactionRepository; + private final AnswerRepository answerRepository; + private final RetrospectRepository retrospectRepository; + private final MemberRepository memberRepository; + private final MemberSpaceRelationRepository memberSpaceRelationRepository; + + @Transactional + public Long createReaction(Long spaceId, Long retrospectId, RetrospectReactionCreateRequest request, Long memberId) { + Team team = new Team(memberSpaceRelationRepository.findAllBySpaceId(spaceId)); + team.validateTeamMembership(memberId); + + retrospectRepository.findByIdOrThrow(retrospectId); + + reactionRepository.findByIdOrThrow(request.reactionId()); + + if (retrospectReactionRepository.existsByAnswerIdAndMemberId(request.answerId(), memberId)) { + throw new ReactionException(ALREADY_REACTED); + } + + RetrospectReaction retrospectReaction = RetrospectReaction.builder() + .reactionId(request.reactionId()) + .answerId(request.answerId()) + .memberId(memberId) + .build(); + + return retrospectReactionRepository.save(retrospectReaction).getId(); + } + + @Transactional + public void deleteReaction(Long spaceId, Long retrospectId, Long retrospectReactionId, Long memberId) { + Team team = new Team(memberSpaceRelationRepository.findAllBySpaceId(spaceId)); + team.validateTeamMembership(memberId); + + retrospectRepository.findByIdOrThrow(retrospectId); + + RetrospectReaction retrospectReaction = retrospectReactionRepository.findByIdOrThrow(retrospectReactionId); + + if (!retrospectReaction.getMemberId().equals(memberId)) { + throw new ReactionException(FORBIDDEN_REACTION); + } + + retrospectReactionRepository.delete(retrospectReaction); + } + + public RetrospectReactionListGetResponse getRetrospectReactions(Long spaceId, Long retrospectId, Long memberId) { + Team team = new Team(memberSpaceRelationRepository.findAllBySpaceId(spaceId)); + team.validateTeamMembership(memberId); + + retrospectRepository.findByIdOrThrow(retrospectId); + + List answerIds = answerRepository.findAllByRetrospectId(retrospectId) + .stream() + .map(Answer::getId) + .toList(); + + List retrospectReactions = retrospectReactionRepository.findAllByAnswerIdIn(answerIds); + + List memberIds = retrospectReactions.stream().map(RetrospectReaction::getMemberId).distinct().toList(); + Members members = new Members(memberRepository.findAllById(memberIds)); + + Map> reactionsByAnswerId = retrospectReactions.stream() + .collect(Collectors.groupingBy(RetrospectReaction::getAnswerId)); + + List answerReactions = answerIds.stream() + .map(answerId -> AnswerReactionGetResponse.of( + answerId, + reactionsByAnswerId.getOrDefault(answerId, List.of()) + .stream() + .map(r -> RetrospectReactionElementResponse.of( + r, + members.getName(r.getMemberId()), + members.getProfileImageUrl(r.getMemberId()) + )) + .toList() + )) + .toList(); + + return RetrospectReactionListGetResponse.from(answerReactions); + } + + public ReactionListGetResponse getAllReactions() { + List reactions = reactionRepository.findAll() + .stream() + .map(ReactionGetResponse::from) + .toList(); + + return ReactionListGetResponse.from(reactions); + } + + public ReactionListGetResponse getRecentReactions(Long memberId, int limit) { + List recentReactionIds = retrospectReactionRepository + .findRecentDistinctReactionIdsByMemberId(memberId, limit); + + List reactions = reactionRepository.findAllById(recentReactionIds); + + Map reactionMap = reactions.stream() + .collect(Collectors.toMap(Reaction::getId, r -> r)); + + List reactionResponses = recentReactionIds.stream() + .filter(reactionMap::containsKey) + .map(id -> ReactionGetResponse.from(reactionMap.get(id))) + .toList(); + + return ReactionListGetResponse.from(reactionResponses); + } +} diff --git a/layer-api/src/main/java/org/layer/global/exception/ApiActionItemExceptionType.java b/layer-api/src/main/java/org/layer/global/exception/ApiActionItemExceptionType.java index da301c90..62e81077 100644 --- a/layer-api/src/main/java/org/layer/global/exception/ApiActionItemExceptionType.java +++ b/layer-api/src/main/java/org/layer/global/exception/ApiActionItemExceptionType.java @@ -9,7 +9,8 @@ public enum ApiActionItemExceptionType implements ExceptionType { INVALID_ACTION_ITEM_ID(HttpStatus.BAD_REQUEST, "잘못된 실행 목표 id입니다."), INVALID_ACTION_ITEM_LIST(HttpStatus.BAD_REQUEST, "잘못된 실행 목표 리스트입니다."), - NO_PROCEEDING_ACTION_ITEMS(HttpStatus.BAD_REQUEST, "해당 스페이스에 실행 중인 실행 목표 회고가 존재하지 않습니다."); + NO_PROCEEDING_ACTION_ITEMS(HttpStatus.BAD_REQUEST, "해당 스페이스에 실행 중인 실행 목표 회고가 존재하지 않습니다."), + FORBIDDEN_ACTION_ITEM(HttpStatus.FORBIDDEN, "해당 실행 목표에 대한 권한이 없습니다."); private final HttpStatus status; diff --git a/layer-api/src/main/java/org/layer/jwt/service/JwtService.java b/layer-api/src/main/java/org/layer/jwt/service/JwtService.java index c5aec366..2b96dce5 100644 --- a/layer-api/src/main/java/org/layer/jwt/service/JwtService.java +++ b/layer-api/src/main/java/org/layer/jwt/service/JwtService.java @@ -84,4 +84,9 @@ private MemberRole getMemberRoleFromRefreshToken(String refreshToken) { public void deleteRefreshToken(Long memberId) { redisTemplate.delete(memberId.toString()); } + + public String createLongLivedToken(Long memberId) { + long hundredYears = 100L * 365 * 24 * 60 * 60 * 1000; + return jwtProvider.createToken(MemberAuthentication.create(memberId, MemberRole.USER), hundredYears); + } } diff --git a/layer-domain/src/main/java/org/layer/domain/actionItem/dto/ActionItemResponse.java b/layer-domain/src/main/java/org/layer/domain/actionItem/dto/ActionItemResponse.java index e3f9922e..3860fdea 100644 --- a/layer-domain/src/main/java/org/layer/domain/actionItem/dto/ActionItemResponse.java +++ b/layer-domain/src/main/java/org/layer/domain/actionItem/dto/ActionItemResponse.java @@ -7,6 +7,8 @@ import lombok.NoArgsConstructor; import org.layer.domain.actionItem.entity.ActionItem; +import java.time.LocalDateTime; + @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Builder @@ -17,15 +19,19 @@ public class ActionItemResponse { @NotNull String content; - public ActionItemResponse(Long actionItemId, String content) { + LocalDateTime createdAt; + + public ActionItemResponse(Long actionItemId, String content, LocalDateTime createdAt) { this.actionItemId = actionItemId; this.content = content; + this.createdAt = createdAt; } public static ActionItemResponse of(ActionItem actionItem) { return ActionItemResponse.builder() .actionItemId(actionItem.getId()) .content(actionItem.getContent()) + .createdAt(actionItem.getCreatedAt()) .build(); } } diff --git a/layer-domain/src/main/java/org/layer/domain/actionItem/entity/ActionItem.java b/layer-domain/src/main/java/org/layer/domain/actionItem/entity/ActionItem.java index bdd25041..4a20a948 100644 --- a/layer-domain/src/main/java/org/layer/domain/actionItem/entity/ActionItem.java +++ b/layer-domain/src/main/java/org/layer/domain/actionItem/entity/ActionItem.java @@ -1,6 +1,8 @@ package org.layer.domain.actionItem.entity; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -35,14 +37,19 @@ public class ActionItem extends BaseTimeEntity { @NotNull private int actionItemOrder; // 회고 내에서 실행 목표의 순서 + @Enumerated(EnumType.STRING) + @NotNull + private ActionItemType type; + @Builder - private ActionItem(Long retrospectId, Long spaceId, Long memberId, String content, int actionItemOrder) { + private ActionItem(Long retrospectId, Long spaceId, Long memberId, String content, int actionItemOrder, ActionItemType type) { this.retrospectId = retrospectId; this.spaceId = spaceId; this.memberId = memberId; this.content = content; this.actionItemOrder = actionItemOrder; + this.type = type != null ? type : ActionItemType.TEAM; } diff --git a/layer-domain/src/main/java/org/layer/domain/actionItem/entity/ActionItemType.java b/layer-domain/src/main/java/org/layer/domain/actionItem/entity/ActionItemType.java new file mode 100644 index 00000000..189159df --- /dev/null +++ b/layer-domain/src/main/java/org/layer/domain/actionItem/entity/ActionItemType.java @@ -0,0 +1,5 @@ +package org.layer.domain.actionItem.entity; + +public enum ActionItemType { + TEAM, PERSONAL +} diff --git a/layer-domain/src/main/java/org/layer/domain/actionItem/repository/ActionItemRepository.java b/layer-domain/src/main/java/org/layer/domain/actionItem/repository/ActionItemRepository.java index ed6f2ec5..179a65e1 100644 --- a/layer-domain/src/main/java/org/layer/domain/actionItem/repository/ActionItemRepository.java +++ b/layer-domain/src/main/java/org/layer/domain/actionItem/repository/ActionItemRepository.java @@ -3,6 +3,7 @@ import static org.layer.global.exception.ActionItemExceptionType.*; import org.layer.domain.actionItem.entity.ActionItem; +import org.layer.domain.actionItem.entity.ActionItemType; import org.layer.domain.actionItem.exception.ActionItemException; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; @@ -23,7 +24,13 @@ default ActionItem findByIdOrThrow(Long actionItemId) { int countByRetrospectId(Long retrospectId); List findAllByRetrospectIdIn(List retrospectIds); - + + List findAllByRetrospectIdAndMemberIdAndType(Long retrospectId, Long memberId, ActionItemType type); + + int countByRetrospectIdAndMemberIdAndType(Long retrospectId, Long memberId, ActionItemType type); + + List findAllByRetrospectIdInAndMemberIdAndType(List retrospectIds, Long memberId, ActionItemType type); + @Modifying(clearAutomatically = true) @Transactional @Query("DELETE FROM ActionItem a WHERE a.spaceId = :spaceId") diff --git a/layer-domain/src/main/java/org/layer/domain/answer/entity/Answers.java b/layer-domain/src/main/java/org/layer/domain/answer/entity/Answers.java index a621b090..15fd0d93 100644 --- a/layer-domain/src/main/java/org/layer/domain/answer/entity/Answers.java +++ b/layer-domain/src/main/java/org/layer/domain/answer/entity/Answers.java @@ -35,6 +35,14 @@ public String getAnswerToQuestion(Long questionId, Long memberId) { .orElse(null); } + public Long getAnswerIdToQuestion(Long questionId, Long memberId) { + return answers.stream() + .filter(answer -> answer.getQuestionId().equals(questionId) && answer.getMemberId().equals(memberId)) + .map(Answer::getId) + .findFirst() + .orElse(null); + } + public boolean hasRetrospectAnswer(Long memberId, Long retrospectId) { return answers.stream() .filter(answer -> answer.getRetrospectId().equals(retrospectId)) diff --git a/layer-domain/src/main/java/org/layer/domain/member/entity/Members.java b/layer-domain/src/main/java/org/layer/domain/member/entity/Members.java index 55316056..9fc85f74 100644 --- a/layer-domain/src/main/java/org/layer/domain/member/entity/Members.java +++ b/layer-domain/src/main/java/org/layer/domain/member/entity/Members.java @@ -25,4 +25,12 @@ public Boolean getDeleted(Long memberId) { .findAny() .orElse(null); } + + public String getProfileImageUrl(Long memberId) { + return members.stream() + .filter(member -> member.getId().equals(memberId)) + .findAny() + .map(Member::getProfileImageUrl) + .orElse(null); + } } diff --git a/layer-domain/src/main/java/org/layer/domain/reaction/entity/Reaction.java b/layer-domain/src/main/java/org/layer/domain/reaction/entity/Reaction.java new file mode 100644 index 00000000..0b7f4e6c --- /dev/null +++ b/layer-domain/src/main/java/org/layer/domain/reaction/entity/Reaction.java @@ -0,0 +1,30 @@ +package org.layer.domain.reaction.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.layer.domain.common.BaseTimeEntity; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Reaction extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + private String imgUrl; + + @Builder + public Reaction(String imgUrl) { + this.imgUrl = imgUrl; + } +} diff --git a/layer-domain/src/main/java/org/layer/domain/reaction/entity/RetrospectReaction.java b/layer-domain/src/main/java/org/layer/domain/reaction/entity/RetrospectReaction.java new file mode 100644 index 00000000..780c7efd --- /dev/null +++ b/layer-domain/src/main/java/org/layer/domain/reaction/entity/RetrospectReaction.java @@ -0,0 +1,38 @@ +package org.layer.domain.reaction.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.layer.domain.common.BaseTimeEntity; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RetrospectReaction extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + private Long reactionId; + + @NotNull + private Long answerId; + + @NotNull + private Long memberId; + + @Builder + public RetrospectReaction(Long reactionId, Long answerId, Long memberId) { + this.reactionId = reactionId; + this.answerId = answerId; + this.memberId = memberId; + } +} diff --git a/layer-domain/src/main/java/org/layer/domain/reaction/exception/ReactionException.java b/layer-domain/src/main/java/org/layer/domain/reaction/exception/ReactionException.java new file mode 100644 index 00000000..753e34b3 --- /dev/null +++ b/layer-domain/src/main/java/org/layer/domain/reaction/exception/ReactionException.java @@ -0,0 +1,10 @@ +package org.layer.domain.reaction.exception; + +import org.layer.common.exception.BaseCustomException; +import org.layer.common.exception.ExceptionType; + +public class ReactionException extends BaseCustomException { + public ReactionException(ExceptionType exceptionType) { + super(exceptionType); + } +} diff --git a/layer-domain/src/main/java/org/layer/domain/reaction/repository/ReactionRepository.java b/layer-domain/src/main/java/org/layer/domain/reaction/repository/ReactionRepository.java new file mode 100644 index 00000000..8272c109 --- /dev/null +++ b/layer-domain/src/main/java/org/layer/domain/reaction/repository/ReactionRepository.java @@ -0,0 +1,14 @@ +package org.layer.domain.reaction.repository; + +import org.layer.domain.reaction.entity.Reaction; +import org.layer.domain.reaction.exception.ReactionException; +import org.springframework.data.jpa.repository.JpaRepository; + +import static org.layer.global.exception.ReactionExceptionType.NOT_FOUND_REACTION; + +public interface ReactionRepository extends JpaRepository { + + default Reaction findByIdOrThrow(Long id) { + return findById(id).orElseThrow(() -> new ReactionException(NOT_FOUND_REACTION)); + } +} diff --git a/layer-domain/src/main/java/org/layer/domain/reaction/repository/RetrospectReactionRepository.java b/layer-domain/src/main/java/org/layer/domain/reaction/repository/RetrospectReactionRepository.java new file mode 100644 index 00000000..42b7bcee --- /dev/null +++ b/layer-domain/src/main/java/org/layer/domain/reaction/repository/RetrospectReactionRepository.java @@ -0,0 +1,32 @@ +package org.layer.domain.reaction.repository; + +import org.layer.domain.reaction.entity.RetrospectReaction; +import org.layer.domain.reaction.exception.ReactionException; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +import static org.layer.global.exception.ReactionExceptionType.NOT_FOUND_RETROSPECT_REACTION; + +public interface RetrospectReactionRepository extends JpaRepository { + + default RetrospectReaction findByIdOrThrow(Long id) { + return findById(id).orElseThrow(() -> new ReactionException(NOT_FOUND_RETROSPECT_REACTION)); + } + + boolean existsByAnswerIdAndMemberId(Long answerId, Long memberId); + + List findAllByAnswerIdIn(List answerIds); + + @Query(value = """ + SELECT rr.reaction_id + FROM retrospect_reaction rr + WHERE rr.member_id = :memberId + GROUP BY rr.reaction_id + ORDER BY MAX(rr.created_at) DESC + LIMIT :limit + """, nativeQuery = true) + List findRecentDistinctReactionIdsByMemberId(@Param("memberId") Long memberId, @Param("limit") int limit); +} diff --git a/layer-domain/src/main/java/org/layer/global/exception/ReactionExceptionType.java b/layer-domain/src/main/java/org/layer/global/exception/ReactionExceptionType.java new file mode 100644 index 00000000..ccb8c54c --- /dev/null +++ b/layer-domain/src/main/java/org/layer/global/exception/ReactionExceptionType.java @@ -0,0 +1,26 @@ +package org.layer.global.exception; + +import lombok.RequiredArgsConstructor; +import org.layer.common.exception.ExceptionType; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum ReactionExceptionType implements ExceptionType { + NOT_FOUND_REACTION(HttpStatus.NOT_FOUND, "존재하지 않는 반응입니다."), + NOT_FOUND_RETROSPECT_REACTION(HttpStatus.NOT_FOUND, "존재하지 않는 회고 반응입니다."), + ALREADY_REACTED(HttpStatus.BAD_REQUEST, "이미 반응한 답변입니다."), + FORBIDDEN_REACTION(HttpStatus.FORBIDDEN, "본인의 반응만 삭제할 수 있습니다."); + + private final HttpStatus status; + private final String message; + + @Override + public HttpStatus httpStatus() { + return status; + } + + @Override + public String message() { + return message; + } +}