From f06d0c1c5470f5bcaa1ac2f2858c82394edadad0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=ED=98=84=EC=98=81?= <89445100+hamo-o@users.noreply.github.com> Date: Mon, 24 Feb 2025 23:54:35 +0900 Subject: [PATCH 01/66] =?UTF-8?q?[All-Chore]=20Readme=20=EC=A3=BC=EC=9A=94?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EC=97=AD=ED=95=A0=20=EC=B6=94=EA=B0=80=20(#294)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 64 +++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 50 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 171700e9..f7a5697c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # ⏰ 언제 만나? 바쁜 학생들을 위한 가장 완벽한 약속 시간 찾기 -사용자들의 개인 일정을 연동하여 최적의 약속 시간을 추천하는 일정 조율 서비스 +여러 사람의 일정을 한눈에 비교하고, 효율적으로 조율할 수 있어요. 바쁜 일정 속에서도 모임을 쉽게 계획할 수 있도록 돕는 스마트한 일정 조율 플랫폼이에요! 🔗 [배포 링크](https://unjemanna.site/) @@ -20,32 +20,62 @@ --- +## ✨ 주요 기능 +### 일정 조율 생성 +#### 📩 초대 링크 공유로 간편한 참여 +일정 조율 논의를 생성한 후, 초대 링크를 공유해 손쉽게 논의에 참여할 수 있어요. -## 🏡 아키텍쳐 - -
+| 일정 조율 생성 | 일정 조율 생성 완료 & 초대 링크 복사 | +|-------------------------------------------|-------------------------------------------| +| | | +| 조율할 일정을 생성할 수 있어요. | 일정 조율 링크를 복사하고 공유할 수 있어요. | ---- +### 일정 조율 초대 참여 및 조회 + +#### 🕒 최적의 후보 일정 추천 +조율할 날짜 범위와 일정이 진행될 시간대를 설정하면, 모든 참여자의 일정을 분석해 가장 적합한 후보 일정을 추천해 줘요. + +| 일정 조율 초대 참여 | 일정 조율 생성 완료 | +|-------------------------------------------|-------------------------------------------| +| | | +| 초대 링크로 일정 조율에 참여할 수 있어요.| 필터링을 통해 원하는 사람들끼리의 일정 조율 결과를 확인할 수 있어요. | + + + +### 일정 확정 +#### ✅ 확정된 일정 자동 반영 +일정 조율이 완료되면, 확정된 일정이 모든 참여자의 개인 일정에 자동으로 추가돼요. +Google 캘린더와 연동된 경우, 캘린더에도 자동으로 반영되어 더욱 편리하게 관리할 수 있어요. -## ✨ 메인 기능 -### 공유 일정 +| 일정 조율 상태 확인 | 일정 확정 | +|-------------------------------------------|-------------------------------------------| +| | | +| 후보 일정에서 조율해야 하는 사람을 알 수 있어요. | 주최자는 후보 일정을 확정해서 일정 조율을 완료할 수 있어요. | -| 일정 조율 생성 | 일정 조율 생성 완료 | 일정 조율 결과 | -|-------------------------------------------|-------------------------------------------|-------------------------------------------| -| | | | -| 조율할 일정을 생성할 수 있습니다. | 일정 조율 링크를 복사하고 공유할 수 있습니다. | 필터링을 통해 원하는 사람들끼리의 일정 조율 결과를 확인할 수 있습니다. | +### 개인 일정 관리 +#### 🔗 Google 캘린더 연동으로 더욱 편리하게 +사용자는 Google 캘린더와 연동하여 자신의 일정을 불러올 수 있어요. +별도의 입력 없이 기존 일정을 기반으로 조율이 가능해요. | 내 일정 관리 | 홈 | |-------------------------------------------|-------------------------------------------| | | | | -| 내 일정을 생성하고 관리할 수 있습니다. 구글 캘린더와의 연동을 지원합니다. | 다가오는 일정, 확정되지 않은 일정, 지난 일정을 확인할 수 있습니다.| +| 내 일정을 생성하고 관리할 수 있어요. | 다가오는 일정, 확정되지 않은 일정, 지난 일정을 확인할 수 있어요.|
--- -## 👥 팀원 구성 + +## 🏡 아키텍쳐 + + +
+ +--- + +## 👥 팀원 역할 @@ -71,7 +101,13 @@ -
이현영BE BE(팀장)
+ + ✔ 개인/공유 일정 캘린더 구현
✔ 디자인 토큰 & SVG & 이미지 변환 스크립트 작성
✔ 전역 Modal, Notification 관리 & 에러핸들링 + ✔ Single/Range DatePicker 구현
✔ git hook & 슬랙 웹훅 세팅
✔ Fetch 유틸 구현 + ✔ 비트 연산 로직(후보 일정 산출) 구현
✔ 구글 캘린더 API 연동 (+OAuth)
✔ Redis 세팅 (동현)
✔ CI/CD (동권) + + +
--- From d88c12845504bc3b2d34d0cdf7b61032857e51a3 Mon Sep 17 00:00:00 2001 From: Kim DongHyun <150654090+efdao@users.noreply.github.com> Date: Tue, 25 Feb 2025 10:10:58 +0900 Subject: [PATCH 02/66] =?UTF-8?q?[ALL-Chore]=20readme=20ERD,=20AWS=20?= =?UTF-8?q?=EC=95=84=ED=82=A4=ED=85=8D=EC=B2=98,=20=ED=8C=80=20=EC=9C=84?= =?UTF-8?q?=ED=82=A4=20=EB=B0=94=EB=A1=9C=EA=B0=80=EA=B8=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#298)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f7a5697c..4c9a4022 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,10 @@ | **인프라** | ![AWS](https://img.shields.io/badge/AWS-%23FF9900.svg?logo=amazon-web-services&logoColor=white) ![GitHub Actions](https://img.shields.io/badge/GitHub%20Actions-%232088FF.svg?style=flat&logo=github-actions&logoColor=white) ![Docker](https://img.shields.io/badge/Docker-%232496ED.svg?style=flat&logo=docker&logoColor=white) ![Nginx](https://img.shields.io/badge/Nginx-%23009639.svg?style=flat&logo=nginx&logoColor=white) | | **소통** | ![Swagger](https://img.shields.io/badge/-Swagger-%23Clojure?style=flat&logo=swagger&logoColor=white) ![GitHub Project](https://img.shields.io/badge/GitHub%20Project-121013?logo=github&logoColor=white)| +--- + +## 📚 팀 문서 +[Team Wiki](https://github.com/softeer5th/Team4-enDolphin/wiki)
--- @@ -36,7 +40,7 @@ #### 🕒 최적의 후보 일정 추천 조율할 날짜 범위와 일정이 진행될 시간대를 설정하면, 모든 참여자의 일정을 분석해 가장 적합한 후보 일정을 추천해 줘요. -| 일정 조율 초대 참여 | 일정 조율 생성 완료 | +| 일정 조율 초대 참여 | 일정 조율 | |-------------------------------------------|-------------------------------------------| | | | | 초대 링크로 일정 조율에 참여할 수 있어요.| 필터링을 통해 원하는 사람들끼리의 일정 조율 결과를 확인할 수 있어요. | @@ -67,9 +71,12 @@ Google 캘린더와 연동된 경우, 캘린더에도 자동으로 반영되어 --- - ## 🏡 아키텍쳐 - +### AWS + + +### 시스템 +
@@ -112,6 +119,11 @@ Google 캘린더와 연동된 경우, 캘린더에도 자동으로 반영되어 --- +## 📋️ ERD + + +--- + ## 🤝 협업 전략 ### 브랜치 구조 @@ -122,4 +134,5 @@ Google 캘린더와 연동된 경우, 캘린더에도 자동으로 반영되어 ### 코드리뷰 `Pn룰`을 도입하여 리뷰의 중요도를 리뷰이가 알 수 있도록 합니다. +
From dc27a578746d769743716a8cc1ba71b3c50d5723 Mon Sep 17 00:00:00 2001 From: Kim DongHyun <150654090+efdao@users.noreply.github.com> Date: Tue, 25 Feb 2025 15:32:37 +0900 Subject: [PATCH 03/66] =?UTF-8?q?[BE-Feat]=20=EB=85=BC=EC=9D=98=20?= =?UTF-8?q?=EC=B0=B8=EC=97=AC=20=EC=9D=B8=EC=A6=9D=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=8B=9C=20=EC=B2=98=EB=A6=AC=20=EC=88=98=EC=A0=95=20(#300)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/discussion/DiscussionService.java | 20 +++---- .../domain/discussion/dto/InvitationInfo.java | 4 +- .../global/error/exception/ErrorCode.java | 2 +- .../global/redis/PasswordCountService.java | 43 ++++++++++++-- .../global/security/JwtAuthFilter.java | 5 +- .../discussion/DiscussionServiceTest.java | 57 ++++++++++--------- .../redis/PasswordCountServiceTest.java | 27 +++++++-- 7 files changed, 102 insertions(+), 56 deletions(-) diff --git a/backend/src/main/java/endolphin/backend/domain/discussion/DiscussionService.java b/backend/src/main/java/endolphin/backend/domain/discussion/DiscussionService.java index b1e228d5..a2aa1767 100644 --- a/backend/src/main/java/endolphin/backend/domain/discussion/DiscussionService.java +++ b/backend/src/main/java/endolphin/backend/domain/discussion/DiscussionService.java @@ -189,6 +189,7 @@ public DiscussionInfo getDiscussionInfo(Long discussionId) { public InvitationInfo getInvitationInfo(Long discussionId) { Discussion discussion = getDiscussionById(discussionId); + User currentUser = userService.getCurrentUser(); return new InvitationInfo( discussionParticipantService.getHostNameByDiscussionId(discussionId), @@ -199,7 +200,8 @@ public InvitationInfo getInvitationInfo(Long discussionId) { discussion.getTimeRangeEnd(), discussion.getDuration(), discussionParticipantService.isFull(discussionId), - discussion.getPassword() != null + discussion.getPassword() != null, + passwordCountService.getExpirationTime(currentUser.getId(), discussionId) ); } @@ -302,8 +304,10 @@ public JoinDiscussionResponse joinDiscussion(Long discussionId, JoinDiscussionRe discussionParticipantService.checkAlreadyParticipated(discussionId, currentUser.getId()); - if (discussion.getPassword() != null && !checkPassword(discussion, request.password())) { - int failedCount = passwordCountService.increaseCount(currentUser.getId(), discussionId); + int failedCount = passwordCountService.tryEnter(currentUser.getId(), discussion, + request.password()); + + if (failedCount != 0) { return new JoinDiscussionResponse(false, failedCount); } @@ -311,7 +315,7 @@ public JoinDiscussionResponse joinDiscussion(Long discussionId, JoinDiscussionRe personalEventService.preprocessPersonalEvents(currentUser, discussion); - return new JoinDiscussionResponse(true, 0); + return new JoinDiscussionResponse(true, failedCount); } private List getSortedUserInfoWithPersonalEvents( @@ -336,12 +340,4 @@ private List getSortedUserInfoWithPersonalEvents( sortedResult.addAll(othersList); return sortedResult; } - - private boolean checkPassword(Discussion discussion, String password) { - if (password == null || password.isBlank()) { - throw new ApiException(ErrorCode.PASSWORD_REQUIRED); - } - - return passwordEncoder.matches(discussion.getId(), password, discussion.getPassword()); - } } diff --git a/backend/src/main/java/endolphin/backend/domain/discussion/dto/InvitationInfo.java b/backend/src/main/java/endolphin/backend/domain/discussion/dto/InvitationInfo.java index d78480ab..2c1c1f11 100644 --- a/backend/src/main/java/endolphin/backend/domain/discussion/dto/InvitationInfo.java +++ b/backend/src/main/java/endolphin/backend/domain/discussion/dto/InvitationInfo.java @@ -1,6 +1,7 @@ package endolphin.backend.domain.discussion.dto; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.LocalTime; public record InvitationInfo( @@ -12,7 +13,8 @@ public record InvitationInfo( LocalTime timeRangeEnd, Integer duration, Boolean isFull, - Boolean requirePassword + Boolean requirePassword, + LocalDateTime timeUnlocked ) { } diff --git a/backend/src/main/java/endolphin/backend/global/error/exception/ErrorCode.java b/backend/src/main/java/endolphin/backend/global/error/exception/ErrorCode.java index ef2a8054..ae8bcec0 100644 --- a/backend/src/main/java/endolphin/backend/global/error/exception/ErrorCode.java +++ b/backend/src/main/java/endolphin/backend/global/error/exception/ErrorCode.java @@ -16,7 +16,7 @@ public enum ErrorCode { // Discussion DISCUSSION_NOT_FOUND(HttpStatus.NOT_FOUND, "D001", "Discussion not found"), DISCUSSION_NOT_ONGOING(HttpStatus.BAD_REQUEST, "D003", "Discussion not ongoing"), - TOO_MANY_FAILED_ATTEMPTS(HttpStatus.FORBIDDEN, "D004", "Too many failed attempts"), + TOO_MANY_FAILED_ATTEMPTS(HttpStatus.TOO_MANY_REQUESTS, "D004", "Too many failed attempts"), PASSWORD_REQUIRED(HttpStatus.BAD_REQUEST, "D005", "Password required"), // PersonalEvent diff --git a/backend/src/main/java/endolphin/backend/global/redis/PasswordCountService.java b/backend/src/main/java/endolphin/backend/global/redis/PasswordCountService.java index a74fb4db..283d0671 100644 --- a/backend/src/main/java/endolphin/backend/global/redis/PasswordCountService.java +++ b/backend/src/main/java/endolphin/backend/global/redis/PasswordCountService.java @@ -1,7 +1,11 @@ package endolphin.backend.global.redis; +import endolphin.backend.domain.discussion.entity.Discussion; import endolphin.backend.global.error.exception.ApiException; import endolphin.backend.global.error.exception.ErrorCode; +import endolphin.backend.global.security.PasswordEncoder; +import endolphin.backend.global.util.TimeUtil; +import java.time.LocalDateTime; import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; @@ -12,20 +16,24 @@ public class PasswordCountService { private final StringRedisTemplate redisStringTemplate; + private final PasswordEncoder passwordEncoder; - private static final int MAX_FAILED_ATTEMPTS = 5; + public static final int MAX_FAILED_ATTEMPTS = 5; private static final long LOCKOUT_DURATION_MS = 5 * 60 * 1000; - public int increaseCount(Long userId, Long discussionId) { - String redisKey = "failedAttempts:" + discussionId + ":" + userId; + public int tryEnter(Long userId, Discussion discussion, String password) { + String redisKey = "failedAttempts:" + discussion.getId() + ":" + userId; - String countStr = redisStringTemplate.opsForValue().get(redisKey); - int failedAttemptsCount = countStr != null ? Integer.parseInt(countStr) : 0; + int failedAttemptsCount = getFailedCount(redisKey); if (failedAttemptsCount >= MAX_FAILED_ATTEMPTS) { throw new ApiException(ErrorCode.TOO_MANY_FAILED_ATTEMPTS); } + if (discussion.getPassword() == null || checkPassword(discussion, password)) { + return 0; + } + Long updatedCount = redisStringTemplate.opsForValue().increment(redisKey); if (updatedCount == null) { @@ -35,4 +43,29 @@ public int increaseCount(Long userId, Long discussionId) { return updatedCount.intValue(); } + + public LocalDateTime getExpirationTime(Long userId, Long discussionId) { + String redisKey = "failedAttempts:" + discussionId + ":" + userId; + + if (getFailedCount(redisKey) < MAX_FAILED_ATTEMPTS) { + return null; + } + + long remainingTime = redisStringTemplate.getExpire(redisKey, TimeUnit.SECONDS); + + return TimeUtil.getNow().plusSeconds(remainingTime); + } + + public int getFailedCount(String redisKey) { + String countStr = redisStringTemplate.opsForValue().get(redisKey); + return countStr != null ? Integer.parseInt(countStr) : 0; + } + + private boolean checkPassword(Discussion discussion, String password) { + if (password == null || password.isBlank()) { + throw new ApiException(ErrorCode.PASSWORD_REQUIRED); + } + + return passwordEncoder.matches(discussion.getId(), password, discussion.getPassword()); + } } diff --git a/backend/src/main/java/endolphin/backend/global/security/JwtAuthFilter.java b/backend/src/main/java/endolphin/backend/global/security/JwtAuthFilter.java index cb582eac..f892ca4b 100644 --- a/backend/src/main/java/endolphin/backend/global/security/JwtAuthFilter.java +++ b/backend/src/main/java/endolphin/backend/global/security/JwtAuthFilter.java @@ -69,10 +69,7 @@ protected boolean shouldNotFilter(HttpServletRequest request) { "/health" ); - Pattern invitePattern = Pattern.compile("^/api/v1/discussion/\\d+/invite$"); - return "OPTIONS".equalsIgnoreCase(request.getMethod()) || - excludedPaths.stream().anyMatch(path::startsWith) || - invitePattern.matcher(path).matches(); + excludedPaths.stream().anyMatch(path::startsWith); } } diff --git a/backend/src/test/java/endolphin/backend/domain/discussion/DiscussionServiceTest.java b/backend/src/test/java/endolphin/backend/domain/discussion/DiscussionServiceTest.java index 14476d98..b4cc503b 100644 --- a/backend/src/test/java/endolphin/backend/domain/discussion/DiscussionServiceTest.java +++ b/backend/src/test/java/endolphin/backend/domain/discussion/DiscussionServiceTest.java @@ -10,6 +10,7 @@ import static org.mockito.Mockito.atLeast; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -20,6 +21,7 @@ import endolphin.backend.domain.discussion.dto.DiscussionResponse; import endolphin.backend.domain.discussion.dto.InvitationInfo; import endolphin.backend.domain.discussion.dto.JoinDiscussionRequest; +import endolphin.backend.domain.discussion.dto.JoinDiscussionResponse; import endolphin.backend.domain.discussion.entity.Discussion; import endolphin.backend.domain.discussion.enums.DiscussionStatus; import endolphin.backend.domain.discussion.enums.MeetingMethod; @@ -410,6 +412,7 @@ public void getInvitationInfo_returnsExpectedInvitationInfo() { when(discussionParticipantService.getHostNameByDiscussionId(discussionId)) .thenReturn("HostName"); when(discussionParticipantService.isFull(discussionId)).thenReturn(true); + when(userService.getCurrentUser()).thenReturn(new User()); InvitationInfo invitationInfo = discussionService.getInvitationInfo(discussionId); @@ -576,6 +579,7 @@ public void testRetrieveCandidateEventDetails_error_userParticipant() { // 날짜 범위 검증 단계에서 예외가 발생하므로 다른 서비스들은 호출되지 않아야 함 then(personalEventService).shouldHaveNoInteractions(); } + @DisplayName("비밀번호가 일치할 때 논의 참여 성공") @Test public void joinDiscussion_withCorrectPassword_returnsTrue() { @@ -588,8 +592,7 @@ public void joinDiscussion_withCorrectPassword_returnsTrue() { .title("Test Discussion") .build(); ReflectionTestUtils.setField(discussion, "discussionStatus", DiscussionStatus.ONGOING); - ReflectionTestUtils.setField(discussion, "id", 1L); - + ReflectionTestUtils.setField(discussion, "id", discussionId); discussion.setPassword(encodedPassword); User currentUser = new User(); @@ -597,14 +600,11 @@ public void joinDiscussion_withCorrectPassword_returnsTrue() { when(discussionRepository.findById(discussionId)).thenReturn(Optional.of(discussion)); when(userService.getCurrentUser()).thenReturn(currentUser); - when(passwordEncoder.matches(discussionId, correctPassword, encodedPassword)).thenReturn( - true); + when(passwordCountService.tryEnter(currentUser.getId(), discussion, correctPassword)).thenReturn(0); - // When - boolean result = discussionService.joinDiscussion(discussionId, new JoinDiscussionRequest(correctPassword)).isSuccess(); + JoinDiscussionResponse response = discussionService.joinDiscussion(discussionId, new JoinDiscussionRequest(correctPassword)); - // Then - assertThat(result).isTrue(); + assertThat(response.isSuccess()).isTrue(); verify(discussionParticipantService).addDiscussionParticipant(discussion, currentUser); verify(personalEventService).preprocessPersonalEvents(currentUser, discussion); } @@ -621,29 +621,23 @@ public void joinDiscussion_withIncorrectPassword_returnsFalse() { .title("Test Discussion") .build(); ReflectionTestUtils.setField(discussion, "discussionStatus", DiscussionStatus.ONGOING); - + ReflectionTestUtils.setField(discussion, "id", discussionId); discussion.setPassword(encodedPassword); - ReflectionTestUtils.setField(discussion, "id", 1L); - User currentUser = new User(); ReflectionTestUtils.setField(currentUser, "id", 1L); when(discussionRepository.findById(discussionId)).thenReturn(Optional.of(discussion)); when(userService.getCurrentUser()).thenReturn(currentUser); - when(passwordEncoder.matches(discussionId, incorrectPassword, encodedPassword)).thenReturn( - false); + when(passwordCountService.tryEnter(currentUser.getId(), discussion, incorrectPassword)).thenReturn(1); // When - boolean result = discussionService.joinDiscussion(discussionId, new JoinDiscussionRequest(incorrectPassword)).isSuccess(); + JoinDiscussionResponse response = discussionService.joinDiscussion(discussionId, new JoinDiscussionRequest(incorrectPassword)); - // Then - assertThat(result).isFalse(); - verify(passwordCountService).increaseCount(currentUser.getId(), discussionId); - verify(discussionParticipantService, org.mockito.Mockito.never()).addDiscussionParticipant( - any(), any()); - verify(personalEventService, org.mockito.Mockito.never()).preprocessPersonalEvents(any(), - any()); + assertThat(response.isSuccess()).isFalse(); + verify(passwordCountService).tryEnter(currentUser.getId(), discussion, incorrectPassword); + verify(discussionParticipantService, never()).addDiscussionParticipant(any(), any()); + verify(personalEventService, never()).preprocessPersonalEvents(any(), any()); } @DisplayName("비밀번호가 없을 때 예외 발생") @@ -655,15 +649,22 @@ public void joinDiscussion_withNullPassword_throwsApiException() { .title("Test Discussion") .build(); ReflectionTestUtils.setField(discussion, "discussionStatus", DiscussionStatus.ONGOING); - discussion.setPassword("encodedPassword123"); + User currentUser = new User(); + ReflectionTestUtils.setField(currentUser, "id", 1L); + when(discussionRepository.findById(discussionId)).thenReturn(Optional.of(discussion)); - when(userService.getCurrentUser()).thenReturn(new User()); + when(userService.getCurrentUser()).thenReturn(currentUser); + + // 비밀번호가 null인 경우, passwordCountService.tryEnter가 예외를 던지도록 모킹 + when(passwordCountService.tryEnter(currentUser.getId(), discussion, null)) + .thenThrow(new ApiException(ErrorCode.PASSWORD_REQUIRED)); // When & Then - assertThatThrownBy(() -> discussionService.joinDiscussion(discussionId, new JoinDiscussionRequest(null))) - .isInstanceOf(ApiException.class) + assertThatThrownBy(() -> + discussionService.joinDiscussion(discussionId, new JoinDiscussionRequest(null)) + ).isInstanceOf(ApiException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PASSWORD_REQUIRED); } @@ -675,10 +676,10 @@ public void joinDiscussion_withInvalidDiscussionId_throwsApiException() { when(discussionRepository.findById(discussionId)).thenReturn(Optional.empty()); // When & Then - assertThatThrownBy(() -> discussionService.joinDiscussion(discussionId, new JoinDiscussionRequest("password123"))) - .isInstanceOf(ApiException.class) + assertThatThrownBy(() -> + discussionService.joinDiscussion(discussionId, new JoinDiscussionRequest("password123")) + ).isInstanceOf(ApiException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.DISCUSSION_NOT_FOUND); } - } diff --git a/backend/src/test/java/endolphin/backend/global/redis/PasswordCountServiceTest.java b/backend/src/test/java/endolphin/backend/global/redis/PasswordCountServiceTest.java index 078b34f9..56315097 100644 --- a/backend/src/test/java/endolphin/backend/global/redis/PasswordCountServiceTest.java +++ b/backend/src/test/java/endolphin/backend/global/redis/PasswordCountServiceTest.java @@ -5,15 +5,19 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import endolphin.backend.domain.discussion.entity.Discussion; import endolphin.backend.global.error.exception.ApiException; +import endolphin.backend.global.security.PasswordEncoder; import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.redis.core.ValueOperations; import org.springframework.data.redis.core.StringRedisTemplate; import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) public class PasswordCountServiceTest { @@ -21,6 +25,9 @@ public class PasswordCountServiceTest { @Mock private StringRedisTemplate redisStringTemplate; + @Mock + private PasswordEncoder passwordEncoder; + @Mock private ValueOperations valueOperations; @@ -30,7 +37,7 @@ public class PasswordCountServiceTest { public void setUp() { // StringRedisTemplate의 opsForValue() 호출 시 mock ValueOperations 반환 when(redisStringTemplate.opsForValue()).thenReturn(valueOperations); - passwordCountService = new PasswordCountService(redisStringTemplate); + passwordCountService = new PasswordCountService(redisStringTemplate, passwordEncoder); } @Test @@ -38,29 +45,36 @@ public void testIncreaseCount_Success_FirstFailure() { Long userId = 1L; Long discussionId = 1L; String redisKey = "failedAttempts:" + discussionId + ":" + userId; + Discussion discussion = new Discussion(); + ReflectionTestUtils.setField(discussion, "id", discussionId); + ReflectionTestUtils.setField(discussion, "password", "password2"); // 이전에 값이 없으면 null 반환 (즉, 첫 실패) when(valueOperations.get(redisKey)).thenReturn(null); // 증가 후 값으로 1 반환 when(valueOperations.increment(redisKey)).thenReturn(1L); - passwordCountService.increaseCount(userId, discussionId); + passwordCountService.tryEnter(userId, discussion, "password"); // 첫 실패시 expire가 설정되어야 함 (5분 = 5*60*1000 밀리초) verify(redisStringTemplate).expire(eq(redisKey), eq(5 * 60 * 1000L), eq(TimeUnit.MILLISECONDS)); } + @DisplayName("첫 실패 후 성공") @Test public void testIncreaseCount_Success_SubsequentFailure() { Long userId = 2L; Long discussionId = 10L; String redisKey = "failedAttempts:" + discussionId + ":" + userId; + Discussion discussion = new Discussion(); + ReflectionTestUtils.setField(discussion, "id", discussionId); + ReflectionTestUtils.setField(discussion, "password", "password"); when(valueOperations.get(redisKey)).thenReturn("2"); - when(valueOperations.increment(redisKey)).thenReturn(3L); + when(passwordEncoder.matches(discussionId, discussion.getPassword(), "password")).thenReturn(true); - passwordCountService.increaseCount(userId, discussionId); + passwordCountService.tryEnter(userId, discussion, "password"); } @Test @@ -69,10 +83,13 @@ public void testIncreaseCount_ExceedsMaxAttempts() { Long discussionId = 20L; String redisKey = "failedAttempts:" + discussionId + ":" + userId; + Discussion discussion = new Discussion(); + ReflectionTestUtils.setField(discussion, "id", discussionId); + when(valueOperations.get(redisKey)).thenReturn("5"); ApiException exception = assertThrows(ApiException.class, () -> - passwordCountService.increaseCount(userId, discussionId) + passwordCountService.tryEnter(userId, discussion, "password") ); } From fac4b2ce5c82534ef1984ebd6fc0a79c0575c33b Mon Sep 17 00:00:00 2001 From: kwon204 <32539398+kwon204@users.noreply.github.com> Date: Tue, 25 Feb 2025 15:44:43 +0900 Subject: [PATCH 04/66] =?UTF-8?q?[BE-Fix]=20=EA=B5=AC=EA=B8=80=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B0=98=EC=98=81=EB=90=9C=20=EC=9D=BC=EC=A0=95?= =?UTF-8?q?=EC=9D=B4=20=ED=95=AD=EC=83=81=20=EC=A1=B0=EC=A0=95=20=EB=B6=88?= =?UTF-8?q?=EA=B0=80=EB=8A=A5=EC=9C=BC=EB=A1=9C=20=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20(#303)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/personal_event/PersonalEventService.java | 5 +++-- .../domain/personal_event/dto/PersonalEventRequest.java | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/src/main/java/endolphin/backend/domain/personal_event/PersonalEventService.java b/backend/src/main/java/endolphin/backend/domain/personal_event/PersonalEventService.java index 55ee1001..4d990ddf 100644 --- a/backend/src/main/java/endolphin/backend/domain/personal_event/PersonalEventService.java +++ b/backend/src/main/java/endolphin/backend/domain/personal_event/PersonalEventService.java @@ -243,8 +243,9 @@ private void upsertPersonalEventByGoogleEvent(GoogleEvent googleEvent, .ifPresentOrElse(personalEvent -> { changedDates.add(personalEvent.getStartTime().toLocalDate()); changedDates.add(personalEvent.getEndTime().toLocalDate()); - updatePersonalEvent(PersonalEventRequest.of(googleEvent), personalEvent, user, - discussions); + updatePersonalEvent( + PersonalEventRequest.of(googleEvent, personalEvent.getIsAdjustable()), + personalEvent, user, discussions); }, () -> { PersonalEvent personalEvent = diff --git a/backend/src/main/java/endolphin/backend/domain/personal_event/dto/PersonalEventRequest.java b/backend/src/main/java/endolphin/backend/domain/personal_event/dto/PersonalEventRequest.java index b3a02d47..f69a3fa2 100644 --- a/backend/src/main/java/endolphin/backend/domain/personal_event/dto/PersonalEventRequest.java +++ b/backend/src/main/java/endolphin/backend/domain/personal_event/dto/PersonalEventRequest.java @@ -1,5 +1,6 @@ package endolphin.backend.domain.personal_event.dto; +import endolphin.backend.domain.personal_event.entity.PersonalEvent; import endolphin.backend.global.google.dto.GoogleEvent; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -14,12 +15,12 @@ public record PersonalEventRequest( Boolean syncWithGoogleCalendar ) { - public static PersonalEventRequest of(GoogleEvent googleEvent) { + public static PersonalEventRequest of(GoogleEvent googleEvent, boolean isAdjustable) { return new PersonalEventRequest( googleEvent.summary(), googleEvent.startDateTime(), googleEvent.endDateTime(), - false, + isAdjustable, false ); } From e234b4113c32253a0605a182535e78060121f54a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=ED=98=84=EC=98=81?= <89445100+hamo-o@users.noreply.github.com> Date: Tue, 25 Feb 2025 16:02:56 +0900 Subject: [PATCH 05/66] =?UTF-8?q?[FE-Feat]=20=EC=A7=84=ED=96=89=20?= =?UTF-8?q?=EC=A4=91=EC=9D=B8=20=EC=A1=B0=EC=9C=A8=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20=EB=B0=8F=20QA=EC=82=AC=ED=95=AD=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=20(#301)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../my-calendar/ui/CalendarCardList/index.tsx | 6 +-- .../ui/MyCalendar/CalendarDiscussionBox.tsx | 25 +++++------- .../ui/MyCalendar/CalendarTable.tsx | 4 +- .../my-calendar/ui/MyCalendar/index.css.ts | 7 +++- .../my-calendar/ui/MyCalendar/index.tsx | 14 ++++--- .../ui/MyCalendar/useScrollToCurrentTime.ts | 21 ---------- .../ui/OngoingDiscussion/OngoingCardList.tsx | 39 +++++++++++++------ frontend/src/hooks/useScrollToTime.ts | 32 +++++++++++++++ frontend/src/hooks/useSelectTime.ts | 9 ++++- frontend/src/pages/HomePage/index.tsx | 25 ++++++++---- .../src/pages/MyCalendarPage/TableContext.ts | 7 ++++ frontend/src/pages/MyCalendarPage/index.tsx | 8 +++- frontend/src/utils/date/date.ts | 8 ++++ frontend/src/utils/date/position.ts | 34 ++++++++++++++++ frontend/src/utils/error/HTTPError.ts | 2 + frontend/src/utils/error/handleError.ts | 8 ++-- 16 files changed, 174 insertions(+), 75 deletions(-) delete mode 100644 frontend/src/features/my-calendar/ui/MyCalendar/useScrollToCurrentTime.ts create mode 100644 frontend/src/hooks/useScrollToTime.ts create mode 100644 frontend/src/pages/MyCalendarPage/TableContext.ts diff --git a/frontend/src/features/my-calendar/ui/CalendarCardList/index.tsx b/frontend/src/features/my-calendar/ui/CalendarCardList/index.tsx index 8fa16208..c769f7e8 100644 --- a/frontend/src/features/my-calendar/ui/CalendarCardList/index.tsx +++ b/frontend/src/features/my-calendar/ui/CalendarCardList/index.tsx @@ -48,20 +48,18 @@ export const CalendarCardList = ({ cards }: { cards: PersonalEventResponse[] }) if (sd !== ed) { return ( - <> +
- +
); } diff --git a/frontend/src/features/my-calendar/ui/MyCalendar/CalendarDiscussionBox.tsx b/frontend/src/features/my-calendar/ui/MyCalendar/CalendarDiscussionBox.tsx index 5c9e66b2..52756aa6 100644 --- a/frontend/src/features/my-calendar/ui/MyCalendar/CalendarDiscussionBox.tsx +++ b/frontend/src/features/my-calendar/ui/MyCalendar/CalendarDiscussionBox.tsx @@ -1,7 +1,6 @@ import { useSharedCalendarContext } from '@/components/Calendar/context/SharedCalendarContext'; import { useDiscussionContext } from '@/pages/MyCalendarPage/DiscussionContext'; -import { isNextWeek, setDateOnly } from '@/utils/date'; -import { calcPositionByDate } from '@/utils/date/position'; +import { calcSizeByDate } from '@/utils/date/position'; import { discussionBoxStyle } from './index.css'; @@ -9,27 +8,21 @@ export const CalendarDiscussionBox = () => { const { selectedDateRange } = useDiscussionContext(); const { selectedWeek } = useSharedCalendarContext(); if (!selectedDateRange) return null; - - // TODO: 테스트 코드 작성 + const { start, end } = selectedDateRange; - if (start > selectedWeek[6] || end < selectedWeek[0]) return null; - - const startDate = start > selectedWeek[0] ? start : setDateOnly(start, selectedWeek[0]); - const endDate = end < selectedWeek[6] ? end : setDateOnly(end, selectedWeek[6]); - - const { x: sx, y: sy } = calcPositionByDate(startDate); - const { x: ex, y: ey } = calcPositionByDate(endDate); - const dayDiff = isNextWeek(start, end) ? 7 - sx : ex - sx; - const height = ey - sy; + const sizePosition = calcSizeByDate({ start, end }, selectedWeek); + if (!sizePosition) return null; + const { x, y, width, height } = sizePosition; + return (
diff --git a/frontend/src/features/my-calendar/ui/MyCalendar/CalendarTable.tsx b/frontend/src/features/my-calendar/ui/MyCalendar/CalendarTable.tsx index 23985154..8850296f 100644 --- a/frontend/src/features/my-calendar/ui/MyCalendar/CalendarTable.tsx +++ b/frontend/src/features/my-calendar/ui/MyCalendar/CalendarTable.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { Calendar } from '@/components/Calendar'; import { useSelectTime } from '@/hooks/useSelectTime'; +import { useTableContext } from '@/pages/MyCalendarPage/TableContext'; import { formatDateToDateTimeString } from '@/utils/date/format'; import type { PersonalEventResponse } from '../../model'; @@ -10,14 +11,13 @@ import { SchedulePopover } from '../SchedulePopover'; import { CalendarDiscussionBox } from './CalendarDiscussionBox'; import { CalendarTimeBar } from './CalendarTimeBar'; import { containerStyle } from './index.css'; -import { useScrollToCurrentTime } from './useScrollToCurrentTime'; const CalendarTable = ( { personalEvents = [] }: { personalEvents?: PersonalEventResponse[] }, ) => { const { handleMouseUp, reset, ...time } = useSelectTime(); + const { tableRef, height } = useTableContext(); const [open, setOpen] = useState(false); - const { tableRef, height } = useScrollToCurrentTime(); const handleMouseUpAddSchedule = () => { if (!time.selectedStartTime && !time.selectedEndTime) return; diff --git a/frontend/src/features/my-calendar/ui/MyCalendar/index.css.ts b/frontend/src/features/my-calendar/ui/MyCalendar/index.css.ts index d53d8e53..29414fcd 100644 --- a/frontend/src/features/my-calendar/ui/MyCalendar/index.css.ts +++ b/frontend/src/features/my-calendar/ui/MyCalendar/index.css.ts @@ -7,6 +7,11 @@ import { vars } from '@/theme/index.css'; export const containerStyle = recipe({ base: { position: 'relative', + + scrollbarWidth: 'none', + '::-webkit-scrollbar': { + display: 'none', + }, }, variants: { open: { @@ -14,7 +19,7 @@ export const containerStyle = recipe({ overflow: 'hidden', }, false: { - overflow: 'scroll', + overflowY: 'scroll', }, }, }, diff --git a/frontend/src/features/my-calendar/ui/MyCalendar/index.tsx b/frontend/src/features/my-calendar/ui/MyCalendar/index.tsx index e25265ec..40f236a8 100644 --- a/frontend/src/features/my-calendar/ui/MyCalendar/index.tsx +++ b/frontend/src/features/my-calendar/ui/MyCalendar/index.tsx @@ -2,7 +2,7 @@ import { Calendar } from '@/components/Calendar'; import { useSharedCalendarContext } from '@/components/Calendar/context/SharedCalendarContext'; import { formatDateToWeekRange, isAllday } from '@/utils/date'; import { formatDateToBarString } from '@/utils/date/format'; -import { calcPositionByDate } from '@/utils/date/position'; +import { calcSizeByDate } from '@/utils/date/position'; import { usePersonalEventsQuery } from '../../api/queries'; import type { PersonalEventResponse } from '../../model'; @@ -11,10 +11,14 @@ import CalendarTable from './CalendarTable'; import { calendarStyle } from './index.css'; const AlldayCard = (card: PersonalEventResponse) => { + const { selectedWeek } = useSharedCalendarContext(); const start = new Date(card.startDateTime); const end = new Date(card.endDateTime); - const dayDiff = end.getDay() - start.getDay() + 1; - const { x: sx } = calcPositionByDate(start); + + const sizePosition = calcSizeByDate({ start, end }, selectedWeek); + + if (!sizePosition) return null; + const { x, width } = sizePosition; return ( { startTime={start} status={card.isAdjustable ? 'adjustable' : 'fixed'} style={{ - width: `calc((100% - 72px - 1.25rem) / 7 * ${dayDiff})`, + width: `calc((100% - 72px - 1.25rem) / 7 * ${width})`, height: 57, position: 'absolute', - left: `calc(((100% - 72px - 1.25rem) / 7 * ${sx}) + 72px)`, + left: `calc(((100% - 72px - 1.25rem) / 7 * ${x}) + 72px)`, top: 136, zIndex: 2, }} diff --git a/frontend/src/features/my-calendar/ui/MyCalendar/useScrollToCurrentTime.ts b/frontend/src/features/my-calendar/ui/MyCalendar/useScrollToCurrentTime.ts deleted file mode 100644 index 7d446b04..00000000 --- a/frontend/src/features/my-calendar/ui/MyCalendar/useScrollToCurrentTime.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useEffect, useRef } from 'react'; - -import { TIME_HEIGHT } from '@/constants/date'; -import { getTimeParts } from '@/utils/date'; - -export const useScrollToCurrentTime = () => { - const tableRef = useRef(null); - const { hour, minute } = getTimeParts(new Date()); - const offset = 6.5 + (hour + minute / 60) * TIME_HEIGHT; - - useEffect(() => { - if (tableRef.current) { - tableRef.current.scrollTo({ - top: offset - 5 * TIME_HEIGHT, - behavior: 'smooth', - }); - } - }, [offset]); - - return { tableRef, height: offset }; -}; diff --git a/frontend/src/features/my-calendar/ui/OngoingDiscussion/OngoingCardList.tsx b/frontend/src/features/my-calendar/ui/OngoingDiscussion/OngoingCardList.tsx index 5fa44658..e28e71ff 100644 --- a/frontend/src/features/my-calendar/ui/OngoingDiscussion/OngoingCardList.tsx +++ b/frontend/src/features/my-calendar/ui/OngoingDiscussion/OngoingCardList.tsx @@ -1,14 +1,30 @@ +import { useSharedCalendarContext } from '@/components/Calendar/context/SharedCalendarContext'; import { useOngoingQuery } from '@/features/shared-schedule/api/queries'; import type { OngoingSchedule } from '@/features/shared-schedule/model'; import { useClickOutside } from '@/hooks/useClickOutside'; import { useDiscussionContext } from '@/pages/MyCalendarPage/DiscussionContext'; -import { parseTime } from '@/utils/date'; +import { useTableContext } from '@/pages/MyCalendarPage/TableContext'; +import { parseTime, setTimeOnly } from '@/utils/date'; import { OngoingCardItem } from './OngoingCardItem'; +const formatDateTimeRange = ( + { dateRangeStart, dateRangeEnd, timeRangeStart, timeRangeEnd }: OngoingSchedule, +) => { + const start = new Date(dateRangeStart); + const end = new Date(dateRangeEnd); + const { hour: sh, minute: sm } = parseTime(timeRangeStart); + const { hour: eh, minute: em } = parseTime(timeRangeEnd); + + return { start, end, sh, sm, eh, em }; +}; + export const OngoingCardList = () => { - const { data, isPending } = useOngoingQuery(1, 3, 'ALL'); + const { data, isPending } = useOngoingQuery(1, 2, 'ALL'); + const { selectedWeek } = useSharedCalendarContext(); const { selectedId, setSelectedId, handleSelectDateRange, reset } = useDiscussionContext(); + const { handleSelectDate } = useSharedCalendarContext(); + const { handleSelectTime } = useTableContext(); const handleClickSelect = (discussion: OngoingSchedule | null) => { if (!discussion) { @@ -16,17 +32,16 @@ export const OngoingCardList = () => { reset(); return; } - const start = new Date(discussion.dateRangeStart); - const end = new Date(discussion.dateRangeEnd); - const { hour: sh, minute: sm } = parseTime(discussion.timeRangeStart); - const { hour: eh, minute: em } = parseTime(discussion.timeRangeEnd); - start.setHours(sh); - start.setMinutes(sm); - end.setHours(eh); - end.setMinutes(em); - + + const { start, end, sh, sm, eh, em } = formatDateTimeRange(discussion); setSelectedId(discussion.discussionId); - handleSelectDateRange(start, end); + handleSelectDateRange( + setTimeOnly(start, { hour: sh, minute: sm }), + setTimeOnly(end, { hour: eh, minute: em }), + ); + handleSelectTime({ hour: sh, minute: sm }); + + if (selectedWeek[6] < start || selectedWeek[0] > end) handleSelectDate(start); }; const cardRef = useClickOutside(()=>handleClickSelect(null)); diff --git a/frontend/src/hooks/useScrollToTime.ts b/frontend/src/hooks/useScrollToTime.ts new file mode 100644 index 00000000..b9942516 --- /dev/null +++ b/frontend/src/hooks/useScrollToTime.ts @@ -0,0 +1,32 @@ +import type { RefObject } from 'react'; +import { useEffect, useRef, useState } from 'react'; + +import { TIME_HEIGHT } from '@/constants/date'; +import { getTimeParts, type Time } from '@/utils/date'; + +export interface ScrollTableProps { + tableRef: RefObject; + height: number; + handleSelectTime: (time: Time) => void; +} + +export const useScrollToTime = (): ScrollTableProps => { + const tableRef = useRef(null); + const [time, setTime] = useState