diff --git a/commitlint.config.js b/commitlint.config.js index a664aef..bb7e447 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -22,6 +22,7 @@ module.exports = { "search", "stats", "friend", + "social", "notify", "infra", "db", @@ -123,6 +124,9 @@ module.exports = { friend: { description: '๐Ÿ‘ฅ ์นœ๊ตฌ ๋„๋ฉ”์ธ (์˜ˆ: ์นœ๊ตฌ ์‹ ์ฒญ, ์ˆ˜๋ฝ, ๋ชฉ๋ก ๊ด€๋ฆฌ)' }, + social: { + description: '๐Ÿ’ฌ ์†Œ์…œ ๋„๋ฉ”์ธ (์˜ˆ: ๋Œ“๊ธ€, ์ข‹์•„์š”, ํ”ผ๋“œ ๊ณต์œ )' + }, notify: { description: '๐Ÿ“ง ์•Œ๋ฆผ/์ด๋ฉ”์ผ ์ „์†ก (์˜ˆ: ์งˆ๋ฌธ ์•Œ๋ฆผ, ์ธ์ฆ ์ด๋ฉ”์ผ ์ „์†ก)' }, diff --git a/src/main/java/com/devkor/ifive/nadab/domain/comment/api/CommentController.java b/src/main/java/com/devkor/ifive/nadab/domain/comment/api/CommentController.java new file mode 100644 index 0000000..f61e200 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/comment/api/CommentController.java @@ -0,0 +1,294 @@ +package com.devkor.ifive.nadab.domain.comment.api; + +import com.devkor.ifive.nadab.domain.comment.api.dto.request.CreateCommentRequest; +import com.devkor.ifive.nadab.domain.comment.api.dto.request.CreateSubCommentRequest; +import com.devkor.ifive.nadab.domain.comment.api.dto.request.UpdateCommentRequest; +import com.devkor.ifive.nadab.domain.comment.api.dto.response.CommentListResponse; +import com.devkor.ifive.nadab.domain.comment.application.CommentCommandService; +import com.devkor.ifive.nadab.domain.comment.application.CommentQueryService; +import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; +import com.devkor.ifive.nadab.global.core.response.ApiResponseEntity; +import com.devkor.ifive.nadab.global.security.principal.UserPrincipal; +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.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "๋Œ“๊ธ€ API", description = "๋Œ“๊ธ€ ๋ฐ ๋Œ€๋Œ“๊ธ€ ๊ด€๋ จ API") +@RestController +@RequestMapping("${api_prefix}") +@RequiredArgsConstructor +public class CommentController { + + private final CommentCommandService commentCommandService; + private final CommentQueryService commentQueryService; + + @GetMapping("/comments") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "๋Œ“๊ธ€ ๋ชฉ๋ก ์กฐํšŒ", + description = """ + ํ”ผ๋“œ ๊ฒŒ์‹œ๊ธ€์˜ ๋Œ“๊ธ€ ๋ชฉ๋ก์„ ์ปค์„œ ๊ธฐ๋ฐ˜์œผ๋กœ ์ตœ์‹ ์ˆœ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. + + ์š”์ฒญ ํŒŒ๋ผ๋ฏธํ„ฐ: + - dailyReportId (ํ•„์ˆ˜): ๋Œ“๊ธ€์„ ์กฐํšŒํ•  ํ”ผ๋“œ ๊ฒŒ์‹œ๊ธ€ ID + - cursor (์„ ํƒ): ์ด์ „ ์‘๋‹ต์˜ nextCursor, ์ฒซ ์š”์ฒญ ์‹œ ์ƒ๋žต + + ์ปค์„œ ํŽ˜์ด์ง€๋„ค์ด์…˜ (ํŽ˜์ด์ง€๋‹น 10๊ฐœ): + - ์ฒซ ์š”์ฒญ: cursor ์—†์ด ํ˜ธ์ถœ โ†’ GET /api/v1/comments?dailyReportId=1 + - ๋‹ค์Œ ํŽ˜์ด์ง€: ์‘๋‹ต์˜ nextCursor๋ฅผ cursor๋กœ ์ „๋‹ฌ โ†’ GET /api/v1/comments?dailyReportId=1&cursor=42 + - hasNext=false์ด๋ฉด ๋งˆ์ง€๋ง‰ ํŽ˜์ด์ง€์ž…๋‹ˆ๋‹ค. + + ๋น„๋ฐ€ ๋Œ“๊ธ€ ์‘๋‹ต: + - ๊ถŒํ•œ ์žˆ์Œ (์ž‘์„ฑ์ž ๋ณธ์ธ, ๊ฒŒ์‹œ๊ธ€ ์ž‘์„ฑ์ž): canViewContent=true, ๋ชจ๋“  ํ•„๋“œ ์ •์ƒ ๋ฐ˜ํ™˜ + - ๊ถŒํ•œ ์—†์Œ: canViewContent=false, authorProfileImageUrlยทauthorNicknameยทcontentยทvisibleSubCommentCount๋Š” null ๋ฐ˜ํ™˜ + + ์‘๋‹ต ํ•„๋“œ ์šฉ๋„: + - commentId: ์ˆ˜์ •(PATCH)ยท์‚ญ์ œ(DELETE)ยท๋Œ€๋Œ“๊ธ€ ์กฐํšŒ(GET)ยท๋Œ€๋Œ“๊ธ€ ์ž‘์„ฑ(POST) API ํ˜ธ์ถœ ์‹œ path variable๋กœ ์‚ฌ์šฉ + - authorProfileImageUrlยทauthorNickname: ๋Œ“๊ธ€ ์ž‘์„ฑ์ž ํ”„๋กœํ•„ ํ‘œ์‹œ (canViewContent=false์ด๋ฉด null โ†’ ๋น„๊ณต๊ฐœ ์ฒ˜๋ฆฌ) + - content: ๋Œ“๊ธ€ ๋ณธ๋ฌธ (canViewContent=false์ด๋ฉด null โ†’ "๋น„๋ฐ€ ๋Œ“๊ธ€์ด์—์š”." ๋“ฑ์œผ๋กœ ๋Œ€์ฒด ํ‘œ์‹œ) + - createdAt: ์ž‘์„ฑ ์‹œ๊ฐ ISO 8601 timestamp (์˜ˆ: 2026-05-11T10:30:00+09:00) โ€” ํ”„๋ก ํŠธ์—์„œ ํ˜„์žฌ ์‹œ๊ฐ ๊ธฐ์ค€์œผ๋กœ ์•„๋ž˜ ๊ทœ์น™์— ๋”ฐ๋ผ ๋ณ€ํ™˜ํ•˜์—ฌ ํ‘œ์‹œ, ํƒ€์ด๋จธ๋กœ ์‹ค์‹œ๊ฐ„ ๊ฐฑ์‹  ๊ฐ€๋Šฅ + ยท 60์ดˆ ๋ฏธ๋งŒ โ†’ N์ดˆ ์ „ + ยท 60๋ถ„ ๋ฏธ๋งŒ โ†’ N๋ถ„ ์ „ + ยท 24์‹œ๊ฐ„ ๋ฏธ๋งŒ โ†’ N์‹œ๊ฐ„ ์ „ + ยท 30์ผ ๋ฏธ๋งŒ โ†’ N์ผ ์ „ + ยท 30์ผ ์ด์ƒ โ†’ ์˜ค๋ž˜ ์ „ + - isLikedยทhasLikes: ์ข‹์•„์š” ์•„์ด์ฝ˜ ์ƒํƒœ ์ œ์–ด + ยท isMine=true & hasLikes=false โ†’ ์•„์ด์ฝ˜ ๋ฏธํ‘œ์‹œ + ยท isMine=true & hasLikes=true โ†’ ์ฑ„์›Œ์ง„ ์•„์ด์ฝ˜ (๋‹จ์ˆœ ํด๋ฆญ ๋ฌด๋ฐ˜์‘, ๊ธธ๊ฒŒ ๋ˆ„๋ฅด๋ฉด ์ข‹์•„์š” ๋ฆฌ์ŠคํŠธ) + ยท isMine=false & isLiked=false โ†’ ๋นˆ ์•„์ด์ฝ˜ + ยท isMine=false & isLiked=true โ†’ ์ฑ„์›Œ์ง„ ์•„์ด์ฝ˜ + ยท canViewContent=false (๋น„๋ฐ€ ๋Œ“๊ธ€ ์—ด๋žŒ ๊ถŒํ•œ ์—†์Œ) โ†’ ์•„์ด์ฝ˜ ๋ฏธํ‘œ์‹œ + - visibleSubCommentCount: "๋‹ต๊ธ€ N๊ฐœ ๋”๋ณด๊ธฐ" ๋ฒ„ํŠผ ํ‘œ์‹œ์šฉ, null ๋˜๋Š” 0์ด๋ฉด ๋ฏธํ‘œ์‹œ + - isSecret: ๋น„๋ฐ€ ๋Œ“๊ธ€ ์—ฌ๋ถ€ (์ž๋ฌผ์‡  ์•„์ด์ฝ˜ ํ‘œ์‹œ, canViewContent=false์ด๋ฉด ๋‹ต๊ธ€ ๋ฒ„ํŠผ ๋ฏธ์ œ๊ณต) + - canViewContent: false์ด๋ฉด authorProfileImageUrlยทauthorNicknameยทcontent ๋งˆ์Šคํ‚น ์ฒ˜๋ฆฌ + - isMine: ์ˆ˜์ • ๋ฒ„ํŠผ ํ‘œ์‹œ ์—ฌ๋ถ€, ์ข‹์•„์š” ์•„์ด์ฝ˜ ๋™์ž‘ ์ œ์–ด (๋ณธ์ธ ๋Œ“๊ธ€์€ ์ข‹์•„์š” ๋ถˆ๊ฐ€) + - canDelete: ์‚ญ์ œ ๋ฒ„ํŠผ ํ‘œ์‹œ ์—ฌ๋ถ€ (๋ณธ์ธ ๋Œ“๊ธ€ ๋˜๋Š” ๋‚ด ํ”ผ๋“œ ๊ฒŒ์‹œ๊ธ€์— ๋‹ฌ๋ฆฐ ํƒ€์ธ ๋Œ“๊ธ€) + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "200", description = "์กฐํšŒ ์„ฑ๊ณต", + content = @Content(schema = @Schema(implementation = CommentListResponse.class))), + @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", content = @Content), + @ApiResponse(responseCode = "403", description = "ErrorCode: AUTH_ACCESS_DENIED - ๋ณธ์ธ ํ”ผ๋“œ ๊ฒŒ์‹œ๊ธ€์ด ์•„๋‹ˆ๊ฑฐ๋‚˜ ์นœ๊ตฌ์˜ ๊ณต์œ  ๊ฒŒ์‹œ๊ธ€์ด ์•„๋‹Œ ๊ฒฝ์šฐ", content = @Content), + @ApiResponse(responseCode = "404", description = "ErrorCode: DAILY_REPORT_NOT_FOUND", content = @Content) + } + ) + public ResponseEntity> getComments( + @AuthenticationPrincipal UserPrincipal principal, + @RequestParam Long dailyReportId, + @RequestParam(required = false) Long cursor + ) { + CommentListResponse response = commentQueryService.getComments(dailyReportId, principal.getId(), cursor); + return ApiResponseEntity.ok(response); + } + + @PostMapping("/comments") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "๋Œ“๊ธ€ ์ž‘์„ฑ", + description = """ + ํ”ผ๋“œ ๊ฒŒ์‹œ๊ธ€์— ๋Œ“๊ธ€์„ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค. + + - isSecret=true๋กœ ์ž‘์„ฑ ์‹œ ๋น„๋ฐ€ ๋Œ“๊ธ€๋กœ ์„ค์ •๋˜๋ฉฐ, ์ž‘์„ฑ์ž ๋ณธ์ธ๊ณผ ๊ฒŒ์‹œ๊ธ€ ์ž‘์„ฑ์ž๋งŒ ๋‚ด์šฉ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + - isSecret์€ ์ž‘์„ฑ ํ›„ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. (์ˆ˜์ • API์—์„œ ๋‚ด์šฉ๋งŒ ๋ณ€๊ฒฝ ๊ฐ€๋Šฅ) + - ๋Œ“๊ธ€ ์ž‘์„ฑ ์‹œ ๊ฒŒ์‹œ๊ธ€ ์ž‘์„ฑ์ž์—๊ฒŒ FCM ํ‘ธ์‹œ ์•Œ๋ฆผ์ด ์ „์†ก๋ฉ๋‹ˆ๋‹ค. (๋ณธ์ธ ํ”ผ๋“œ ๊ฒŒ์‹œ๊ธ€์— ์ž‘์„ฑ ์‹œ ์•Œ๋ฆผ ๋ฏธ์ „์†ก) + - ์ž‘์„ฑ ์„ฑ๊ณต ํ›„ GET /api/v1/comments?dailyReportId={id}๋กœ ๋ชฉ๋ก์„ ์žฌ์กฐํšŒํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "204", description = "์ž‘์„ฑ ์„ฑ๊ณต", content = @Content), + @ApiResponse(responseCode = "400", description = """ + - ErrorCode: VALIDATION_FAILED - ๋‚ด์šฉ ๋ˆ„๋ฝ ๋˜๋Š” 500์ž ์ดˆ๊ณผ + - ErrorCode: SOCIAL_SUSPENDED - ์†Œ์…œ ์ •์ง€ ์ค‘ + """, content = @Content), + @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", content = @Content), + @ApiResponse(responseCode = "403", description = "ErrorCode: AUTH_ACCESS_DENIED - ๋ณธ์ธ ํ”ผ๋“œ ๊ฒŒ์‹œ๊ธ€์ด ์•„๋‹ˆ๊ฑฐ๋‚˜ ์นœ๊ตฌ์˜ ๊ณต์œ  ๊ฒŒ์‹œ๊ธ€์ด ์•„๋‹Œ ๊ฒฝ์šฐ", content = @Content), + @ApiResponse(responseCode = "404", description = "ErrorCode: DAILY_REPORT_NOT_FOUND", content = @Content) + } + ) + public ResponseEntity> createComment( + @AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody CreateCommentRequest request + ) { + commentCommandService.createComment( + request.dailyReportId(), principal.getId(), request.content(), request.isSecret()); + return ApiResponseEntity.noContent(); + } + + @GetMapping("/comments/{commentId}/sub-comments") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "๋Œ€๋Œ“๊ธ€ ๋ชฉ๋ก ์กฐํšŒ", + description = """ + ํŠน์ • ๋Œ“๊ธ€์˜ ๋Œ€๋Œ“๊ธ€ ๋ชฉ๋ก์„ ์ปค์„œ ๊ธฐ๋ฐ˜์œผ๋กœ ์ตœ์‹ ์ˆœ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. + + ์š”์ฒญ ํŒŒ๋ผ๋ฏธํ„ฐ: + - commentId (path, ํ•„์ˆ˜): ๋Œ€๋Œ“๊ธ€์„ ์กฐํšŒํ•  ๋ถ€๋ชจ ๋Œ“๊ธ€ ID + - cursor (์„ ํƒ): ์ด์ „ ์‘๋‹ต์˜ nextCursor, ์ฒซ ์š”์ฒญ ์‹œ ์ƒ๋žต (ํŽ˜์ด์ง€๋‹น 10๊ฐœ) + + ์ปค์„œ ํŽ˜์ด์ง€๋„ค์ด์…˜: + - ์ฒซ ์š”์ฒญ: cursor ์—†์ด ํ˜ธ์ถœ โ†’ GET /api/v1/comments/42/sub-comments + - ๋‹ค์Œ ํŽ˜์ด์ง€: ์‘๋‹ต์˜ nextCursor๋ฅผ cursor๋กœ ์ „๋‹ฌ โ†’ GET /api/v1/comments/42/sub-comments?cursor=99 + - hasNext=false์ด๋ฉด ๋งˆ์ง€๋ง‰ ํŽ˜์ด์ง€์ž…๋‹ˆ๋‹ค. + + ๋น„๋ฐ€ ๋Œ€๋Œ“๊ธ€ ์‘๋‹ต: + - ๊ถŒํ•œ ์žˆ์Œ (์ž‘์„ฑ์ž ๋ณธ์ธ, ๊ฒŒ์‹œ๊ธ€ ์ž‘์„ฑ์ž, ๋ถ€๋ชจ ๋Œ“๊ธ€ ์ž‘์„ฑ์ž): canViewContent=true, ๋ชจ๋“  ํ•„๋“œ ์ •์ƒ ๋ฐ˜ํ™˜ + - ๊ถŒํ•œ ์—†์Œ: canViewContent=false, authorProfileImageUrlยทauthorNicknameยทcontent๋Š” null ๋ฐ˜ํ™˜ + + "N๊ฐœ ๋”๋ณด๊ธฐ" ๋ฒ„ํŠผ ์นด์šดํŠธ ๊ณ„์‚ฐ: + - ์ฒซ ์ง„์ž… ์‹œ: ๋Œ“๊ธ€ ๋ชฉ๋ก ์กฐํšŒ(GET /comments) ์‘๋‹ต์˜ visibleSubCommentCount ๊ฐ’์„ ์‚ฌ์šฉ + - ๋Œ€๋Œ“๊ธ€ ๋กœ๋“œ ํ›„ hasNext=true์ด๋ฉด: visibleSubCommentCount - ํ˜„์žฌ๊นŒ์ง€ ๋กœ๋“œํ•œ ๋Œ€๋Œ“๊ธ€ ์ˆ˜ = ๋‚จ์€ ๊ฐœ์ˆ˜๋กœ ๊ฐฑ์‹ ํ•˜์—ฌ ํ‘œ์‹œ (ํ”„๋ก ํŠธ์—์„œ ์ง์ ‘ ๊ณ„์‚ฐ ๋ฐ ๊ฐฑ์‹ ) + - ์˜ˆ) visibleSubCommentCount=12 โ†’ 10๊ฐœ ๋กœ๋“œ ํ›„ "2๊ฐœ ๋”๋ณด๊ธฐ" ํ‘œ์‹œ + + ์‘๋‹ต ํ•„๋“œ ์šฉ๋„: + - commentId: ์ˆ˜์ •(PATCH)ยท์‚ญ์ œ(DELETE) API ํ˜ธ์ถœ ์‹œ path variable๋กœ ์‚ฌ์šฉ + - authorProfileImageUrlยทauthorNickname: ๋Œ€๋Œ“๊ธ€ ์ž‘์„ฑ์ž ํ”„๋กœํ•„ ํ‘œ์‹œ (canViewContent=false์ด๋ฉด null โ†’ ๋น„๊ณต๊ฐœ ์ฒ˜๋ฆฌ) + - content: ๋Œ€๋Œ“๊ธ€ ๋ณธ๋ฌธ (canViewContent=false์ด๋ฉด null โ†’ "๋น„๋ฐ€ ๋Œ“๊ธ€์ด์—์š”." ๋“ฑ์œผ๋กœ ๋Œ€์ฒด ํ‘œ์‹œ) + - createdAt: ์ž‘์„ฑ ์‹œ๊ฐ ISO 8601 timestamp โ€” ๋Œ“๊ธ€ ๋ชฉ๋ก ์กฐํšŒ์™€ ๋™์ผํ•œ ๋ณ€ํ™˜ ๊ทœ์น™ ์ ์šฉ + - isLikedยทhasLikes: ์ข‹์•„์š” ์•„์ด์ฝ˜ ์ƒํƒœ ์ œ์–ด + ยท isMine=true & hasLikes=false โ†’ ์•„์ด์ฝ˜ ๋ฏธํ‘œ์‹œ + ยท isMine=true & hasLikes=true โ†’ ์ฑ„์›Œ์ง„ ์•„์ด์ฝ˜ (๋‹จ์ˆœ ํด๋ฆญ ๋ฌด๋ฐ˜์‘, ๊ธธ๊ฒŒ ๋ˆ„๋ฅด๋ฉด ์ข‹์•„์š” ๋ฆฌ์ŠคํŠธ) + ยท isMine=false & isLiked=false โ†’ ๋นˆ ์•„์ด์ฝ˜ + ยท isMine=false & isLiked=true โ†’ ์ฑ„์›Œ์ง„ ์•„์ด์ฝ˜ + ยท canViewContent=false (๋น„๋ฐ€ ๋Œ“๊ธ€ ์—ด๋žŒ ๊ถŒํ•œ ์—†์Œ) โ†’ ์•„์ด์ฝ˜ ๋ฏธํ‘œ์‹œ + - visibleSubCommentCount: ๋Œ€๋Œ“๊ธ€์—์„œ๋Š” ํ•ญ์ƒ null + - isSecret: ๋น„๋ฐ€ ๋Œ“๊ธ€ ์—ฌ๋ถ€ (์ž๋ฌผ์‡  ์•„์ด์ฝ˜ ํ‘œ์‹œ) + - canViewContent: false์ด๋ฉด authorProfileImageUrlยทauthorNicknameยทcontent ๋งˆ์Šคํ‚น ์ฒ˜๋ฆฌ + ยท ๋Œ€๋Œ“๊ธ€ ์—ด๋žŒ ๊ถŒํ•œ: ์ž‘์„ฑ์ž ๋ณธ์ธ, ๊ฒŒ์‹œ๊ธ€ ์ž‘์„ฑ์ž, ๋ถ€๋ชจ ๋Œ“๊ธ€ ์ž‘์„ฑ์ž + - isMine: ์ˆ˜์ • ๋ฒ„ํŠผ ํ‘œ์‹œ ์—ฌ๋ถ€, ์ข‹์•„์š” ์•„์ด์ฝ˜ ๋™์ž‘ ์ œ์–ด (๋ณธ์ธ ๋Œ“๊ธ€์€ ์ข‹์•„์š” ๋ถˆ๊ฐ€) + - canDelete: ์‚ญ์ œ ๋ฒ„ํŠผ ํ‘œ์‹œ ์—ฌ๋ถ€ (๋ณธ์ธ ๋Œ€๋Œ“๊ธ€ ๋˜๋Š” ๋‚ด ํ”ผ๋“œ ๊ฒŒ์‹œ๊ธ€์— ๋‹ฌ๋ฆฐ ํƒ€์ธ ๋Œ€๋Œ“๊ธ€) + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "200", description = "์กฐํšŒ ์„ฑ๊ณต", + content = @Content(schema = @Schema(implementation = CommentListResponse.class))), + @ApiResponse(responseCode = "400", description = "ErrorCode: COMMENT_NOT_TOP_LEVEL - commentId๊ฐ€ ๋Œ€๋Œ“๊ธ€ ID์ธ ๊ฒฝ์šฐ (๋Œ€๋Œ“๊ธ€์˜ ๋Œ€๋Œ“๊ธ€ ๋ถˆ๊ฐ€)", content = @Content), + @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", content = @Content), + @ApiResponse(responseCode = "403", description = """ + - ErrorCode: AUTH_ACCESS_DENIED - ๋ณธ์ธ ํ”ผ๋“œ ๊ฒŒ์‹œ๊ธ€์ด ์•„๋‹ˆ๊ฑฐ๋‚˜ ์นœ๊ตฌ์˜ ๊ณต์œ  ๊ฒŒ์‹œ๊ธ€์ด ์•„๋‹Œ ๊ฒฝ์šฐ + - ErrorCode: AUTH_ACCESS_DENIED - ๋น„๋ฐ€ ๋Œ“๊ธ€์— ๋Œ€ํ•œ ์—ด๋žŒ ๊ถŒํ•œ ์—†์Œ + """, content = @Content), + @ApiResponse(responseCode = "404", description = "ErrorCode: COMMENT_NOT_FOUND", content = @Content) + } + ) + public ResponseEntity> getSubComments( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long commentId, + @RequestParam(required = false) Long cursor + ) { + CommentListResponse response = commentQueryService.getSubComments(commentId, principal.getId(), cursor); + return ApiResponseEntity.ok(response); + } + + @PostMapping("/comments/{commentId}/sub-comments") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "๋Œ€๋Œ“๊ธ€ ์ž‘์„ฑ", + description = """ + ํŠน์ • ๋Œ“๊ธ€์— ๋Œ€๋Œ“๊ธ€์„ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค. + + - ๋ถ€๋ชจ ๋Œ“๊ธ€์ด ๋น„๋ฐ€ ๋Œ“๊ธ€์ธ ๊ฒฝ์šฐ, isSecret ๊ฐ’๊ณผ ๋ฌด๊ด€ํ•˜๊ฒŒ ๋Œ€๋Œ“๊ธ€๋„ ๊ฐ•์ œ๋กœ ๋น„๋ฐ€ ์ฒ˜๋ฆฌ๋ฉ๋‹ˆ๋‹ค. ์ด ๊ฒฝ์šฐ isSecret ํ† ๊ธ€์„ ์‚ฌ์šฉํ•˜์ง€ ๋ชปํ•˜๊ฒŒ ํ•˜๊ณ  isSecret=true๋กœ ๊ณ ์ • ์ „์†ก์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค. + - isSecret์€ ์ž‘์„ฑ ํ›„ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. (์ˆ˜์ • API์—์„œ ๋‚ด์šฉ๋งŒ ๋ณ€๊ฒฝ ๊ฐ€๋Šฅ) + - ๋Œ€๋Œ“๊ธ€์— ๋Œ€ํ•œ ๋Œ€๋Œ“๊ธ€์€ ๋ถˆ๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. + - ๋Œ€๋Œ“๊ธ€ ์ž‘์„ฑ ์‹œ ๋ถ€๋ชจ ๋Œ“๊ธ€ ์ž‘์„ฑ์ž์—๊ฒŒ FCM ํ‘ธ์‹œ ์•Œ๋ฆผ์ด ์ „์†ก๋ฉ๋‹ˆ๋‹ค. + - ํ•ด๋‹น ๋Œ“๊ธ€์— ์ด๋ฏธ ๋Œ€๋Œ“๊ธ€์„ ๋‹จ ๋‹ค๋ฅธ ์ฐธ์—ฌ์ž๋“ค์—๊ฒŒ๋„ ์•Œ๋ฆผ์ด ์ „์†ก๋ฉ๋‹ˆ๋‹ค. + - ์ž‘์„ฑ ์„ฑ๊ณต ํ›„ GET /api/v1/comments/{commentId}/sub-comments๋กœ ๋ชฉ๋ก์„ ์žฌ์กฐํšŒํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. + - ๋Œ€๋Œ“๊ธ€ ์ž‘์„ฑ ์„ฑ๊ณต ์‹œ ๋Œ“๊ธ€ ๋ชฉ๋ก์˜ visibleSubCommentCount๊ฐ€ ๊ฐฑ์‹ ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. "๋‹ต๊ธ€ N๊ฐœ ๋”๋ณด๊ธฐ" ์นด์šดํŠธ๋ฅผ ์ตœ์‹ ํ™”ํ•˜๋ ค๋ฉด ์„ฑ๊ณต ์‹œ ๋กœ์ปฌ์—์„œ +1 ์—…๋ฐ์ดํŠธํ•˜๊ฑฐ๋‚˜ GET /api/v1/comments?dailyReportId={id}๋กœ ๋Œ“๊ธ€ ๋ชฉ๋ก๋„ ์žฌ์กฐํšŒํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "204", description = "์ž‘์„ฑ ์„ฑ๊ณต", content = @Content), + @ApiResponse(responseCode = "400", description = """ + - ErrorCode: VALIDATION_FAILED - ๋‚ด์šฉ ๋ˆ„๋ฝ ๋˜๋Š” 500์ž ์ดˆ๊ณผ + - ErrorCode: COMMENT_NOT_TOP_LEVEL - ๋Œ€๋Œ“๊ธ€์— ๋Œ€๋Œ“๊ธ€ ์‹œ๋„ + - ErrorCode: SOCIAL_SUSPENDED - ์†Œ์…œ ์ •์ง€ ์ค‘ + """, content = @Content), + @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", content = @Content), + @ApiResponse(responseCode = "403", description = "ErrorCode: AUTH_ACCESS_DENIED - ๋ณธ์ธ ํ”ผ๋“œ ๊ฒŒ์‹œ๊ธ€์ด ์•„๋‹ˆ๊ฑฐ๋‚˜ ์นœ๊ตฌ์˜ ๊ณต์œ  ๊ฒŒ์‹œ๊ธ€์ด ์•„๋‹Œ ๊ฒฝ์šฐ", content = @Content), + @ApiResponse(responseCode = "409", description = "ErrorCode: COMMENT_DELETED - ์ด๋ฏธ ์‚ญ์ œ๋œ ๋Œ“๊ธ€์— ๋Œ€๋Œ“๊ธ€ ์‹œ๋„", content = @Content) + } + ) + public ResponseEntity> createSubComment( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long commentId, + @Valid @RequestBody CreateSubCommentRequest request + ) { + commentCommandService.createSubComment( + commentId, principal.getId(), request.content(), request.isSecret()); + return ApiResponseEntity.noContent(); + } + + @PatchMapping("/comments/{commentId}") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "๋Œ“๊ธ€ ์ˆ˜์ •", + description = """ + ๋ณธ์ธ์ด ์ž‘์„ฑํ•œ ๋Œ“๊ธ€/๋Œ€๋Œ“๊ธ€์˜ ๋‚ด์šฉ์„ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค. + + - ์ž‘์„ฑ์ž ๋ณธ์ธ๋งŒ ์ˆ˜์ • ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. (๊ฒŒ์‹œ๊ธ€ ์ž‘์„ฑ์ž๋„ ํƒ€์ธ ๋Œ“๊ธ€ ์ˆ˜์ • ๋ถˆ๊ฐ€) + - ๋‚ด์šฉ(content)๋งŒ ๋ณ€๊ฒฝ ๊ฐ€๋Šฅํ•˜๋ฉฐ, ๋น„๋ฐ€ ์—ฌ๋ถ€(isSecret)๋Š” ๋ณ€๊ฒฝํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. + - 1์ž ์ด์ƒ 500์ž ์ดํ•˜๋กœ ์ž…๋ ฅํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "204", description = "์ˆ˜์ • ์„ฑ๊ณต", content = @Content), + @ApiResponse(responseCode = "400", description = """ + - ErrorCode: VALIDATION_FAILED - ๋‚ด์šฉ ๋ˆ„๋ฝ ๋˜๋Š” 500์ž ์ดˆ๊ณผ + - ErrorCode: SOCIAL_SUSPENDED - ์†Œ์…œ ์ •์ง€ ์ค‘ + """, content = @Content), + @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", content = @Content), + @ApiResponse(responseCode = "403", description = "ErrorCode: AUTH_ACCESS_DENIED - ๋ณธ์ธ ๋Œ“๊ธ€๋งŒ ์ˆ˜์ • ๊ฐ€๋Šฅ", content = @Content), + @ApiResponse(responseCode = "404", description = "ErrorCode: COMMENT_NOT_FOUND", content = @Content) + } + ) + public ResponseEntity> updateComment( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long commentId, + @Valid @RequestBody UpdateCommentRequest request + ) { + commentCommandService.updateComment(commentId, principal.getId(), request.content()); + return ApiResponseEntity.noContent(); + } + + @DeleteMapping("/comments/{commentId}") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "๋Œ“๊ธ€ ์‚ญ์ œ", + description = """ + ๋Œ“๊ธ€/๋Œ€๋Œ“๊ธ€์„ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค. + + ์‚ญ์ œ ๊ถŒํ•œ: + - ๋ณธ์ธ์ด ์ž‘์„ฑํ•œ ๋Œ“๊ธ€/๋Œ€๋Œ“๊ธ€: ์ž‘์„ฑ์ž ๋ณธ์ธ + - ๋‚ด ํ”ผ๋“œ ๊ฒŒ์‹œ๊ธ€์— ๋‹ฌ๋ฆฐ ํƒ€์ธ์˜ ๋Œ“๊ธ€/๋Œ€๋Œ“๊ธ€: ๊ฒŒ์‹œ๊ธ€ ์ž‘์„ฑ์ž + - ํƒ€์ธ ํ”ผ๋“œ ๊ฒŒ์‹œ๊ธ€์˜ ํƒ€์ธ ๋Œ“๊ธ€: ์‚ญ์ œ ๋ถˆ๊ฐ€ + + - ๋Œ“๊ธ€ ์‚ญ์ œ ์‹œ ํ•˜์œ„ ๋Œ€๋Œ“๊ธ€๋„ ํ•จ๊ป˜ ์‚ญ์ œ๋ฉ๋‹ˆ๋‹ค. + - ์‹ ๊ณ  ์ด๋ ฅ์ด ์žˆ๋Š” ๋Œ“๊ธ€๋„ ์‚ญ์ œ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "204", description = "์‚ญ์ œ ์„ฑ๊ณต", content = @Content), + @ApiResponse(responseCode = "400", description = "ErrorCode: SOCIAL_SUSPENDED - ์†Œ์…œ ์ •์ง€ ์ค‘", content = @Content), + @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", content = @Content), + @ApiResponse(responseCode = "403", description = "ErrorCode: AUTH_ACCESS_DENIED - ์‚ญ์ œ ๊ถŒํ•œ ์—†์Œ", content = @Content), + @ApiResponse(responseCode = "404", description = "ErrorCode: COMMENT_NOT_FOUND", content = @Content), + @ApiResponse(responseCode = "409", description = "ErrorCode: COMMENT_DELETED - ์ด๋ฏธ ์‚ญ์ œ๋œ ๋Œ“๊ธ€", content = @Content) + } + ) + public ResponseEntity> deleteComment( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long commentId + ) { + commentCommandService.deleteComment(commentId, principal.getId()); + return ApiResponseEntity.noContent(); + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/comment/api/dto/request/CreateCommentRequest.java b/src/main/java/com/devkor/ifive/nadab/domain/comment/api/dto/request/CreateCommentRequest.java new file mode 100644 index 0000000..a577f5f --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/comment/api/dto/request/CreateCommentRequest.java @@ -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 +) { +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/comment/api/dto/request/CreateSubCommentRequest.java b/src/main/java/com/devkor/ifive/nadab/domain/comment/api/dto/request/CreateSubCommentRequest.java new file mode 100644 index 0000000..7368153 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/comment/api/dto/request/CreateSubCommentRequest.java @@ -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 +) { +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/comment/api/dto/request/UpdateCommentRequest.java b/src/main/java/com/devkor/ifive/nadab/domain/comment/api/dto/request/UpdateCommentRequest.java new file mode 100644 index 0000000..6ffc6cc --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/comment/api/dto/request/UpdateCommentRequest.java @@ -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 +) { +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/comment/api/dto/response/CommentListResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/comment/api/dto/response/CommentListResponse.java new file mode 100644 index 0000000..a3ee4e0 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/comment/api/dto/response/CommentListResponse.java @@ -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 comments, + + @Schema(description = "๋‹ค์Œ ํŽ˜์ด์ง€ ์ปค์„œ (์—†์œผ๋ฉด null)") + Long nextCursor, + + @Schema(description = "๋‹ค์Œ ํŽ˜์ด์ง€ ์กด์žฌ ์—ฌ๋ถ€") + boolean hasNext +) { +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/comment/api/dto/response/CommentResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/comment/api/dto/response/CommentResponse.java new file mode 100644 index 0000000..9caba13 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/comment/api/dto/response/CommentResponse.java @@ -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 + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/comment/application/CommentCommandService.java b/src/main/java/com/devkor/ifive/nadab/domain/comment/application/CommentCommandService.java new file mode 100644 index 0000000..179d5ea --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/comment/application/CommentCommandService.java @@ -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)); + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/comment/application/CommentQueryService.java b/src/main/java/com/devkor/ifive/nadab/domain/comment/application/CommentQueryService.java new file mode 100644 index 0000000..0381459 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/comment/application/CommentQueryService.java @@ -0,0 +1,211 @@ +package com.devkor.ifive.nadab.domain.comment.application; + +import com.devkor.ifive.nadab.domain.comment.api.dto.response.CommentListResponse; +import com.devkor.ifive.nadab.domain.comment.api.dto.response.CommentResponse; +import com.devkor.ifive.nadab.domain.comment.core.dto.SubCommentCountDto; +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.repository.DailyReportRepository; +import com.devkor.ifive.nadab.domain.friend.core.repository.FriendshipRepository; +import com.devkor.ifive.nadab.domain.like.core.repository.CommentLikeRepository; +import com.devkor.ifive.nadab.domain.moderation.application.SharingSuspensionService; +import com.devkor.ifive.nadab.domain.moderation.core.repository.UserBlockRepository; +import com.devkor.ifive.nadab.domain.user.infra.ProfileImageUrlBuilder; +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.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CommentQueryService { + + private static final int DEFAULT_PAGE_SIZE = 10; + + private final CommentRepository commentRepository; + private final DailyReportRepository dailyReportRepository; + private final FriendshipRepository friendshipRepository; + private final UserBlockRepository userBlockRepository; + private final CommentLikeRepository commentLikeRepository; + private final ProfileImageUrlBuilder profileImageUrlBuilder; + private final SharingSuspensionService sharingSuspensionService; + + public CommentListResponse getComments(Long dailyReportId, Long currentUserId, Long cursor) { + Long reportOwnerId = dailyReportRepository.findReportOwnerIdById(dailyReportId) + .orElseThrow(() -> new NotFoundException(ErrorCode.DAILY_REPORT_NOT_FOUND)); + checkCommentViewAccess(dailyReportId, reportOwnerId, currentUserId); + List excludedUserIds = getExcludedUserIds(currentUserId); + + List comments = commentRepository.findTopLevelComments( + dailyReportId, cursor, excludedUserIds, currentUserId, PageRequest.of(0, DEFAULT_PAGE_SIZE + 1)); + + boolean hasNext = comments.size() > DEFAULT_PAGE_SIZE; + if (hasNext) { + comments = comments.subList(0, DEFAULT_PAGE_SIZE); + } + Long nextCursor = hasNext ? comments.get(comments.size() - 1).getId() : null; + + Map subCountMap = buildSubCountMap(comments, excludedUserIds, currentUserId, reportOwnerId); + + List commentIds = comments.stream().map(Comment::getId).toList(); + Set likedCommentIds = commentIds.isEmpty() ? Set.of() + : new HashSet<>(commentLikeRepository.findLikedCommentIds(commentIds, currentUserId)); + Set commentIdsWithLikes = commentIds.isEmpty() ? Set.of() + : new HashSet<>(commentLikeRepository.findCommentIdsWithLikes(commentIds)); + + List responses = comments.stream() + .map(c -> { + boolean isMine = c.getAuthor().getId().equals(currentUserId); + boolean canViewContent = !c.isSecret() || isMine || currentUserId.equals(reportOwnerId); + boolean canDelete = isMine || currentUserId.equals(reportOwnerId); + return CommentResponse.from( + c.getId(), + canViewContent ? profileImageUrlBuilder.buildUserProfileUrl(c.getAuthor()) : null, + canViewContent ? c.getAuthor().getNickname() : null, + canViewContent ? c.getContent() : null, + c.getCreatedAt(), + canViewContent && likedCommentIds.contains(c.getId()), + canViewContent && commentIdsWithLikes.contains(c.getId()), + canViewContent ? subCountMap.getOrDefault(c.getId(), 0L).intValue() : null, + c.isSecret(), + canViewContent, + isMine, + canDelete + ); + }) + .collect(Collectors.toList()); + + return new CommentListResponse(responses, nextCursor, hasNext); + } + + public CommentListResponse getSubComments(Long parentCommentId, Long currentUserId, Long cursor) { + Comment parentComment = commentRepository.findByIdWithAuthorAndDailyReport(parentCommentId) + .orElseThrow(() -> commentRepository.existsById(parentCommentId) + ? new ConflictException(ErrorCode.COMMENT_DELETED) + : new NotFoundException(ErrorCode.COMMENT_NOT_FOUND)); + + if (!parentComment.isTopLevel()) { + throw new BadRequestException(ErrorCode.COMMENT_NOT_TOP_LEVEL); + } + + Long dailyReportId = parentComment.getDailyReport().getId(); + Long reportOwnerId = dailyReportRepository.findReportOwnerIdById(dailyReportId) + .orElseThrow(() -> new NotFoundException(ErrorCode.DAILY_REPORT_NOT_FOUND)); + checkCommentViewAccess(dailyReportId, reportOwnerId, currentUserId); + + if (parentComment.isSecret()) { + boolean canViewParent = parentComment.getAuthor().getId().equals(currentUserId) + || reportOwnerId.equals(currentUserId); + if (!canViewParent) { + throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED); + } + } + + Long parentCommentAuthorId = parentComment.getAuthor().getId(); + List excludedUserIds = getExcludedUserIds(currentUserId); + + List subComments = commentRepository.findSubComments( + parentCommentId, cursor, excludedUserIds, currentUserId, PageRequest.of(0, DEFAULT_PAGE_SIZE + 1)); + + boolean hasNext = subComments.size() > DEFAULT_PAGE_SIZE; + if (hasNext) { + subComments = subComments.subList(0, DEFAULT_PAGE_SIZE); + } + Long nextCursor = hasNext ? subComments.get(subComments.size() - 1).getId() : null; + + List subCommentIds = subComments.stream().map(Comment::getId).toList(); + Set likedSubCommentIds = subCommentIds.isEmpty() ? Set.of() + : new HashSet<>(commentLikeRepository.findLikedCommentIds(subCommentIds, currentUserId)); + Set subCommentIdsWithLikes = subCommentIds.isEmpty() ? Set.of() + : new HashSet<>(commentLikeRepository.findCommentIdsWithLikes(subCommentIds)); + + List responses = subComments.stream() + .map(c -> { + boolean isMine = c.getAuthor().getId().equals(currentUserId); + boolean canViewContent = !c.isSecret() + || isMine + || currentUserId.equals(reportOwnerId) + || currentUserId.equals(parentCommentAuthorId); + boolean canDelete = isMine || currentUserId.equals(reportOwnerId); + return CommentResponse.from( + c.getId(), + canViewContent ? profileImageUrlBuilder.buildUserProfileUrl(c.getAuthor()) : null, + canViewContent ? c.getAuthor().getNickname() : null, + canViewContent ? c.getContent() : null, + c.getCreatedAt(), + canViewContent && likedSubCommentIds.contains(c.getId()), + canViewContent && subCommentIdsWithLikes.contains(c.getId()), + null, + c.isSecret(), + canViewContent, + isMine, + canDelete + ); + }) + .collect(Collectors.toList()); + + return new CommentListResponse(responses, nextCursor, hasNext); + } + + private Map buildSubCountMap(List comments, List excludedUserIds, + Long currentUserId, Long reportOwnerId) { + if (comments.isEmpty()) { + return Map.of(); + } + List parentIds = comments.stream().map(Comment::getId).toList(); + + // ํ˜„์žฌ ์‚ฌ์šฉ์ž๊ฐ€ ๋น„๋ฐ€ ๋Œ€๋Œ“๊ธ€์„ ์—ด๋žŒํ•  ์ˆ˜ ์žˆ๋Š” ๋ถ€๋ชจ ๋Œ“๊ธ€ ID ๋ชฉ๋ก + // - ๋ฆฌํฌํŠธ ์†Œ์œ ์ž: ๋ชจ๋“  ๋ถ€๋ชจ ๋Œ“๊ธ€์˜ ๋น„๋ฐ€ ๋Œ€๋Œ“๊ธ€ ์—ด๋žŒ ๊ฐ€๋Šฅ + // - ๊ทธ ์™ธ: ์ž์‹ ์ด ์ž‘์„ฑํ•œ ๋ถ€๋ชจ ๋Œ“๊ธ€์˜ ๋น„๋ฐ€ ๋Œ€๋Œ“๊ธ€๋งŒ ์—ด๋žŒ ๊ฐ€๋Šฅ + List visibleSecretParentIds; + if (currentUserId.equals(reportOwnerId)) { + visibleSecretParentIds = parentIds; + } else { + visibleSecretParentIds = comments.stream() + .filter(c -> c.getAuthor().getId().equals(currentUserId)) + .map(Comment::getId) + .toList(); + if (visibleSecretParentIds.isEmpty()) { + visibleSecretParentIds = List.of(-1L); + } + } + + return commentRepository.countVisibleSubCommentsByParentIds( + parentIds, excludedUserIds, currentUserId, visibleSecretParentIds) + .stream() + .collect(Collectors.toMap(SubCommentCountDto::parentCommentId, SubCommentCountDto::count)); + } + + private void checkCommentViewAccess(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); + } + } + + private List getExcludedUserIds(Long userId) { + List blocked = userBlockRepository.findBlockedUserIdsBidirectional(userId); + List suspended = sharingSuspensionService.getAllActiveSuspendedUserIds(); + + Set combined = new HashSet<>(blocked); + combined.addAll(suspended); + combined.remove(userId); + + return combined.isEmpty() ? List.of(-1L) : new ArrayList<>(combined); + } + +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/comment/application/event/CommentCreatedEvent.java b/src/main/java/com/devkor/ifive/nadab/domain/comment/application/event/CommentCreatedEvent.java new file mode 100644 index 0000000..bcf06ea --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/comment/application/event/CommentCreatedEvent.java @@ -0,0 +1,15 @@ +package com.devkor.ifive.nadab.domain.comment.application.event; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class CommentCreatedEvent { + + private final Long commentId; + private final Long dailyReportId; + private final Long authorId; + private final Long reportOwnerId; + private final String content; +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/comment/application/event/SubCommentCreatedEvent.java b/src/main/java/com/devkor/ifive/nadab/domain/comment/application/event/SubCommentCreatedEvent.java new file mode 100644 index 0000000..2443ec8 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/comment/application/event/SubCommentCreatedEvent.java @@ -0,0 +1,17 @@ +package com.devkor.ifive.nadab.domain.comment.application.event; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class SubCommentCreatedEvent { + + private final Long subCommentId; + private final Long dailyReportId; + private final Long authorId; + private final Long parentCommentId; + private final Long parentCommentAuthorId; + private final Long reportOwnerId; + private final String content; +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/comment/core/dto/SubCommentCountDto.java b/src/main/java/com/devkor/ifive/nadab/domain/comment/core/dto/SubCommentCountDto.java new file mode 100644 index 0000000..9ac5f48 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/comment/core/dto/SubCommentCountDto.java @@ -0,0 +1,4 @@ +package com.devkor.ifive.nadab.domain.comment.core.dto; + +public record SubCommentCountDto(Long parentCommentId, Long count) { +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/comment/core/entity/Comment.java b/src/main/java/com/devkor/ifive/nadab/domain/comment/core/entity/Comment.java new file mode 100644 index 0000000..0e23ab7 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/comment/core/entity/Comment.java @@ -0,0 +1,66 @@ +package com.devkor.ifive.nadab.domain.comment.core.entity; + +import com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReport; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.global.shared.entity.SoftDeletableEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "comments") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Comment extends SoftDeletableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "daily_report_id", nullable = false) + private DailyReport dailyReport; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "author_id", nullable = false) + private User author; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_comment_id") + private Comment parentComment; + + @Column(name = "content", nullable = false, length = 500) + private String content; + + @Column(name = "is_secret", nullable = false) + private boolean secret; + + public static Comment createTopLevel(DailyReport dailyReport, User author, String content, boolean isSecret) { + Comment comment = new Comment(); + comment.dailyReport = dailyReport; + comment.author = author; + comment.content = content; + comment.secret = isSecret; + return comment; + } + + public static Comment createSubComment( + User author, Comment parentComment, String content, boolean isSecret) { + Comment comment = new Comment(); + comment.dailyReport = parentComment.dailyReport; + comment.author = author; + comment.parentComment = parentComment; + comment.content = content; + comment.secret = isSecret; + return comment; + } + + public void updateContent(String content) { + this.content = content; + } + + public boolean isTopLevel() { + return parentComment == null; + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/comment/core/repository/CommentRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/comment/core/repository/CommentRepository.java new file mode 100644 index 0000000..89a119c --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/comment/core/repository/CommentRepository.java @@ -0,0 +1,125 @@ +package com.devkor.ifive.nadab.domain.comment.core.repository; + +import com.devkor.ifive.nadab.domain.comment.core.dto.SubCommentCountDto; +import com.devkor.ifive.nadab.domain.comment.core.entity.Comment; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; + +public interface CommentRepository extends JpaRepository { + + @Query(""" + select c from Comment c + join fetch c.author + join fetch c.dailyReport + where c.id = :id + and c.deletedAt is null + """) + Optional findByIdWithAuthorAndDailyReport(@Param("id") Long id); + + @Query(""" + select c.parentComment.author.id from Comment c + where c.id = :id + and c.parentComment is not null + """) + Optional findParentAuthorIdById(@Param("id") Long id); + + @Query(""" + select c from Comment c + join fetch c.author a + where c.dailyReport.id = :dailyReportId + and c.parentComment is null + and c.deletedAt is null + and a.deletedAt is null + and (:cursor is null or c.id < :cursor) + and a.id not in :excludedUserIds + and not exists ( + select 1 from ContentReport cr + where cr.reporter.id = :currentUserId and cr.comment = c + ) + order by c.id desc + """) + List findTopLevelComments( + @Param("dailyReportId") Long dailyReportId, + @Param("cursor") Long cursor, + @Param("excludedUserIds") List excludedUserIds, + @Param("currentUserId") Long currentUserId, + Pageable pageable + ); + + @Query(""" + select c from Comment c + join fetch c.author a + where c.parentComment.id = :parentCommentId + and c.deletedAt is null + and a.deletedAt is null + and (:cursor is null or c.id < :cursor) + and a.id not in :excludedUserIds + and not exists ( + select 1 from ContentReport cr + where cr.reporter.id = :currentUserId and cr.comment = c + ) + order by c.id desc + """) + List findSubComments( + @Param("parentCommentId") Long parentCommentId, + @Param("cursor") Long cursor, + @Param("excludedUserIds") List excludedUserIds, + @Param("currentUserId") Long currentUserId, + Pageable pageable + ); + + @Query(""" + select new com.devkor.ifive.nadab.domain.comment.core.dto.SubCommentCountDto( + c.parentComment.id, count(c) + ) + from Comment c + where c.parentComment.id in :parentIds + and c.deletedAt is null + and c.author.deletedAt is null + and c.author.id not in :excludedUserIds + and not exists ( + select 1 from ContentReport cr + where cr.reporter.id = :currentUserId and cr.comment = c + ) + and ( + c.secret = false + or c.author.id = :currentUserId + or c.parentComment.id in :visibleSecretParentIds + ) + group by c.parentComment.id + """) + List countVisibleSubCommentsByParentIds( + @Param("parentIds") List parentIds, + @Param("excludedUserIds") List excludedUserIds, + @Param("currentUserId") Long currentUserId, + @Param("visibleSecretParentIds") List visibleSecretParentIds + ); + + @Query(""" + select distinct c.author.id + from Comment c + where c.parentComment.id = :parentCommentId + and c.deletedAt is null + and c.author.id not in :excludeUserIds + and c.author.deletedAt is null + """) + List findDistinctSubCommentAuthorIds( + @Param("parentCommentId") Long parentCommentId, + @Param("excludeUserIds") List excludeUserIds + ); + + @Modifying(clearAutomatically = true) + @Query(""" + update Comment c + set c.deletedAt = :now, c.updatedAt = :now + where c.parentComment.id = :parentId and c.deletedAt is null + """) + void softDeleteSubCommentsByParentId(@Param("parentId") Long parentId, @Param("now") OffsetDateTime now); +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/FeedListResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/FeedListResponse.java index 0122402..5dbefc8 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/FeedListResponse.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/FeedListResponse.java @@ -6,7 +6,10 @@ @Schema(description = "ํ”ผ๋“œ ๋ชฉ๋ก ์‘๋‹ต") public record FeedListResponse( - @Schema(description = "ํ”ผ๋“œ ๋ชฉ๋ก") + @Schema(description = "์˜ค๋Š˜ ๋‚ด ๊ณต์œ  ๋ฆฌํฌํŠธ (๋ฏธ๊ณต์œ  ์‹œ null)") + FeedResponse myReport, + + @Schema(description = "์นœ๊ตฌ ํ”ผ๋“œ ๋ชฉ๋ก") List feeds ) { } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/FeedResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/FeedResponse.java index 5b07cc5..6b8bace 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/FeedResponse.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/FeedResponse.java @@ -27,6 +27,12 @@ public record FeedResponse( String emotionCode, @Schema(description = "์ด๋ฏธ์ง€ URL") - String imageUrl + String imageUrl, + + @Schema(description = "๋‚ด๊ฐ€ ์ข‹์•„์š” ๋ˆŒ๋ €๋Š”์ง€ ์—ฌ๋ถ€") + boolean isLiked, + + @Schema(description = "์ข‹์•„์š”๊ฐ€ 1๊ฐœ ์ด์ƒ์ธ์ง€ ์—ฌ๋ถ€") + boolean hasLikes ) { } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/FeedQueryService.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/FeedQueryService.java index d1e39e0..306d374 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/FeedQueryService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/FeedQueryService.java @@ -8,6 +8,7 @@ import com.devkor.ifive.nadab.domain.friend.core.entity.Friendship; import com.devkor.ifive.nadab.domain.friend.core.entity.FriendshipStatus; import com.devkor.ifive.nadab.domain.friend.core.repository.FriendshipRepository; +import com.devkor.ifive.nadab.domain.like.core.repository.DailyReportLikeRepository; import com.devkor.ifive.nadab.domain.moderation.application.SharingSuspensionService; import com.devkor.ifive.nadab.domain.moderation.core.repository.ContentReportRepository; import com.devkor.ifive.nadab.domain.user.core.entity.DefaultProfileType; @@ -20,7 +21,9 @@ import java.time.LocalDate; import java.util.HashSet; import java.util.List; +import java.util.Optional; import java.util.Set; +import java.util.stream.Stream; @Service @RequiredArgsConstructor @@ -29,26 +32,32 @@ public class FeedQueryService { private final FriendshipRepository friendshipRepository; private final DailyReportRepository dailyReportRepository; + private final DailyReportLikeRepository dailyReportLikeRepository; private final ProfileImageUrlBuilder profileImageUrlBuilder; private final ContentReportRepository contentReportRepository; private final SharingSuspensionService sharingSuspensionService; public FeedListResponse getFeeds(Long userId) { - // 1. ACCEPTED ์ƒํƒœ์˜ ์นœ๊ตฌ ๊ด€๊ณ„ ์กฐํšŒ + LocalDate today = TodayDateTimeProvider.getTodayDate(); + + // 1. ๋‚ด ๊ณต์œ  ๋ฆฌํฌํŠธ ์กฐํšŒ + Optional myFeedDto = dailyReportRepository.findMySharedFeedByDate(userId, today); + + // 2. ACCEPTED ์ƒํƒœ์˜ ์นœ๊ตฌ ๊ด€๊ณ„ ์กฐํšŒ List friendships = friendshipRepository .findByUserIdAndStatusWithUsers(userId, FriendshipStatus.ACCEPTED); - // 2. ์นœ๊ตฌ ID ๋ฆฌ์ŠคํŠธ ์ถ”์ถœ + // 3. ์นœ๊ตฌ ID ๋ฆฌ์ŠคํŠธ ์ถ”์ถœ List friendIds = friendships.stream() .map(f -> f.getOtherUserId(userId)) .toList(); - // 3. ์นœ๊ตฌ๊ฐ€ ์—†์œผ๋ฉด ๋นˆ ๋ฆฌ์ŠคํŠธ ๋ฐ˜ํ™˜ + // 4. ์นœ๊ตฌ๊ฐ€ ์—†์œผ๋ฉด myReport๋งŒ ๋งคํ•‘ ํ›„ ๋ฐ˜ํ™˜ if (friendIds.isEmpty()) { - return new FeedListResponse(List.of()); + return toFeedListResponse(userId, myFeedDto, List.of()); } - // 4. ๊ณต์œ  ํ™œ๋™ ์ค‘์ง€๋œ ์œ ์ € ์ œ์™ธ + // 5. ๊ณต์œ  ํ™œ๋™ ์ค‘์ง€๋œ ์œ ์ € ์ œ์™ธ Set suspendedUserIds = new HashSet<>( sharingSuspensionService.getSharingSuspendedUserIds(friendIds) ); @@ -56,45 +65,50 @@ public FeedListResponse getFeeds(Long userId) { .filter(id -> !suspendedUserIds.contains(id)) .toList(); - // 5. ๊ณต์œ  ๊ฐ€๋Šฅํ•œ ์นœ๊ตฌ๊ฐ€ ์—†์œผ๋ฉด ๋นˆ ๋ฆฌ์ŠคํŠธ ๋ฐ˜ํ™˜ + // 6. ๊ณต์œ  ๊ฐ€๋Šฅํ•œ ์นœ๊ตฌ๊ฐ€ ์—†์œผ๋ฉด myReport๋งŒ ๋ฐ˜ํ™˜ if (activeFriendIds.isEmpty()) { - return new FeedListResponse(List.of()); + return toFeedListResponse(userId, myFeedDto, List.of()); } - // 6. ๋‹น์ผ ๊ณต์œ ๋œ ํ”ผ๋“œ ์กฐํšŒ - LocalDate today = TodayDateTimeProvider.getTodayDate(); + // 7. ๋‹น์ผ ๊ณต์œ ๋œ ํ”ผ๋“œ ์กฐํšŒ List feedDtos = dailyReportRepository.findSharedFeedsByFriendIds(today, activeFriendIds); - // 7. ๋‚ด๊ฐ€ ์‹ ๊ณ ํ•œ ๊ธ€ ์ œ์™ธ - List dailyReportIds = feedDtos.stream() + // 8. ๋‚ด๊ฐ€ ์‹ ๊ณ ํ•œ ๊ธ€ ์ œ์™ธ + List friendReportIds = feedDtos.stream().map(FeedDto::dailyReportId).toList(); + Set reportedIds = friendReportIds.isEmpty() ? Set.of() + : new HashSet<>(contentReportRepository.findReportedDailyReportIdsByReporter(userId, friendReportIds)); + List filteredFeedDtos = feedDtos.stream() + .filter(dto -> !reportedIds.contains(dto.dailyReportId())) + .toList(); + + return toFeedListResponse(userId, myFeedDto, filteredFeedDtos); + } + + private FeedListResponse toFeedListResponse(Long userId, Optional myFeedDto, List feedDtos) { + // ์ „์ฒด reportId ์ˆ˜์ง‘ ํ›„ ์ข‹์•„์š” ์ •๋ณด ๋ฒŒํฌ ์กฐํšŒ + List allReportIds = Stream.concat(myFeedDto.stream(), feedDtos.stream()) .map(FeedDto::dailyReportId) .toList(); - Set reportedIds = new HashSet<>( - contentReportRepository.findReportedDailyReportIdsByReporter(userId, dailyReportIds) - ); + Set likedReportIds; + Set reportIdsWithLikes; + if (allReportIds.isEmpty()) { + likedReportIds = Set.of(); + reportIdsWithLikes = Set.of(); + } else { + likedReportIds = new HashSet<>(dailyReportLikeRepository.findLikedReportIds(allReportIds, userId)); + reportIdsWithLikes = new HashSet<>(dailyReportLikeRepository.findReportIdsWithLikes(allReportIds)); + } + + FeedResponse myReport = myFeedDto + .map(dto -> toFeedResponse(dto, likedReportIds, reportIdsWithLikes)) + .orElse(null); - // 8. ํ•„ํ„ฐ๋ง ๋ฐ ์‘๋‹ต DTO ๋ณ€ํ™˜ List feeds = feedDtos.stream() - .filter(dto -> !reportedIds.contains(dto.dailyReportId())) - .map(dto -> { - String profileUrl = buildProfileUrl(dto.profileImageKey(), dto.defaultProfileType()); - String imageUrl = dto.imageKey() != null ? profileImageUrlBuilder.buildUrl(dto.imageKey()) : null; - - return new FeedResponse( - dto.dailyReportId(), - dto.nickname(), - profileUrl, - dto.interestCode() != null ? dto.interestCode().name() : null, - dto.questionText(), - dto.answerContent(), - dto.emotionCode() != null ? dto.emotionCode().name() : null, - imageUrl - ); - }) + .map(dto -> toFeedResponse(dto, likedReportIds, reportIdsWithLikes)) .toList(); - return new FeedListResponse(feeds); + return new FeedListResponse(myReport, feeds); } public ShareStatusResponse getShareStatus(Long userId) { @@ -104,6 +118,23 @@ public ShareStatusResponse getShareStatus(Long userId) { .orElse(new ShareStatusResponse(false)); } + private FeedResponse toFeedResponse(FeedDto dto, Set likedReportIds, Set reportIdsWithLikes) { + String profileUrl = buildProfileUrl(dto.profileImageKey(), dto.defaultProfileType()); + String imageUrl = dto.imageKey() != null ? profileImageUrlBuilder.buildUrl(dto.imageKey()) : null; + return new FeedResponse( + dto.dailyReportId(), + dto.nickname(), + profileUrl, + dto.interestCode() != null ? dto.interestCode().name() : null, + dto.questionText(), + dto.answerContent(), + dto.emotionCode() != null ? dto.emotionCode().name() : null, + imageUrl, + likedReportIds.contains(dto.dailyReportId()), + reportIdsWithLikes.contains(dto.dailyReportId()) + ); + } + private String buildProfileUrl(String profileImageKey, DefaultProfileType defaultProfileType) { if (profileImageKey != null) { return profileImageUrlBuilder.buildUrl(profileImageKey); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/DailyReportRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/DailyReportRepository.java index 9c78c01..02de7cd 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/DailyReportRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/DailyReportRepository.java @@ -160,6 +160,35 @@ Optional findByUserIdAndDate( @Param("date") LocalDate date ); + @Query("select ae.user.id from DailyReport dr join dr.answerEntry ae where dr.id = :reportId") + Optional findReportOwnerIdById(@Param("reportId") Long reportId); + + boolean existsByIdAndIsSharedTrue(Long id); + + @Query(""" + select new com.devkor.ifive.nadab.domain.dailyreport.core.dto.FeedDto( + dr.id, + ae.user.nickname, + ae.user.profileImageKey, + ae.user.defaultProfileType, + ae.question.interest.code, + ae.question.questionText, + ae.content, + dr.emotion.code, + ae.imageKey + ) + from DailyReport dr + join dr.answerEntry ae + where ae.user.id = :userId + and dr.date = :date + and dr.isShared = true + and dr.status = com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReportStatus.COMPLETED + """) + Optional findMySharedFeedByDate( + @Param("userId") Long userId, + @Param("date") LocalDate date + ); + @Query(""" select new com.devkor.ifive.nadab.domain.dailyreport.core.dto.InterestCompletedCountDto( i.code, @@ -177,4 +206,8 @@ List countCompletedByInterest( @Param("userId") Long userId, @Param("status") DailyReportStatus status ); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE DailyReport r SET r.isShared = false WHERE r.answerEntry.user.id = :userId AND r.date = :date AND r.isShared = true") + int stopSharingByUserIdAndDate(@Param("userId") Long userId, @Param("date") LocalDate date); } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/friend/core/repository/FriendshipRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/friend/core/repository/FriendshipRepository.java index 4d489c2..1b11493 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/friend/core/repository/FriendshipRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/friend/core/repository/FriendshipRepository.java @@ -20,6 +20,16 @@ select case when exists ( """) boolean existsByUserIds(@Param("userId1") Long userId1, @Param("userId2") Long userId2); + @Query(""" + select case when exists ( + select 1 from Friendship f + where f.user1.id = :userId1 and f.user2.id = :userId2 + and f.status = 'ACCEPTED' + and f.user1.deletedAt is null and f.user2.deletedAt is null + ) then true else false end + """) + boolean existsAcceptedByUserIds(@Param("userId1") Long userId1, @Param("userId2") Long userId2); + @Query(""" select count(f) from Friendship f where (f.user1.id = :userId or f.user2.id = :userId) diff --git a/src/main/java/com/devkor/ifive/nadab/domain/like/api/LikeController.java b/src/main/java/com/devkor/ifive/nadab/domain/like/api/LikeController.java new file mode 100644 index 0000000..9f75af3 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/like/api/LikeController.java @@ -0,0 +1,188 @@ +package com.devkor.ifive.nadab.domain.like.api; + +import com.devkor.ifive.nadab.domain.like.api.dto.response.LikeListResponse; +import com.devkor.ifive.nadab.domain.like.application.LikeCommandService; +import com.devkor.ifive.nadab.domain.like.application.LikeQueryService; +import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; +import com.devkor.ifive.nadab.global.core.response.ApiResponseEntity; +import com.devkor.ifive.nadab.global.security.principal.UserPrincipal; +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.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "์ข‹์•„์š” API", description = "๊ฒŒ์‹œ๊ธ€ ๋ฐ ๋Œ“๊ธ€ ์ข‹์•„์š” ๊ด€๋ จ API") +@RestController +@RequestMapping("${api_prefix}") +@RequiredArgsConstructor +public class LikeController { + + private final LikeCommandService likeCommandService; + private final LikeQueryService likeQueryService; + + @PostMapping("/feed/{dailyReportId}/likes") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "๊ฒŒ์‹œ๊ธ€ ์ข‹์•„์š”", + description = """ + ์นœ๊ตฌ์˜ ๊ณต์œ  ๊ฒŒ์‹œ๊ธ€์— ์ข‹์•„์š”๋ฅผ ๋ˆ„๋ฆ…๋‹ˆ๋‹ค. + + - ๋ณธ์ธ์˜ ๊ฒŒ์‹œ๊ธ€์—๋Š” ์ข‹์•„์š” ๋ถˆ๊ฐ€ (400) + - ์ด๋ฏธ ์ข‹์•„์š”ํ•œ ๊ฒฝ์šฐ 204 ๋ฐ˜ํ™˜ (๋ฉฑ๋“ฑ) + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "204", description = "์ข‹์•„์š” ์„ฑ๊ณต", content = @Content), + @ApiResponse(responseCode = "400", description = """ + - ErrorCode: CANNOT_LIKE_OWN_CONTENT - ๋ณธ์ธ ๊ฒŒ์‹œ๊ธ€ ์ข‹์•„์š” ๋ถˆ๊ฐ€ + - ErrorCode: SOCIAL_SUSPENDED - ์†Œ์…œ ์ •์ง€ ์ค‘ + """, content = @Content), + @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", content = @Content), + @ApiResponse(responseCode = "403", description = "ErrorCode: AUTH_ACCESS_DENIED - ๊ณต์œ ๋˜์ง€ ์•Š์€ ๊ฒŒ์‹œ๊ธ€์ด๊ฑฐ๋‚˜ ์นœ๊ตฌ๊ฐ€ ์•„๋‹Œ ๊ฒฝ์šฐ", content = @Content), + @ApiResponse(responseCode = "404", description = "ErrorCode: DAILY_REPORT_NOT_FOUND", content = @Content) + } + ) + public ResponseEntity> likeReport( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long dailyReportId + ) { + likeCommandService.likeReport(dailyReportId, principal.getId()); + return ApiResponseEntity.noContent(); + } + + @DeleteMapping("/feed/{dailyReportId}/likes") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "๊ฒŒ์‹œ๊ธ€ ์ข‹์•„์š” ์ทจ์†Œ", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "204", description = "์ทจ์†Œ ์„ฑ๊ณต", content = @Content), + @ApiResponse(responseCode = "400", description = "ErrorCode: SOCIAL_SUSPENDED - ์†Œ์…œ ์ •์ง€ ์ค‘", content = @Content), + @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", content = @Content), + @ApiResponse(responseCode = "404", description = "ErrorCode: LIKE_NOT_FOUND", content = @Content) + } + ) + public ResponseEntity> unlikeReport( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long dailyReportId + ) { + likeCommandService.unlikeReport(dailyReportId, principal.getId()); + return ApiResponseEntity.noContent(); + } + + @GetMapping("/feed/{dailyReportId}/likes") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "๊ฒŒ์‹œ๊ธ€ ์ข‹์•„์š” ๋ฆฌ์ŠคํŠธ", + description = """ + ๊ฒŒ์‹œ๊ธ€์— ์ข‹์•„์š”๋ฅผ ๋ˆ„๋ฅธ ์‚ฌ์šฉ์ž ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. + + - ๋ฆฌํฌํŠธ ๋‹น์‚ฌ์ž(๋ณธ์ธ ๊ฒŒ์‹œ๊ธ€)๋งŒ ์กฐํšŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. + - ์ตœ์‹ ์ˆœ ์ •๋ ฌ, ์ฐจ๋‹จ ๊ด€๊ณ„ ์–‘๋ฐฉํ–ฅ ์ œ์™ธํ•ฉ๋‹ˆ๋‹ค. + - isFriend=true์ธ ๊ฒฝ์šฐ ์นœ๊ตฌ ์‚ญ์ œยท์ฐจ๋‹จ ๋ฒ„ํŠผ ์ œ๊ณต, false์ธ ๊ฒฝ์šฐ ์นœ๊ตฌ ์‹ ์ฒญ ๋ฒ„ํŠผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "200", description = "์กฐํšŒ ์„ฑ๊ณต", + content = @Content(schema = @Schema(implementation = LikeListResponse.class))), + @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", content = @Content), + @ApiResponse(responseCode = "403", description = "ErrorCode: DAILY_REPORT_LIKE_LIST_FORBIDDEN - ๋ณธ์ธ ๊ฒŒ์‹œ๊ธ€ ์•„๋‹˜", content = @Content), + @ApiResponse(responseCode = "404", description = "ErrorCode: DAILY_REPORT_NOT_FOUND", content = @Content) + } + ) + public ResponseEntity> getReportLikers( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long dailyReportId + ) { + LikeListResponse response = likeQueryService.getReportLikers(dailyReportId, principal.getId()); + return ApiResponseEntity.ok(response); + } + + @PostMapping("/comments/{commentId}/likes") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "๋Œ“๊ธ€/๋Œ€๋Œ“๊ธ€ ์ข‹์•„์š”", + description = """ + ๋Œ“๊ธ€ ๋˜๋Š” ๋Œ€๋Œ“๊ธ€์— ์ข‹์•„์š”๋ฅผ ๋ˆ„๋ฆ…๋‹ˆ๋‹ค. + + - ๋ณธ์ธ์˜ ๋Œ“๊ธ€์—๋Š” ์ข‹์•„์š” ๋ถˆ๊ฐ€ (400) + - ์—ด๋žŒ ๊ถŒํ•œ ์—†๋Š” ๋น„๋ฐ€ ๋Œ“๊ธ€์—๋Š” ์ข‹์•„์š” ๋ถˆ๊ฐ€ (403) + - ์ด๋ฏธ ์ข‹์•„์š”ํ•œ ๊ฒฝ์šฐ 204 ๋ฐ˜ํ™˜ (๋ฉฑ๋“ฑ) + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "204", description = "์ข‹์•„์š” ์„ฑ๊ณต", content = @Content), + @ApiResponse(responseCode = "400", description = """ + - ErrorCode: CANNOT_LIKE_OWN_CONTENT - ๋ณธ์ธ ๋Œ“๊ธ€ ์ข‹์•„์š” ๋ถˆ๊ฐ€ + - ErrorCode: SOCIAL_SUSPENDED - ์†Œ์…œ ์ •์ง€ ์ค‘ + """, content = @Content), + @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", content = @Content), + @ApiResponse(responseCode = "403", description = "ErrorCode: AUTH_ACCESS_DENIED", content = @Content), + @ApiResponse(responseCode = "404", description = "ErrorCode: COMMENT_NOT_FOUND", content = @Content), + @ApiResponse(responseCode = "409", description = "ErrorCode: COMMENT_DELETED", content = @Content) + } + ) + public ResponseEntity> likeComment( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long commentId + ) { + likeCommandService.likeComment(commentId, principal.getId()); + return ApiResponseEntity.noContent(); + } + + @DeleteMapping("/comments/{commentId}/likes") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "๋Œ“๊ธ€/๋Œ€๋Œ“๊ธ€ ์ข‹์•„์š” ์ทจ์†Œ", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "204", description = "์ทจ์†Œ ์„ฑ๊ณต", content = @Content), + @ApiResponse(responseCode = "400", description = "ErrorCode: SOCIAL_SUSPENDED - ์†Œ์…œ ์ •์ง€ ์ค‘", content = @Content), + @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", content = @Content), + @ApiResponse(responseCode = "404", description = "ErrorCode: LIKE_NOT_FOUND", content = @Content) + } + ) + public ResponseEntity> unlikeComment( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long commentId + ) { + likeCommandService.unlikeComment(commentId, principal.getId()); + return ApiResponseEntity.noContent(); + } + + @GetMapping("/comments/{commentId}/likes") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "๋Œ“๊ธ€/๋Œ€๋Œ“๊ธ€ ์ข‹์•„์š” ๋ฆฌ์ŠคํŠธ", + description = """ + ๋Œ“๊ธ€ ๋˜๋Š” ๋Œ€๋Œ“๊ธ€์— ์ข‹์•„์š”๋ฅผ ๋ˆ„๋ฅธ ์‚ฌ์šฉ์ž ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. + + - ์ตœ์‹ ์ˆœ ์ •๋ ฌ, ์ฐจ๋‹จ ๊ด€๊ณ„ ์–‘๋ฐฉํ–ฅ ์ œ์™ธํ•ฉ๋‹ˆ๋‹ค. + - isFriend=true์ธ ๊ฒฝ์šฐ ์นœ๊ตฌ ์‚ญ์ œยท์ฐจ๋‹จ ๋ฒ„ํŠผ ์ œ๊ณต, false์ธ ๊ฒฝ์šฐ ์นœ๊ตฌ ์‹ ์ฒญ ๋ฒ„ํŠผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. + - ๋น„๋ฐ€ ๋Œ“๊ธ€์€ ์—ด๋žŒ ๊ถŒํ•œ์ž(์ž‘์„ฑ์žยท๋ฆฌํฌํŠธ ๋‹น์‚ฌ์ž)๋งŒ ์กฐํšŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "200", description = "์กฐํšŒ ์„ฑ๊ณต", + content = @Content(schema = @Schema(implementation = LikeListResponse.class))), + @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", content = @Content), + @ApiResponse(responseCode = "403", description = "ErrorCode: AUTH_ACCESS_DENIED - ๋น„๋ฐ€ ๋Œ“๊ธ€ ์—ด๋žŒ ๊ถŒํ•œ ์—†์Œ ๋˜๋Š” ํ”ผ๋“œ ์ ‘๊ทผ ๊ถŒํ•œ ์—†์Œ", content = @Content), + @ApiResponse(responseCode = "404", description = "ErrorCode: COMMENT_NOT_FOUND", content = @Content), + @ApiResponse(responseCode = "409", description = "ErrorCode: COMMENT_DELETED", content = @Content) + } + ) + public ResponseEntity> getCommentLikers( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long commentId + ) { + LikeListResponse response = likeQueryService.getCommentLikers(commentId, principal.getId()); + return ApiResponseEntity.ok(response); + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/like/api/dto/response/LikeListResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/like/api/dto/response/LikeListResponse.java new file mode 100644 index 0000000..fd24ea5 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/like/api/dto/response/LikeListResponse.java @@ -0,0 +1,13 @@ +package com.devkor.ifive.nadab.domain.like.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "์ข‹์•„์š” ๋ฆฌ์ŠคํŠธ ์‘๋‹ต") +public record LikeListResponse( + + @Schema(description = "์ข‹์•„์š” ๋ˆ„๋ฅธ ์‚ฌ์šฉ์ž ๋ชฉ๋ก (์ตœ์‹ ์ˆœ, ์ฐจ๋‹จ ๊ด€๊ณ„ ์ œ์™ธ)") + List likers +) { +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/like/api/dto/response/LikerResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/like/api/dto/response/LikerResponse.java new file mode 100644 index 0000000..6a2884f --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/like/api/dto/response/LikerResponse.java @@ -0,0 +1,20 @@ +package com.devkor.ifive.nadab.domain.like.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "์ข‹์•„์š” ๋ˆ„๋ฅธ ์‚ฌ์šฉ์ž") +public record LikerResponse( + + @Schema(description = "์‚ฌ์šฉ์ž ID", example = "42") + Long userId, + + @Schema(description = "ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ URL") + String profileImageUrl, + + @Schema(description = "๋‹‰๋„ค์ž„", example = "๋ชจ๋ž˜") + String nickname, + + @Schema(description = "์นœ๊ตฌ ์—ฌ๋ถ€ (true: ์นœ๊ตฌ ์‚ญ์ œยท์ฐจ๋‹จ ๋ฒ„ํŠผ ์ œ๊ณต, false: ์นœ๊ตฌ ์‹ ์ฒญ ๋ฒ„ํŠผ ์ œ๊ณต)") + boolean isFriend +) { +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/like/application/LikeCommandService.java b/src/main/java/com/devkor/ifive/nadab/domain/like/application/LikeCommandService.java new file mode 100644 index 0000000..4de2849 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/like/application/LikeCommandService.java @@ -0,0 +1,136 @@ +package com.devkor.ifive.nadab.domain.like.application; + +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.like.core.entity.CommentLike; +import com.devkor.ifive.nadab.domain.like.core.entity.DailyReportLike; +import com.devkor.ifive.nadab.domain.like.core.repository.CommentLikeRepository; +import com.devkor.ifive.nadab.domain.like.core.repository.DailyReportLikeRepository; +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.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class LikeCommandService { + + private final DailyReportLikeRepository dailyReportLikeRepository; + private final CommentLikeRepository commentLikeRepository; + private final DailyReportRepository dailyReportRepository; + private final CommentRepository commentRepository; + private final FriendshipRepository friendshipRepository; + private final UserRepository userRepository; + private final SharingSuspensionService sharingSuspensionService; + + public void likeReport(Long dailyReportId, Long userId) { + checkNotSuspended(userId); + Long reportOwnerId = dailyReportRepository.findReportOwnerIdById(dailyReportId) + .orElseThrow(() -> new NotFoundException(ErrorCode.DAILY_REPORT_NOT_FOUND)); + + if (userId.equals(reportOwnerId)) { + throw new BadRequestException(ErrorCode.CANNOT_LIKE_OWN_CONTENT); + } + + checkReportLikeAccess(dailyReportId, reportOwnerId, userId); + + if (dailyReportLikeRepository.existsByUserIdAndDailyReportId(userId, dailyReportId)) { + return; // ์ด๋ฏธ ์ข‹์•„์š” โ€” ๋ฉฑ๋“ฑ ์ฒ˜๋ฆฌ + } + + User user = userRepository.getReferenceById(userId); + DailyReport dailyReport = dailyReportRepository.getReferenceById(dailyReportId); + dailyReportLikeRepository.save(DailyReportLike.create(user, dailyReport)); + } + + public void unlikeReport(Long dailyReportId, Long userId) { + checkNotSuspended(userId); + DailyReportLike like = dailyReportLikeRepository.findByUserIdAndDailyReportId(userId, dailyReportId) + .orElseThrow(() -> new NotFoundException(ErrorCode.LIKE_NOT_FOUND)); + dailyReportLikeRepository.delete(like); + } + + public void likeComment(Long commentId, Long userId) { + checkNotSuspended(userId); + Comment comment = commentRepository.findByIdWithAuthorAndDailyReport(commentId) + .orElseThrow(() -> commentRepository.existsById(commentId) + ? new ConflictException(ErrorCode.COMMENT_DELETED) + : new NotFoundException(ErrorCode.COMMENT_NOT_FOUND)); + + if (userId.equals(comment.getAuthor().getId())) { + throw new BadRequestException(ErrorCode.CANNOT_LIKE_OWN_CONTENT); + } + + Long dailyReportId = comment.getDailyReport().getId(); + Long reportOwnerId = dailyReportRepository.findReportOwnerIdById(dailyReportId) + .orElseThrow(() -> new NotFoundException(ErrorCode.DAILY_REPORT_NOT_FOUND)); + checkCommentLikeAccess(dailyReportId, reportOwnerId, userId); + + if (comment.isSecret()) { + boolean isParentAuthor = !comment.isTopLevel() && + commentRepository.findParentAuthorIdById(commentId) + .map(id -> id.equals(userId)) + .orElse(false); + boolean canView = comment.getAuthor().getId().equals(userId) + || reportOwnerId.equals(userId) + || isParentAuthor; + if (!canView) { + throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED); + } + } + + if (commentLikeRepository.existsByUserIdAndCommentId(userId, commentId)) { + return; // ์ด๋ฏธ ์ข‹์•„์š” โ€” ๋ฉฑ๋“ฑ ์ฒ˜๋ฆฌ + } + + User user = userRepository.getReferenceById(userId); + commentLikeRepository.save(CommentLike.create(user, comment)); + } + + public void unlikeComment(Long commentId, Long userId) { + checkNotSuspended(userId); + CommentLike like = commentLikeRepository.findByUserIdAndCommentId(userId, commentId) + .orElseThrow(() -> new NotFoundException(ErrorCode.LIKE_NOT_FOUND)); + commentLikeRepository.delete(like); + } + + private void checkNotSuspended(Long userId) { + if (sharingSuspensionService.isSharingSuspended(userId)) { + throw new BadRequestException(ErrorCode.SOCIAL_SUSPENDED); + } + } + + private void checkReportLikeAccess(Long dailyReportId, Long reportOwnerId, Long currentUserId) { + 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); + } + } + + private void checkCommentLikeAccess(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); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/like/application/LikeQueryService.java b/src/main/java/com/devkor/ifive/nadab/domain/like/application/LikeQueryService.java new file mode 100644 index 0000000..6e1bb2f --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/like/application/LikeQueryService.java @@ -0,0 +1,138 @@ +package com.devkor.ifive.nadab.domain.like.application; + +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.repository.DailyReportRepository; +import com.devkor.ifive.nadab.domain.friend.core.entity.Friendship; +import com.devkor.ifive.nadab.domain.friend.core.entity.FriendshipStatus; +import com.devkor.ifive.nadab.domain.friend.core.repository.FriendshipRepository; +import com.devkor.ifive.nadab.domain.like.api.dto.response.LikeListResponse; +import com.devkor.ifive.nadab.domain.like.api.dto.response.LikerResponse; +import com.devkor.ifive.nadab.domain.like.core.repository.CommentLikeRepository; +import com.devkor.ifive.nadab.domain.like.core.repository.DailyReportLikeRepository; +import com.devkor.ifive.nadab.domain.moderation.application.SharingSuspensionService; +import com.devkor.ifive.nadab.domain.moderation.core.repository.UserBlockRepository; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.infra.ProfileImageUrlBuilder; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +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.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class LikeQueryService { + + private final DailyReportLikeRepository dailyReportLikeRepository; + private final CommentLikeRepository commentLikeRepository; + private final DailyReportRepository dailyReportRepository; + private final CommentRepository commentRepository; + private final FriendshipRepository friendshipRepository; + private final UserBlockRepository userBlockRepository; + private final ProfileImageUrlBuilder profileImageUrlBuilder; + private final SharingSuspensionService sharingSuspensionService; + + public LikeListResponse getReportLikers(Long dailyReportId, Long currentUserId) { + Long reportOwnerId = dailyReportRepository.findReportOwnerIdById(dailyReportId) + .orElseThrow(() -> new NotFoundException(ErrorCode.DAILY_REPORT_NOT_FOUND)); + + if (!currentUserId.equals(reportOwnerId)) { + throw new ForbiddenException(ErrorCode.DAILY_REPORT_LIKE_LIST_FORBIDDEN); + } + + List excludedUserIds = getExcludedUserIds(currentUserId); + List likers = dailyReportLikeRepository.findLikersByReportId(dailyReportId, excludedUserIds); + + Set friendIds = getAcceptedFriendIds(currentUserId); + List responses = likers.stream() + .map(u -> new LikerResponse( + u.getId(), + profileImageUrlBuilder.buildUserProfileUrl(u), + u.getNickname(), + friendIds.contains(u.getId()) + )) + .toList(); + + return new LikeListResponse(responses); + } + + public LikeListResponse getCommentLikers(Long commentId, Long currentUserId) { + Comment comment = commentRepository.findByIdWithAuthorAndDailyReport(commentId) + .orElseThrow(() -> commentRepository.existsById(commentId) + ? new ConflictException(ErrorCode.COMMENT_DELETED) + : new NotFoundException(ErrorCode.COMMENT_NOT_FOUND)); + + Long dailyReportId = comment.getDailyReport().getId(); + Long reportOwnerId = dailyReportRepository.findReportOwnerIdById(dailyReportId) + .orElseThrow(() -> new NotFoundException(ErrorCode.DAILY_REPORT_NOT_FOUND)); + checkCommentViewAccess(dailyReportId, reportOwnerId, currentUserId); + + if (comment.isSecret()) { + boolean isParentAuthor = !comment.isTopLevel() && + commentRepository.findParentAuthorIdById(commentId) + .map(id -> id.equals(currentUserId)) + .orElse(false); + boolean canView = comment.getAuthor().getId().equals(currentUserId) + || reportOwnerId.equals(currentUserId) + || isParentAuthor; + if (!canView) { + throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED); + } + } + + List excludedUserIds = getExcludedUserIds(currentUserId); + List likers = commentLikeRepository.findLikersByCommentId(commentId, excludedUserIds); + + Set friendIds = getAcceptedFriendIds(currentUserId); + List responses = likers.stream() + .map(u -> new LikerResponse( + u.getId(), + profileImageUrlBuilder.buildUserProfileUrl(u), + u.getNickname(), + friendIds.contains(u.getId()) + )) + .toList(); + + return new LikeListResponse(responses); + } + + private void checkCommentViewAccess(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); + } + } + + private Set getAcceptedFriendIds(Long userId) { + return friendshipRepository.findByUserIdAndStatusWithUsers(userId, FriendshipStatus.ACCEPTED) + .stream() + .map(f -> f.getOtherUserId(userId)) + .collect(Collectors.toSet()); + } + + private List getExcludedUserIds(Long userId) { + List blocked = userBlockRepository.findBlockedUserIdsBidirectional(userId); + List suspended = sharingSuspensionService.getAllActiveSuspendedUserIds(); + + Set combined = new HashSet<>(blocked); + combined.addAll(suspended); + combined.remove(userId); + + return combined.isEmpty() ? List.of(-1L) : new ArrayList<>(combined); + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/like/core/entity/CommentLike.java b/src/main/java/com/devkor/ifive/nadab/domain/like/core/entity/CommentLike.java new file mode 100644 index 0000000..b7e7f33 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/like/core/entity/CommentLike.java @@ -0,0 +1,35 @@ +package com.devkor.ifive.nadab.domain.like.core.entity; + +import com.devkor.ifive.nadab.domain.comment.core.entity.Comment; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.global.shared.entity.CreatableEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "comment_likes") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CommentLike extends CreatableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "comment_id", nullable = false) + private Comment comment; + + public static CommentLike create(User user, Comment comment) { + CommentLike like = new CommentLike(); + like.user = user; + like.comment = comment; + return like; + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/like/core/entity/DailyReportLike.java b/src/main/java/com/devkor/ifive/nadab/domain/like/core/entity/DailyReportLike.java new file mode 100644 index 0000000..37733ab --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/like/core/entity/DailyReportLike.java @@ -0,0 +1,35 @@ +package com.devkor.ifive.nadab.domain.like.core.entity; + +import com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReport; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.global.shared.entity.CreatableEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "daily_report_likes") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DailyReportLike extends CreatableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "daily_report_id", nullable = false) + private DailyReport dailyReport; + + public static DailyReportLike create(User user, DailyReport dailyReport) { + DailyReportLike like = new DailyReportLike(); + like.user = user; + like.dailyReport = dailyReport; + return like; + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/like/core/repository/CommentLikeRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/like/core/repository/CommentLikeRepository.java new file mode 100644 index 0000000..4a55d77 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/like/core/repository/CommentLikeRepository.java @@ -0,0 +1,42 @@ +package com.devkor.ifive.nadab.domain.like.core.repository; + +import com.devkor.ifive.nadab.domain.like.core.entity.CommentLike; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +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 java.util.Optional; + +public interface CommentLikeRepository extends JpaRepository { + + Optional findByUserIdAndCommentId(Long userId, Long commentId); + + boolean existsByUserIdAndCommentId(Long userId, Long commentId); + + @Query(""" + select l.comment.id + from CommentLike l + where l.comment.id in :commentIds + and l.user.id = :userId + """) + List findLikedCommentIds(@Param("commentIds") List commentIds, @Param("userId") Long userId); + + @Query(""" + select distinct l.comment.id + from CommentLike l + where l.comment.id in :commentIds + """) + List findCommentIdsWithLikes(@Param("commentIds") List commentIds); + + @Query(""" + select l.user + from CommentLike l + where l.comment.id = :commentId + and l.user.id not in :excludedUserIds + and l.user.deletedAt is null + order by l.createdAt desc + """) + List findLikersByCommentId(@Param("commentId") Long commentId, @Param("excludedUserIds") List excludedUserIds); +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/like/core/repository/DailyReportLikeRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/like/core/repository/DailyReportLikeRepository.java new file mode 100644 index 0000000..9c3ab37 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/like/core/repository/DailyReportLikeRepository.java @@ -0,0 +1,42 @@ +package com.devkor.ifive.nadab.domain.like.core.repository; + +import com.devkor.ifive.nadab.domain.like.core.entity.DailyReportLike; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +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 java.util.Optional; + +public interface DailyReportLikeRepository extends JpaRepository { + + Optional findByUserIdAndDailyReportId(Long userId, Long dailyReportId); + + boolean existsByUserIdAndDailyReportId(Long userId, Long dailyReportId); + + @Query(""" + select l.dailyReport.id + from DailyReportLike l + where l.dailyReport.id in :reportIds + and l.user.id = :userId + """) + List findLikedReportIds(@Param("reportIds") List reportIds, @Param("userId") Long userId); + + @Query(""" + select distinct l.dailyReport.id + from DailyReportLike l + where l.dailyReport.id in :reportIds + """) + List findReportIdsWithLikes(@Param("reportIds") List reportIds); + + @Query(""" + select l.user + from DailyReportLike l + where l.dailyReport.id = :reportId + and l.user.id not in :excludedUserIds + and l.user.deletedAt is null + order by l.createdAt desc + """) + List findLikersByReportId(@Param("reportId") Long reportId, @Param("excludedUserIds") List excludedUserIds); +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/moderation/api/ModerationController.java b/src/main/java/com/devkor/ifive/nadab/domain/moderation/api/ModerationController.java index 8785045..0a16675 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/moderation/api/ModerationController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/moderation/api/ModerationController.java @@ -3,7 +3,9 @@ import com.devkor.ifive.nadab.domain.moderation.api.dto.request.BlockUserRequest; import com.devkor.ifive.nadab.domain.moderation.api.dto.request.ReportContentRequest; import com.devkor.ifive.nadab.domain.moderation.api.dto.response.BlockedUserListResponse; +import com.devkor.ifive.nadab.domain.moderation.api.dto.response.SuspensionStatusResponse; import com.devkor.ifive.nadab.domain.moderation.application.ContentReportCommandService; +import com.devkor.ifive.nadab.domain.moderation.application.SharingSuspensionService; import com.devkor.ifive.nadab.domain.moderation.application.UserBlockCommandService; import com.devkor.ifive.nadab.domain.moderation.application.UserBlockQueryService; import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; @@ -22,7 +24,8 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -@Tag(name = "์‹ ๊ณ  ๋ฐ ์ฐจ๋‹จ API", description = "๊ณต์œ ๊ธ€ ์‹ ๊ณ , ์‚ฌ์šฉ์ž ์ฐจ๋‹จ ๊ด€๋ จ API") + +@Tag(name = "์‹ ๊ณ  ๋ฐ ์ฐจ๋‹จ API", description = "๊ณต์œ ๊ธ€/๋Œ“๊ธ€ ์‹ ๊ณ , ์‚ฌ์šฉ์ž ์ฐจ๋‹จ, ์†Œ์…œ ์ •์ง€ ๊ด€๋ จ API") @RestController @RequestMapping("${api_prefix}/moderation") @RequiredArgsConstructor @@ -31,16 +34,17 @@ public class ModerationController { private final ContentReportCommandService contentReportCommandService; private final UserBlockCommandService userBlockCommandService; private final UserBlockQueryService userBlockQueryService; + private final SharingSuspensionService sharingSuspensionService; @PostMapping("/reports") @PreAuthorize("isAuthenticated()") @Operation( - summary = "๊ณต์œ ๊ธ€ ์‹ ๊ณ  API", + summary = "์‹ ๊ณ  API", description = """ - ๊ณต์œ ๋œ DailyReport๋ฅผ ์‹ ๊ณ ํ•ฉ๋‹ˆ๋‹ค. + ๊ณต์œ ๊ธ€ ๋˜๋Š” ๋Œ“๊ธ€/๋Œ€๋Œ“๊ธ€์„ ์‹ ๊ณ ํ•ฉ๋‹ˆ๋‹ค. ์š”์ฒญ ํ•„๋“œ: - - dailyReportId (ํ•„์ˆ˜): ์‹ ๊ณ ํ•  ๊ณต์œ ๊ธ€์˜ DailyReport ID(GET /api/v1/feed ํ˜ธ์ถœ ์‹œ ํ•„๋“œ์—์„œ ํ™•์ธ ๊ฐ€๋Šฅ) + - dailyReportId / commentId: ๋‘˜ ์ค‘ ํ•˜๋‚˜๋งŒ ํ•„์ˆ˜ (๋™์‹œ ์ž…๋ ฅ ๋ถˆ๊ฐ€) - reason (ํ•„์ˆ˜): ์‹ ๊ณ  ์‚ฌ์œ  - PROFANITY_HATE_SPEECH: ์š•์„ค / ํ˜์˜ค ํ‘œํ˜„ - SEXUAL_CONTENT: ์„ฑ์ ์œผ๋กœ ๋ถ€์ ์ ˆํ•œ ์–ธํ–‰ @@ -49,35 +53,36 @@ public class ModerationController { - customReason: reason์ด OTHER์ผ ๋•Œ๋งŒ ํ•„์ˆ˜, 200์ž ์ดํ•˜ ์‹ ๊ณ  ํ›„ ๋™์ž‘: - - ๋™์ผ ๊ณต์œ ๊ธ€ ์ค‘๋ณต ์‹ ๊ณ  ๋ถˆ๊ฐ€ + - ๋™์ผ ๋Œ€์ƒ ์ค‘๋ณต ์‹ ๊ณ  ๋ถˆ๊ฐ€ - ์‹ ๊ณ ํ•œ ๊ณต์œ ๊ธ€์€ ์‹ ๊ณ ์ž์˜ ํ”ผ๋“œ์—์„œ ์ˆจ๊ฒจ์ง - - ๋ˆ„์  ์‹ ๊ณ  10๊ฑด ์ด์ƒ & ์‹ ๊ณ ์ž 2๋ช… ์ด์ƒ ์‹œ ์ž‘์„ฑ์ž์˜ ์†Œ์…œ ํ™œ๋™ ์ž๋™ ์ค‘์ง€(๊ณต์œ ํ•˜๊ธฐ ์‹œ๋„ ์‹œ status๋กœ SUSPENDED ๋ฐ˜ํ™˜) + - ๋ˆ„์  ์‹ ๊ณ  20๊ฑด ์ด์ƒ & ์‹ ๊ณ ์ž 3๋ช… ์ด์ƒ ์‹œ ์†Œ์…œ ํ™œ๋™ ์ž๋™ ์ •์ง€ (720์‹œ๊ฐ„) """, security = @SecurityRequirement(name = "bearerAuth"), responses = { - @ApiResponse( - responseCode = "204", - description = "์‹ ๊ณ  ์„ฑ๊ณต", - content = @Content - ), + @ApiResponse(responseCode = "204", description = "์‹ ๊ณ  ์„ฑ๊ณต", content = @Content), @ApiResponse( responseCode = "400", - description = "ErrorCode: CONTENT_REPORT_INVALID - ์ž˜๋ชป๋œ ์‹ ๊ณ  ์š”์ฒญ (๊ธฐํƒ€ ์‚ฌ์œ  ๋ฏธ์ž…๋ ฅ ๋˜๋Š” 200์ž ์ดˆ๊ณผ)", - content = @Content - ), - @ApiResponse( - responseCode = "401", - description = "์ธ์ฆ ์‹คํŒจ", + description = """ + - ErrorCode: CONTENT_REPORT_INVALID - ์ž˜๋ชป๋œ ์‹ ๊ณ  ์š”์ฒญ (๋Œ€์ƒ ๋ฏธ์ž…๋ ฅ/์ค‘๋ณต ์ž…๋ ฅ, ๊ธฐํƒ€ ์‚ฌ์œ  ๋ฏธ์ž…๋ ฅ ๋˜๋Š” 200์ž ์ดˆ๊ณผ) + - ErrorCode: CONTENT_REPORT_SELF_REPORT_FORBIDDEN - ๋ณธ์ธ ์‹ ๊ณ  ๋ถˆ๊ฐ€ + """, content = @Content ), + @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", content = @Content), @ApiResponse( responseCode = "404", - description = "ErrorCode: DAILY_REPORT_NOT_FOUND - ๊ณต์œ ๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ", + description = """ + - ErrorCode: DAILY_REPORT_NOT_FOUND - ๊ณต์œ ๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ + - ErrorCode: COMMENT_NOT_FOUND - ๋Œ“๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ + """, content = @Content ), @ApiResponse( responseCode = "409", - description = "ErrorCode: CONTENT_REPORT_ALREADY_EXISTS - ์ด๋ฏธ ์‹ ๊ณ ํ•œ ๊ณต์œ ๊ธ€", + description = """ + - ErrorCode: CONTENT_REPORT_ALREADY_EXISTS - ์ด๋ฏธ ์‹ ๊ณ ํ•œ ๋Œ€์ƒ + - ErrorCode: COMMENT_DELETED - ์ด๋ฏธ ์‚ญ์ œ๋œ ๋Œ“๊ธ€ + """, content = @Content ) } @@ -89,12 +94,40 @@ public ResponseEntity> reportContent( contentReportCommandService.reportContent( principal.getId(), request.dailyReportId(), + request.commentId(), request.reason(), request.customReason() ); return ApiResponseEntity.noContent(); } + @GetMapping("/suspension/status") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "์†Œ์…œ ์ •์ง€ ์ƒํƒœ ์กฐํšŒ", + description = """ + ๋‚ด ์†Œ์…œ ์ •์ง€ ์—ฌ๋ถ€์™€ ํ•ด์ œ ์‹œ๊ฐ์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. (ํŒ์—… ํ‘œ์‹œ์šฉ) + + - isSuspended: ์ •์ง€ ์ค‘์ด๋ฉด true + - expiresAt: ์ •์ง€ ์ค‘์ผ ๋•Œ๋งŒ ๋ฐ˜ํ™˜ (์ •์ง€ ํ•ด์ œ ์‹œ๊ฐ, ISO 8601) + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse( + responseCode = "200", + description = "์กฐํšŒ ์„ฑ๊ณต", + content = @Content(schema = @Schema(implementation = SuspensionStatusResponse.class)) + ), + @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", content = @Content) + } + ) + public ResponseEntity> getSuspensionStatus( + @AuthenticationPrincipal UserPrincipal principal + ) { + SuspensionStatusResponse response = sharingSuspensionService.getSuspensionStatus(principal.getId()); + return ApiResponseEntity.ok(response); + } + @PostMapping("/blocks") @PreAuthorize("isAuthenticated()") @Operation( diff --git a/src/main/java/com/devkor/ifive/nadab/domain/moderation/api/dto/request/ReportContentRequest.java b/src/main/java/com/devkor/ifive/nadab/domain/moderation/api/dto/request/ReportContentRequest.java index e0c1346..6dad183 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/moderation/api/dto/request/ReportContentRequest.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/moderation/api/dto/request/ReportContentRequest.java @@ -4,13 +4,15 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; -@Schema(description = "๊ณต์œ ๊ธ€ ์‹ ๊ณ  ์š”์ฒญ") +@Schema(description = "์‹ ๊ณ  ์š”์ฒญ โ€” dailyReportId / commentId ์ค‘ ํ•˜๋‚˜๋งŒ ํ•„์ˆ˜") public record ReportContentRequest( - @NotNull(message = "์‹ ๊ณ ํ•  ๊ณต์œ ๊ธ€ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") - @Schema(description = "์‹ ๊ณ ํ•  DailyReport ID", example = "123") + @Schema(description = "์‹ ๊ณ ํ•  DailyReport ID (๊ฒŒ์‹œ๊ธ€ ์‹ ๊ณ  ์‹œ ํ•„์ˆ˜)", example = "123", nullable = true) Long dailyReportId, + @Schema(description = "์‹ ๊ณ ํ•  Comment ID (๋Œ“๊ธ€/๋Œ€๋Œ“๊ธ€ ์‹ ๊ณ  ์‹œ ํ•„์ˆ˜)", example = "456", nullable = true) + Long commentId, + @NotNull(message = "์‹ ๊ณ  ์‚ฌ์œ ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") @Schema( description = "์‹ ๊ณ  ์‚ฌ์œ  (PROFANITY_HATE_SPEECH, SEXUAL_CONTENT, SELF_HARM, OTHER)", diff --git a/src/main/java/com/devkor/ifive/nadab/domain/moderation/api/dto/response/SuspensionStatusResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/moderation/api/dto/response/SuspensionStatusResponse.java new file mode 100644 index 0000000..4d0a29a --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/moderation/api/dto/response/SuspensionStatusResponse.java @@ -0,0 +1,23 @@ +package com.devkor.ifive.nadab.domain.moderation.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.OffsetDateTime; + +@Schema(description = "์†Œ์…œ ์ •์ง€ ์ƒํƒœ ์‘๋‹ต") +public record SuspensionStatusResponse( + + @Schema(description = "์†Œ์…œ ์ •์ง€ ์—ฌ๋ถ€") + boolean isSuspended, + + @Schema(description = "์ •์ง€ ํ•ด์ œ ์‹œ๊ฐ (์ •์ง€ ์ค‘์ผ ๋•Œ๋งŒ ๋ฐ˜ํ™˜)", nullable = true) + OffsetDateTime expiresAt +) { + public static SuspensionStatusResponse notSuspended() { + return new SuspensionStatusResponse(false, null); + } + + public static SuspensionStatusResponse suspended(OffsetDateTime expiresAt) { + return new SuspensionStatusResponse(true, expiresAt); + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/moderation/application/ContentReportCommandService.java b/src/main/java/com/devkor/ifive/nadab/domain/moderation/application/ContentReportCommandService.java index c867f14..d1e80bc 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/moderation/application/ContentReportCommandService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/moderation/application/ContentReportCommandService.java @@ -1,5 +1,7 @@ package com.devkor.ifive.nadab.domain.moderation.application; +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.moderation.core.entity.ContentReport; @@ -23,46 +25,85 @@ public class ContentReportCommandService { private final ContentReportRepository contentReportRepository; private final DailyReportRepository dailyReportRepository; + private final CommentRepository commentRepository; private final UserRepository userRepository; + private final SharingSuspensionService sharingSuspensionService; - public void reportContent(Long reporterId, Long dailyReportId, ReportReason reason, String customReason) { - // 1. customReason ๊ฒ€์ฆ + public void reportContent(Long reporterId, Long dailyReportId, Long commentId, + ReportReason reason, String customReason) { + // 1. ์ž…๋ ฅ๊ฐ’ ๊ฒ€์ฆ validateCustomReason(reason, customReason); + validateTarget(dailyReportId, commentId); - // 2. ์ค‘๋ณต ์‹ ๊ณ  ๊ฒ€์ฆ + // 2. ์‹ ๊ณ  ์ƒ์„ฑ + User reporter = userRepository.getReferenceById(reporterId); + ContentReport report = commentId != null + ? buildCommentReport(reporter, reporterId, commentId, reason, customReason) + : buildDailyReportReport(reporter, reporterId, dailyReportId, reason, customReason); + + // 3. ์‹ ๊ณ  ์ €์žฅ (๋™์‹œ์„ฑ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ) + try { + contentReportRepository.save(report); + } catch (DataIntegrityViolationException e) { + boolean isDuplicate = commentId != null + ? contentReportRepository.existsByReporterIdAndCommentId(reporterId, commentId) + : contentReportRepository.existsByReporterIdAndDailyReportId(reporterId, dailyReportId); + if (isDuplicate) { + throw new ConflictException(ErrorCode.CONTENT_REPORT_ALREADY_EXISTS); + } + throw e; + } + + // 4. ์ž๋™ ์ •์ง€ ์กฐ๊ฑด ์ฒดํฌ + sharingSuspensionService.checkAndTriggerSuspension(report.getReportedUser().getId()); + } + + private ContentReport buildDailyReportReport(User reporter, Long reporterId, Long dailyReportId, + ReportReason reason, String customReason) { + // ์ค‘๋ณต ์‹ ๊ณ  ๊ฒ€์ฆ if (contentReportRepository.existsByReporterIdAndDailyReportId(reporterId, dailyReportId)) { throw new ConflictException(ErrorCode.CONTENT_REPORT_ALREADY_EXISTS); } - // 3. DailyReport ์กฐํšŒ + // DailyReport ์กฐํšŒ DailyReport dailyReport = dailyReportRepository.findById(dailyReportId) .orElseThrow(() -> new NotFoundException(ErrorCode.DAILY_REPORT_NOT_FOUND)); - // 4. ์ž๊ธฐ ์‹ ๊ณ  ๋ฐฉ์ง€ ๊ฒ€์ฆ + // ์ž๊ธฐ ์‹ ๊ณ  ๋ฐฉ์ง€ ๊ฒ€์ฆ User reportedUser = dailyReport.getAnswerEntry().getUser(); if (reporterId.equals(reportedUser.getId())) { throw new BadRequestException(ErrorCode.CONTENT_REPORT_SELF_REPORT_FORBIDDEN); } + return ContentReport.createForDailyReport(reporter, dailyReport, reportedUser, reason, customReason); + } - // 5. ์‹ ๊ณ  ์ €์žฅ (๋™์‹œ์„ฑ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ) - User reporter = userRepository.getReferenceById(reporterId); - - ContentReport report = ContentReport.create( - reporter, dailyReport, reportedUser, reason, customReason - ); + private ContentReport buildCommentReport(User reporter, Long reporterId, Long commentId, + ReportReason reason, String customReason) { + // ์ค‘๋ณต ์‹ ๊ณ  ๊ฒ€์ฆ + if (contentReportRepository.existsByReporterIdAndCommentId(reporterId, commentId)) { + throw new ConflictException(ErrorCode.CONTENT_REPORT_ALREADY_EXISTS); + } - try { - contentReportRepository.save(report); - } catch (DataIntegrityViolationException e) { - // UNIQUE ์ œ์•ฝ ์œ„๋ฐ˜์ธ์ง€ ์žฌ์กฐํšŒ๋กœ ํ™•์ธ - boolean isDuplicate = contentReportRepository - .existsByReporterIdAndDailyReportId(reporterId, dailyReportId); + // ๋Œ“๊ธ€ ์กฐํšŒ + Comment comment = commentRepository.findByIdWithAuthorAndDailyReport(commentId) + .orElseThrow(() -> commentRepository.existsById(commentId) + ? new ConflictException(ErrorCode.COMMENT_DELETED) + : new NotFoundException(ErrorCode.COMMENT_NOT_FOUND)); - if (isDuplicate) { - throw new ConflictException(ErrorCode.CONTENT_REPORT_ALREADY_EXISTS); - } + // ์ž๊ธฐ ์‹ ๊ณ  ๋ฐฉ์ง€ ๊ฒ€์ฆ + User reportedUser = comment.getAuthor(); + if (reporterId.equals(reportedUser.getId())) { + throw new BadRequestException(ErrorCode.CONTENT_REPORT_SELF_REPORT_FORBIDDEN); + } + return ContentReport.createForComment(reporter, comment, reportedUser, reason, customReason); + } - throw e; + private void validateTarget(Long dailyReportId, Long commentId) { + if (dailyReportId == null && commentId == null) { + throw new BadRequestException(ErrorCode.CONTENT_REPORT_INVALID); + } + if (dailyReportId != null && commentId != null) { + throw new BadRequestException(ErrorCode.CONTENT_REPORT_INVALID); } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/moderation/application/SharingSuspensionService.java b/src/main/java/com/devkor/ifive/nadab/domain/moderation/application/SharingSuspensionService.java index 55b56d2..fb43c11 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/moderation/application/SharingSuspensionService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/moderation/application/SharingSuspensionService.java @@ -1,45 +1,91 @@ package com.devkor.ifive.nadab.domain.moderation.application; +import com.devkor.ifive.nadab.domain.dailyreport.core.repository.DailyReportRepository; +import com.devkor.ifive.nadab.domain.moderation.api.dto.response.SuspensionStatusResponse; +import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; +import com.devkor.ifive.nadab.domain.moderation.core.entity.SocialSuspension; import com.devkor.ifive.nadab.domain.moderation.core.repository.ContentReportRepository; +import com.devkor.ifive.nadab.domain.moderation.core.repository.SocialSuspensionRepository; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.OffsetDateTime; import java.util.List; -/** - * ๊ณต์œ  ํ™œ๋™ ์ค‘์ง€ ํŒ๋‹จ ์„œ๋น„์Šค - */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class SharingSuspensionService { - private static final long REPORT_COUNT_THRESHOLD = 10L; - private static final long REPORTER_COUNT_THRESHOLD = 2L; + private static final long REPORT_COUNT_THRESHOLD = 20L; + private static final long REPORTER_COUNT_THRESHOLD = 3L; + static final long SUSPENSION_HOURS = 720L; private final ContentReportRepository contentReportRepository; + private final SocialSuspensionRepository socialSuspensionRepository; + private final DailyReportRepository dailyReportRepository; + private final UserRepository userRepository; - /** - * ํŠน์ • ์œ ์ €๊ฐ€ ๊ณต์œ  ํ™œ๋™ ์ค‘์ง€ ์ƒํƒœ์ธ์ง€ ํ™•์ธ (์‹ ๊ณ  10๊ฑด ์ด์ƒ && ์‹ ๊ณ ์ž 2๋ช… ์ด์ƒ) - */ public boolean isSharingSuspended(Long userId) { - long reportCount = contentReportRepository.countByReportedUserId(userId); - if (reportCount < REPORT_COUNT_THRESHOLD) { - return false; + return socialSuspensionRepository.existsByUserIdAndExpiresAtAfter(userId, OffsetDateTime.now()); + } + + public List getSharingSuspendedUserIds(List userIds) { + if (userIds == null || userIds.isEmpty()) { + return List.of(); } + return socialSuspensionRepository.findActiveSuspendedUserIds(userIds, OffsetDateTime.now()); + } + + public SuspensionStatusResponse getSuspensionStatus(Long userId) { + OffsetDateTime now = OffsetDateTime.now(); + return socialSuspensionRepository.findFirstByUserIdOrderByStartedAtDesc(userId) + .filter(s -> s.getExpiresAt().isAfter(now)) + .map(s -> SuspensionStatusResponse.suspended(s.getExpiresAt())) + .orElse(SuspensionStatusResponse.notSuspended()); + } - long reporterCount = contentReportRepository.countDistinctReportersByReportedUserId(userId); - return reporterCount >= REPORTER_COUNT_THRESHOLD; + public List getAllActiveSuspendedUserIds() { + return socialSuspensionRepository.findAllActiveSuspendedUserIds(OffsetDateTime.now()); } /** - * ์—ฌ๋Ÿฌ ์œ ์ € ์ค‘ ๊ณต์œ  ํ™œ๋™ ์ค‘์ง€๋œ ์œ ์ € ID ์กฐํšŒ + * ์‹ ๊ณ  ์ €์žฅ ํ›„ ํ˜ธ์ถœ. ์ •์ง€ ์กฐ๊ฑด(์นœ๊ตฌ ์‹ ๊ณ  20๊ฑด ์ด์ƒ + ์‹ ๊ณ ์ž 3๋ช… ์ด์ƒ) ์ถฉ์กฑ ์‹œ ์ •์ง€ ๋ฐœ๋™. + * ์ •์ง€ ์ค‘์ด๋ฉด ์กฐ๊ฑด ์ฒดํฌ๋ฅผ ๊ฑด๋„ˆ๋œ€. */ - public List getSharingSuspendedUserIds(List userIds) { - if (userIds == null || userIds.isEmpty()) { - return List.of(); + @Transactional + public void checkAndTriggerSuspension(Long reportedUserId) { + if (isSharingSuspended(reportedUserId)) { + return; + } + + // ๊ฐ€์žฅ ์ตœ๊ทผ ์ •์ง€์˜ expires_at์„ ๊ธฐ์ค€์ ์œผ๋กœ ์‚ฌ์šฉ (์—†์œผ๋ฉด ์ „์ฒด ๋ˆ„์ ) + OffsetDateTime since = socialSuspensionRepository + .findFirstByUserIdOrderByStartedAtDesc(reportedUserId) + .map(SocialSuspension::getExpiresAt) + .orElse(null); + + long reportCount = since == null + ? contentReportRepository.countAllReports(reportedUserId) + : contentReportRepository.countReportsSince(reportedUserId, since); + if (reportCount < REPORT_COUNT_THRESHOLD) { + return; } - return contentReportRepository.findSharingSuspendedUserIds(userIds); + + long reporterCount = since == null + ? contentReportRepository.countAllDistinctReporters(reportedUserId) + : contentReportRepository.countDistinctReportersSince(reportedUserId, since); + if (reporterCount < REPORTER_COUNT_THRESHOLD) { + return; + } + + // ์ •์ง€ ๋ฐœ๋™ + OffsetDateTime now = OffsetDateTime.now(); + User user = userRepository.getReferenceById(reportedUserId); + socialSuspensionRepository.save(SocialSuspension.create(user, now, now.plusHours(SUSPENSION_HOURS))); + dailyReportRepository.stopSharingByUserIdAndDate(reportedUserId, TodayDateTimeProvider.getTodayDate()); } } \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/entity/ContentReport.java b/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/entity/ContentReport.java index 31aec8f..3455e79 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/entity/ContentReport.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/entity/ContentReport.java @@ -1,5 +1,6 @@ package com.devkor.ifive.nadab.domain.moderation.core.entity; +import com.devkor.ifive.nadab.domain.comment.core.entity.Comment; import com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReport; import com.devkor.ifive.nadab.domain.user.core.entity.User; import com.devkor.ifive.nadab.global.shared.entity.CreatableEntity; @@ -26,10 +27,14 @@ public class ContentReport extends CreatableEntity { @JoinColumn(name = "reported_user_id", nullable = false) private User reportedUser; - @ManyToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "daily_report_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "daily_report_id") private DailyReport dailyReport; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "comment_id") + private Comment comment; + @Enumerated(EnumType.STRING) @Column(name = "reason", nullable = false, length = 50) private ReportReason reason; @@ -37,7 +42,7 @@ public class ContentReport extends CreatableEntity { @Column(name = "custom_reason", length = 200) private String customReason; - public static ContentReport create( + public static ContentReport createForDailyReport( User reporter, DailyReport dailyReport, User reportedUser, @@ -49,7 +54,23 @@ public static ContentReport create( report.dailyReport = dailyReport; report.reportedUser = reportedUser; report.reason = reason; - report.customReason = customReason; // ๊ธฐํƒ€ ์‚ฌ์œ  (OTHER์ผ ๋•Œ๋งŒ) + report.customReason = customReason; + return report; + } + + public static ContentReport createForComment( + User reporter, + Comment comment, + User reportedUser, + ReportReason reason, + String customReason + ) { + ContentReport report = new ContentReport(); + report.reporter = reporter; + report.comment = comment; + report.reportedUser = reportedUser; + report.reason = reason; + report.customReason = customReason; return report; } } \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/entity/SocialSuspension.java b/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/entity/SocialSuspension.java new file mode 100644 index 0000000..decd114 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/entity/SocialSuspension.java @@ -0,0 +1,39 @@ +package com.devkor.ifive.nadab.domain.moderation.core.entity; + +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.OffsetDateTime; + +@Entity +@Table(name = "social_suspensions") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class SocialSuspension { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "started_at", nullable = false) + private OffsetDateTime startedAt; + + @Column(name = "expires_at", nullable = false) + private OffsetDateTime expiresAt; + + public static SocialSuspension create(User user, OffsetDateTime startedAt, OffsetDateTime expiresAt) { + SocialSuspension s = new SocialSuspension(); + s.user = user; + s.startedAt = startedAt; + s.expiresAt = expiresAt; + return s; + } + +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/repository/ContentReportRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/repository/ContentReportRepository.java index 7385b1c..2c84429 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/repository/ContentReportRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/repository/ContentReportRepository.java @@ -5,42 +5,58 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.OffsetDateTime; import java.util.List; public interface ContentReportRepository extends JpaRepository { - /** - * ์ค‘๋ณต ์‹ ๊ณ  ์ฒดํฌ - */ boolean existsByReporterIdAndDailyReportId(Long reporterId, Long dailyReportId); + boolean existsByReporterIdAndCommentId(Long reporterId, Long commentId); + /** - * ํŠน์ • ์œ ์ €์— ๋Œ€ํ•œ ์‹ ๊ณ  ๊ฑด์ˆ˜ ์กฐํšŒ + * reportedUser๋ฅผ ์‹ ๊ณ ํ•œ ์ „์ฒด ๋ˆ„์  ๊ฑด์ˆ˜ */ - long countByReportedUserId(Long reportedUserId); + @Query(""" + SELECT COUNT(cr.id) + FROM ContentReport cr + WHERE cr.reportedUser.id = :reportedUserId + """) + long countAllReports(@Param("reportedUserId") Long reportedUserId); /** - * ํŠน์ • ์œ ์ €๋ฅผ ์‹ ๊ณ ํ•œ ์‚ฌ๋žŒ ์ˆ˜ (์ค‘๋ณต ์ œ๊ฑฐ) + * reportedUser๋ฅผ ์‹ ๊ณ ํ•œ ์ „์ฒด ๋ˆ„์  distinct ์‹ ๊ณ ์ž ์ˆ˜ */ @Query(""" SELECT COUNT(DISTINCT cr.reporter.id) FROM ContentReport cr WHERE cr.reportedUser.id = :reportedUserId """) - long countDistinctReportersByReportedUserId(@Param("reportedUserId") Long reportedUserId); + long countAllDistinctReporters(@Param("reportedUserId") Long reportedUserId); + + /** + * reportedUser๋ฅผ ์‹ ๊ณ ํ•œ ๊ฑด์ˆ˜ (since ์ดํ›„ ๋ˆ„์ ) + */ + @Query(""" + SELECT COUNT(cr.id) + FROM ContentReport cr + WHERE cr.reportedUser.id = :reportedUserId + AND cr.createdAt > :since + """) + long countReportsSince(@Param("reportedUserId") Long reportedUserId, + @Param("since") OffsetDateTime since); /** - * ๊ณต์œ  ํ™œ๋™ ์ค‘์ง€ ๋Œ€์ƒ ์œ ์ € ID ์กฐํšŒ (์‹ ๊ณ  10๊ฑด ์ด์ƒ && ์‹ ๊ณ ์ž 2๋ช… ์ด์ƒ) + * reportedUser๋ฅผ ์‹ ๊ณ ํ•œ distinct ์‹ ๊ณ ์ž ์ˆ˜ (since ์ดํ›„ ๋ˆ„์ ) */ @Query(""" - SELECT cr.reportedUser.id + SELECT COUNT(DISTINCT cr.reporter.id) FROM ContentReport cr - WHERE cr.reportedUser.id IN :userIds - GROUP BY cr.reportedUser.id - HAVING COUNT(cr.id) >= 10 - AND COUNT(DISTINCT cr.reporter.id) >= 2 + WHERE cr.reportedUser.id = :reportedUserId + AND cr.createdAt > :since """) - List findSharingSuspendedUserIds(@Param("userIds") List userIds); + long countDistinctReportersSince(@Param("reportedUserId") Long reportedUserId, + @Param("since") OffsetDateTime since); /** * ๋‚ด๊ฐ€ ์‹ ๊ณ ํ•œ DailyReport ID ๋ชฉ๋ก ์กฐํšŒ diff --git a/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/repository/SocialSuspensionRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/repository/SocialSuspensionRepository.java new file mode 100644 index 0000000..2dff853 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/repository/SocialSuspensionRepository.java @@ -0,0 +1,36 @@ +package com.devkor.ifive.nadab.domain.moderation.core.repository; + +import com.devkor.ifive.nadab.domain.moderation.core.entity.SocialSuspension; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; + +public interface SocialSuspensionRepository extends JpaRepository { + + boolean existsByUserIdAndExpiresAtAfter(Long userId, OffsetDateTime now); + + @Query(""" + SELECT DISTINCT ss.user.id + FROM SocialSuspension ss + WHERE ss.user.id IN :userIds + AND ss.expiresAt > :now + """) + List findActiveSuspendedUserIds(@Param("userIds") List userIds, + @Param("now") OffsetDateTime now); + + @Query(""" + SELECT DISTINCT ss.user.id + FROM SocialSuspension ss + WHERE ss.expiresAt > :now + """) + List findAllActiveSuspendedUserIds(@Param("now") OffsetDateTime now); + + /** + * ๊ฐ€์žฅ ์ตœ๊ทผ ์ •์ง€ ๋ ˆ์ฝ”๋“œ ์กฐํšŒ โ€” ์‹ ๊ณ  ๋ˆ„์  ๊ธฐ์ค€์ (expires_at)์œผ๋กœ ์‚ฌ์šฉ + */ + Optional findFirstByUserIdOrderByStartedAtDesc(Long userId); +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/repository/UserBlockRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/repository/UserBlockRepository.java index 4902dd8..774f4a5 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/repository/UserBlockRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/moderation/core/repository/UserBlockRepository.java @@ -33,4 +33,12 @@ select case when exists ( ) then true else false end """) boolean existsAnyBlockBetweenUsers(@Param("userId") Long userId, @Param("otherUserId") Long otherUserId); + + @Query(""" + select case when ub.blocker.id = :userId then ub.blocked.id + else ub.blocker.id end + from UserBlock ub + where ub.blocker.id = :userId or ub.blocked.id = :userId + """) + List findBlockedUserIdsBidirectional(@Param("userId") Long userId); } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/notification/application/event/social/CommentNotificationEventListener.java b/src/main/java/com/devkor/ifive/nadab/domain/notification/application/event/social/CommentNotificationEventListener.java new file mode 100644 index 0000000..1fc63e1 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/notification/application/event/social/CommentNotificationEventListener.java @@ -0,0 +1,168 @@ +package com.devkor.ifive.nadab.domain.notification.application.event.social; + +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.repository.CommentRepository; +import com.devkor.ifive.nadab.domain.notification.application.NotificationCommandService; +import com.devkor.ifive.nadab.domain.notification.core.entity.NotificationType; +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.notification.message.NotificationContent; +import com.devkor.ifive.nadab.global.core.notification.message.NotificationMessageFactory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +@Component +@RequiredArgsConstructor +@Slf4j +public class CommentNotificationEventListener { + + private final UserRepository userRepository; + private final CommentRepository commentRepository; + private final NotificationMessageFactory messageFactory; + private final NotificationCommandService notificationCommandService; + + @Async("notificationTaskExecutor") + @EventListener + public void handleCommentCreated(CommentCreatedEvent event) { + try { + if (event.getAuthorId().equals(event.getReportOwnerId())) { + log.debug("Self-comment, skip notification: commentId={}", event.getCommentId()); + return; + } + + User author = userRepository.findById(event.getAuthorId()).orElse(null); + if (author == null || author.getDeletedAt() != null) { + log.debug("Author not found or deleted, skip notification: authorId={}", event.getAuthorId()); + return; + } + + User reportOwner = userRepository.findById(event.getReportOwnerId()).orElse(null); + if (reportOwner == null || reportOwner.getDeletedAt() != null) { + log.debug("Report owner not found or deleted, skip notification: reportOwnerId={}", event.getReportOwnerId()); + return; + } + + Map params = Map.of( + "senderName", author.getNickname(), + "commentContent", truncate(event.getContent()) + ); + NotificationContent content = messageFactory.createMessage(NotificationType.COMMENT_ON_MY_REPORT, params); + + String idempotencyKey = String.format("COMMENT_%d_REPORT_OWNER", event.getCommentId()); + notificationCommandService.sendNotification( + event.getReportOwnerId(), + NotificationType.COMMENT_ON_MY_REPORT, + content.title(), + content.body(), + content.inboxMessage(), + event.getDailyReportId().toString(), + idempotencyKey + ); + log.debug("Comment notification sent: commentId={}, reportOwnerId={}", event.getCommentId(), event.getReportOwnerId()); + } catch (Exception e) { + log.error("Failed to handle CommentCreatedEvent: commentId={}, error={}", + event.getCommentId(), e.getMessage(), e); + } + } + + @Async("notificationTaskExecutor") + @EventListener + public void handleSubCommentCreated(SubCommentCreatedEvent event) { + try { + User author = userRepository.findById(event.getAuthorId()).orElse(null); + if (author == null || author.getDeletedAt() != null) { + log.debug("Author not found or deleted, skip notification: authorId={}", event.getAuthorId()); + return; + } + + Map params = Map.of( + "senderName", author.getNickname(), + "commentContent", truncate(event.getContent()) + ); + + // 1. ๋ถ€๋ชจ ๋Œ“๊ธ€ ์ž‘์„ฑ์ž ์•Œ๋ฆผ (author ์ œ์™ธ, ์—ญํ•  ๋ฌด๊ด€ ์ตœ์šฐ์„ ) + if (!event.getAuthorId().equals(event.getParentCommentAuthorId())) { + User parentCommentAuthor = userRepository.findById(event.getParentCommentAuthorId()).orElse(null); + if (parentCommentAuthor == null || parentCommentAuthor.getDeletedAt() != null) { + log.debug("Parent comment author not found or deleted, skip notification: parentCommentAuthorId={}", event.getParentCommentAuthorId()); + } else { + NotificationContent replyContent = messageFactory.createMessage( + NotificationType.REPLY_ON_MY_COMMENT, params); + notificationCommandService.sendNotification( + event.getParentCommentAuthorId(), + NotificationType.REPLY_ON_MY_COMMENT, + replyContent.title(), + replyContent.body(), + replyContent.inboxMessage(), + event.getDailyReportId().toString(), + String.format("COMMENT_%d_PARENT_AUTHOR", event.getSubCommentId()) + ); + log.debug("Sub-comment notification sent to parent author: subCommentId={}, parentCommentAuthorId={}", event.getSubCommentId(), event.getParentCommentAuthorId()); + } + } + + // 2. ์ฐธ์—ฌ์ž ์•Œ๋ฆผ (author, ๋ถ€๋ชจ ๋Œ“๊ธ€ ์ž‘์„ฑ์ž ์ œ์™ธ) โ€” ๋ฆฌํฌํŠธ ๋‹น์‚ฌ์ž๋„ ์ฐธ์—ฌ์ž๋ฉด ์—ฌ๊ธฐ์„œ ์ฒ˜๋ฆฌ + List excludeFromParticipants = List.of( + event.getAuthorId(), + event.getParentCommentAuthorId() + ); + List participantIds = commentRepository.findDistinctSubCommentAuthorIds( + event.getParentCommentId(), excludeFromParticipants); + + // 3. ๋ฆฌํฌํŠธ ๋‹น์‚ฌ์ž๊ฐ€ ์ฐธ์—ฌ์ž๊ฐ€ ์•„๋‹Œ ๊ฒฝ์šฐ์—๋งŒ COMMENT_ON_MY_REPORT + boolean reportOwnerIsParentAuthor = event.getReportOwnerId().equals(event.getParentCommentAuthorId()); + boolean reportOwnerIsParticipant = participantIds.contains(event.getReportOwnerId()); + boolean reportOwnerIsAuthor = event.getReportOwnerId().equals(event.getAuthorId()); + + if (!reportOwnerIsAuthor && !reportOwnerIsParentAuthor && !reportOwnerIsParticipant) { + User reportOwner = userRepository.findById(event.getReportOwnerId()).orElse(null); + if (reportOwner == null || reportOwner.getDeletedAt() != null) { + log.debug("Report owner not found or deleted, skip notification: reportOwnerId={}", event.getReportOwnerId()); + } else { + NotificationContent reportOwnerContent = messageFactory.createMessage( + NotificationType.COMMENT_ON_MY_REPORT, params); + notificationCommandService.sendNotification( + event.getReportOwnerId(), + NotificationType.COMMENT_ON_MY_REPORT, + reportOwnerContent.title(), + reportOwnerContent.body(), + reportOwnerContent.inboxMessage(), + event.getDailyReportId().toString(), + String.format("COMMENT_%d_REPORT_OWNER", event.getSubCommentId()) + ); + log.debug("Sub-comment notification sent to report owner: subCommentId={}, reportOwnerId={}", event.getSubCommentId(), event.getReportOwnerId()); + } + } + + NotificationContent participantContent = messageFactory.createMessage( + NotificationType.REPLY_ON_PARTICIPATED_COMMENT, params); + for (Long participantId : participantIds) { + notificationCommandService.sendNotification( + participantId, + NotificationType.REPLY_ON_PARTICIPATED_COMMENT, + participantContent.title(), + participantContent.body(), + participantContent.inboxMessage(), + event.getDailyReportId().toString(), + String.format("COMMENT_%d_PARTICIPANT_%d", event.getSubCommentId(), participantId) + ); + } + log.debug("Sub-comment notifications sent: subCommentId={}, participantCount={}", event.getSubCommentId(), participantIds.size()); + } catch (Exception e) { + log.error("Failed to handle SubCommentCreatedEvent: subCommentId={}, error={}", + event.getSubCommentId(), e.getMessage(), e); + } + } + + private String truncate(String content) { + if (content == null) return ""; + return content.length() > 20 ? content.substring(0, 20).stripTrailing() + "..." : content; + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/notification/application/event/friend/FriendNotificationEventListener.java b/src/main/java/com/devkor/ifive/nadab/domain/notification/application/event/social/FriendNotificationEventListener.java similarity index 99% rename from src/main/java/com/devkor/ifive/nadab/domain/notification/application/event/friend/FriendNotificationEventListener.java rename to src/main/java/com/devkor/ifive/nadab/domain/notification/application/event/social/FriendNotificationEventListener.java index 467de66..5c9039c 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/notification/application/event/friend/FriendNotificationEventListener.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/notification/application/event/social/FriendNotificationEventListener.java @@ -1,4 +1,4 @@ -package com.devkor.ifive.nadab.domain.notification.application.event.friend; +package com.devkor.ifive.nadab.domain.notification.application.event.social; import com.devkor.ifive.nadab.domain.friend.application.event.FriendRequestAcceptedEvent; import com.devkor.ifive.nadab.domain.friend.application.event.FriendRequestReceivedEvent; diff --git a/src/main/java/com/devkor/ifive/nadab/domain/notification/core/entity/NotificationType.java b/src/main/java/com/devkor/ifive/nadab/domain/notification/core/entity/NotificationType.java index a9d7f47..c382199 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/notification/core/entity/NotificationType.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/notification/core/entity/NotificationType.java @@ -22,7 +22,10 @@ public enum NotificationType { // ์†Œ์…œ ์•Œ๋ฆผ FRIEND_REQUEST_RECEIVED("์นœ๊ตฌ ์š”์ฒญ", NotificationGroup.SOCIAL), - FRIEND_REQUEST_ACCEPTED("์นœ๊ตฌ ์ˆ˜๋ฝ", NotificationGroup.SOCIAL); + FRIEND_REQUEST_ACCEPTED("์นœ๊ตฌ ์ˆ˜๋ฝ", NotificationGroup.SOCIAL), + COMMENT_ON_MY_REPORT("๋‚ด ๋ฆฌํฌํŠธ์— ๋Œ“๊ธ€/๋Œ€๋Œ“๊ธ€", NotificationGroup.SOCIAL), + REPLY_ON_MY_COMMENT("๋‚ด ๋Œ“๊ธ€์— ๋Œ€๋Œ“๊ธ€", NotificationGroup.SOCIAL), + REPLY_ON_PARTICIPATED_COMMENT("์ฐธ์—ฌ ๋Œ“๊ธ€์— ๋Œ€๋Œ“๊ธ€", NotificationGroup.SOCIAL); private final String description; private final NotificationGroup group; diff --git a/src/main/java/com/devkor/ifive/nadab/global/core/notification/message/NotificationMessageFactory.java b/src/main/java/com/devkor/ifive/nadab/global/core/notification/message/NotificationMessageFactory.java index 8a4f64a..62826a2 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/core/notification/message/NotificationMessageFactory.java +++ b/src/main/java/com/devkor/ifive/nadab/global/core/notification/message/NotificationMessageFactory.java @@ -50,7 +50,8 @@ private void validateAllTemplates() { "senderName", "ํ…Œ์ŠคํŠธ", "categoryName", "ํ…Œ์ŠคํŠธ", "daysInactive", "5", - "milestone", "30" + "milestone", "30", + "commentContent", "ํ…Œ์ŠคํŠธ ๋Œ“๊ธ€ ๋‚ด์šฉ" ); for (NotificationType type : NotificationType.values()) { diff --git a/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java b/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java index 6528450..3c40d99 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java +++ b/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java @@ -392,7 +392,31 @@ public enum ErrorCode { CONTENT_REPORT_SELF_REPORT_FORBIDDEN(HttpStatus.BAD_REQUEST, "์ž์‹ ์˜ ๊ฒŒ์‹œ๊ธ€์€ ์‹ ๊ณ ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"), // 409 Conflict - CONTENT_REPORT_ALREADY_EXISTS(HttpStatus.CONFLICT, "์ด๋ฏธ ์‹ ๊ณ ํ•œ ๊ฒŒ์‹œ๊ธ€์ž…๋‹ˆ๋‹ค"); + CONTENT_REPORT_ALREADY_EXISTS(HttpStatus.CONFLICT, "์ด๋ฏธ ์‹ ๊ณ ํ•œ ๊ฒŒ์‹œ๊ธ€์ž…๋‹ˆ๋‹ค"), + + // ==================== MODERATION (์†Œ์…œ ์ •์ง€) ==================== + // 400 Bad Request + SOCIAL_SUSPENDED(HttpStatus.BAD_REQUEST, "์†Œ์…œ ํ™œ๋™์ด ์ •์ง€๋œ ์ƒํƒœ์ž…๋‹ˆ๋‹ค"), + + // ==================== COMMENT (๋Œ“๊ธ€) ==================== + // 400 Bad Request + COMMENT_NOT_TOP_LEVEL(HttpStatus.BAD_REQUEST, "๋Œ€๋Œ“๊ธ€์—๋Š” ๋‹ต๊ธ€์„ ๋‹ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"), + + // 404 Not Found + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "๋Œ“๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"), + + // 409 Conflict + COMMENT_DELETED(HttpStatus.CONFLICT, "์ด๋ฏธ ์‚ญ์ œ๋œ ๋Œ“๊ธ€์ž…๋‹ˆ๋‹ค"), + + // ==================== LIKE (์ข‹์•„์š”) ==================== + // 400 Bad Request + CANNOT_LIKE_OWN_CONTENT(HttpStatus.BAD_REQUEST, "๋ณธ์ธ์˜ ๊ฒŒ์‹œ๊ธ€/๋Œ“๊ธ€์—๋Š” ์ข‹์•„์š”๋ฅผ ๋ˆ„๋ฅผ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"), + + // 403 Forbidden + DAILY_REPORT_LIKE_LIST_FORBIDDEN(HttpStatus.FORBIDDEN, "๋ณธ์ธ์˜ ๊ฒŒ์‹œ๊ธ€ ์ข‹์•„์š” ๋ฆฌ์ŠคํŠธ๋งŒ ์กฐํšŒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค"), + + // 404 Not Found + LIKE_NOT_FOUND(HttpStatus.NOT_FOUND, "์ข‹์•„์š”๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"); private final HttpStatus httpStatus; diff --git a/src/main/resources/db/migration/V20260515_1730__CH_create_comments_table.sql b/src/main/resources/db/migration/V20260515_1730__CH_create_comments_table.sql new file mode 100644 index 0000000..3baf0d1 --- /dev/null +++ b/src/main/resources/db/migration/V20260515_1730__CH_create_comments_table.sql @@ -0,0 +1,18 @@ +CREATE TABLE comments ( + id BIGSERIAL PRIMARY KEY, + daily_report_id BIGINT NOT NULL, + author_id BIGINT NOT NULL, + parent_comment_id BIGINT, + content VARCHAR(500) NOT NULL, + is_secret BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + + CONSTRAINT fk_comments_daily_report FOREIGN KEY (daily_report_id) REFERENCES daily_reports(id) ON DELETE CASCADE, + CONSTRAINT fk_comments_author FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_comments_parent_comment FOREIGN KEY (parent_comment_id) REFERENCES comments(id) ON DELETE CASCADE +); + +CREATE INDEX idx_comments_daily_report_id ON comments (daily_report_id); +CREATE INDEX idx_comments_parent_comment_id ON comments (parent_comment_id); \ No newline at end of file diff --git a/src/main/resources/db/migration/V20260515_1735__CH_create_likes_tables.sql b/src/main/resources/db/migration/V20260515_1735__CH_create_likes_tables.sql new file mode 100644 index 0000000..4cafb26 --- /dev/null +++ b/src/main/resources/db/migration/V20260515_1735__CH_create_likes_tables.sql @@ -0,0 +1,19 @@ +CREATE TABLE daily_report_likes ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + daily_report_id BIGINT NOT NULL REFERENCES daily_reports(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_daily_report_likes_user_report UNIQUE (user_id, daily_report_id) +); + +CREATE INDEX idx_daily_report_likes_report ON daily_report_likes (daily_report_id); + +CREATE TABLE comment_likes ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + comment_id BIGINT NOT NULL REFERENCES comments(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_comment_likes_user_comment UNIQUE (user_id, comment_id) +); + +CREATE INDEX idx_comment_likes_comment ON comment_likes (comment_id); \ No newline at end of file diff --git a/src/main/resources/db/migration/V20260518_1500__CH_create_social_suspensions_table.sql b/src/main/resources/db/migration/V20260518_1500__CH_create_social_suspensions_table.sql new file mode 100644 index 0000000..52164e4 --- /dev/null +++ b/src/main/resources/db/migration/V20260518_1500__CH_create_social_suspensions_table.sql @@ -0,0 +1,11 @@ +CREATE TABLE social_suspensions ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + + CONSTRAINT fk_social_suspensions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX idx_social_suspensions_user_expires ON social_suspensions(user_id, expires_at); +CREATE INDEX idx_social_suspensions_expires ON social_suspensions(expires_at); \ No newline at end of file diff --git a/src/main/resources/db/migration/V20260518_1503__CH_alter_content_reports_for_comments.sql b/src/main/resources/db/migration/V20260518_1503__CH_alter_content_reports_for_comments.sql new file mode 100644 index 0000000..3fa6816 --- /dev/null +++ b/src/main/resources/db/migration/V20260518_1503__CH_alter_content_reports_for_comments.sql @@ -0,0 +1,29 @@ +-- comment_id ์ปฌ๋Ÿผ ์ถ”๊ฐ€ +ALTER TABLE content_reports ADD COLUMN comment_id BIGINT; + +-- daily_report_id NOT NULL โ†’ nullable +ALTER TABLE content_reports ALTER COLUMN daily_report_id DROP NOT NULL; + +-- ๊ธฐ์กด UNIQUE ์ œ์•ฝ ์ œ๊ฑฐ +ALTER TABLE content_reports DROP CONSTRAINT uq_content_reports_reporter_daily_report; + +-- daily_report_id FK: ON DELETE CASCADE โ†’ ON DELETE SET NULL +ALTER TABLE content_reports DROP CONSTRAINT fk_content_reports_daily_report; +ALTER TABLE content_reports + ADD CONSTRAINT fk_content_reports_daily_report + FOREIGN KEY (daily_report_id) REFERENCES daily_reports(id) ON DELETE SET NULL; + +-- comment_id FK +ALTER TABLE content_reports + ADD CONSTRAINT fk_content_reports_comment + FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE SET NULL; + +-- ๊ฒŒ์‹œ๊ธ€ ์‹ ๊ณ  partial unique (๊ฒŒ์‹œ๊ธ€ ์‹ ๊ณ ์—์„œ ์ค‘๋ณต ๋ฐฉ์ง€) +CREATE UNIQUE INDEX uq_content_reports_reporter_daily_report + ON content_reports(reporter_id, daily_report_id) + WHERE daily_report_id IS NOT NULL AND comment_id IS NULL; + +-- ๋Œ“๊ธ€ ์‹ ๊ณ  partial unique (๋Œ“๊ธ€ ์‹ ๊ณ ์—์„œ ์ค‘๋ณต ๋ฐฉ์ง€) +CREATE UNIQUE INDEX uq_content_reports_reporter_comment + ON content_reports(reporter_id, comment_id) + WHERE comment_id IS NOT NULL; \ No newline at end of file