Repository: woowacourse-teams/2022-dallog
Branch: develop
Commit: 111ee13e44c3
Files: 426
Total size: 811.5 KB
Directory structure:
gitextract_eag9zh7a/
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug-template.md
│ │ └── feature-template.md
│ ├── PULL_REQUEST_TEMPLATE.md
│ └── workflows/
│ ├── backend-cd.yml
│ ├── backend-ci.yml
│ └── frontend-ci.yml
├── .gitignore
├── .gitmodules
├── LICENSE
├── README.md
├── backend/
│ ├── .gitignore
│ ├── build.gradle
│ ├── gradle/
│ │ └── wrapper/
│ │ ├── gradle-wrapper.jar
│ │ └── gradle-wrapper.properties
│ ├── gradlew
│ ├── gradlew.bat
│ └── src/
│ ├── docs/
│ │ └── asciidoc/
│ │ ├── auth.adoc
│ │ ├── category.adoc
│ │ ├── external-calendar.adoc
│ │ ├── index.adoc
│ │ ├── member.adoc
│ │ ├── schedule.adoc
│ │ └── subscription.adoc
│ ├── main/
│ │ ├── java/
│ │ │ └── com/
│ │ │ └── allog/
│ │ │ └── dallog/
│ │ │ ├── DallogApplication.java
│ │ │ ├── auth/
│ │ │ │ ├── application/
│ │ │ │ │ ├── AuthService.java
│ │ │ │ │ ├── AuthTokenCreator.java
│ │ │ │ │ ├── JwtTokenProvider.java
│ │ │ │ │ ├── OAuthClient.java
│ │ │ │ │ ├── OAuthUri.java
│ │ │ │ │ ├── TokenCreator.java
│ │ │ │ │ └── TokenProvider.java
│ │ │ │ ├── domain/
│ │ │ │ │ ├── AuthToken.java
│ │ │ │ │ ├── InMemoryAuthTokenRepository.java
│ │ │ │ │ ├── OAuthToken.java
│ │ │ │ │ ├── OAuthTokenRepository.java
│ │ │ │ │ └── TokenRepository.java
│ │ │ │ ├── dto/
│ │ │ │ │ ├── LoginMember.java
│ │ │ │ │ ├── OAuthMember.java
│ │ │ │ │ ├── request/
│ │ │ │ │ │ ├── TokenRenewalRequest.java
│ │ │ │ │ │ └── TokenRequest.java
│ │ │ │ │ └── response/
│ │ │ │ │ ├── AccessAndRefreshTokenResponse.java
│ │ │ │ │ ├── AccessTokenResponse.java
│ │ │ │ │ ├── OAuthAccessTokenResponse.java
│ │ │ │ │ └── OAuthUriResponse.java
│ │ │ │ ├── event/
│ │ │ │ │ └── MemberSavedEvent.java
│ │ │ │ ├── exception/
│ │ │ │ │ ├── EmptyAuthorizationHeaderException.java
│ │ │ │ │ ├── InvalidTokenException.java
│ │ │ │ │ ├── NoPermissionException.java
│ │ │ │ │ ├── NoSuchOAuthTokenException.java
│ │ │ │ │ └── NoSuchTokenException.java
│ │ │ │ └── presentation/
│ │ │ │ ├── AuthController.java
│ │ │ │ ├── AuthenticationPrincipal.java
│ │ │ │ ├── AuthenticationPrincipalArgumentResolver.java
│ │ │ │ └── AuthorizationExtractor.java
│ │ │ ├── category/
│ │ │ │ ├── application/
│ │ │ │ │ ├── CategoryService.java
│ │ │ │ │ └── ExternalCategoryDetailService.java
│ │ │ │ ├── domain/
│ │ │ │ │ ├── Category.java
│ │ │ │ │ ├── CategoryRepository.java
│ │ │ │ │ ├── CategoryType.java
│ │ │ │ │ ├── ExternalCategoryDetail.java
│ │ │ │ │ └── ExternalCategoryDetailRepository.java
│ │ │ │ ├── dto/
│ │ │ │ │ ├── request/
│ │ │ │ │ │ ├── CategoryCreateRequest.java
│ │ │ │ │ │ ├── CategoryUpdateRequest.java
│ │ │ │ │ │ └── ExternalCategoryCreateRequest.java
│ │ │ │ │ └── response/
│ │ │ │ │ ├── CategoriesResponse.java
│ │ │ │ │ ├── CategoryDetailResponse.java
│ │ │ │ │ └── CategoryResponse.java
│ │ │ │ ├── exception/
│ │ │ │ │ ├── ExistExternalCategoryException.java
│ │ │ │ │ ├── InvalidCategoryException.java
│ │ │ │ │ ├── NoSuchCategoryException.java
│ │ │ │ │ └── NoSuchExternalCategoryDetailException.java
│ │ │ │ └── presentaion/
│ │ │ │ └── CategoryController.java
│ │ │ ├── categoryrole/
│ │ │ │ ├── application/
│ │ │ │ │ └── CategoryRoleService.java
│ │ │ │ ├── domain/
│ │ │ │ │ ├── CategoryAuthority.java
│ │ │ │ │ ├── CategoryRole.java
│ │ │ │ │ ├── CategoryRoleRepository.java
│ │ │ │ │ └── CategoryRoleType.java
│ │ │ │ ├── dto/
│ │ │ │ │ ├── request/
│ │ │ │ │ │ └── CategoryRoleUpdateRequest.java
│ │ │ │ │ └── response/
│ │ │ │ │ ├── MemberWithRoleTypeResponse.java
│ │ │ │ │ └── SubscribersResponse.java
│ │ │ │ └── exception/
│ │ │ │ ├── ManagingCategoryLimitExcessException.java
│ │ │ │ ├── NoCategoryAuthorityException.java
│ │ │ │ ├── NoSuchCategoryRoleException.java
│ │ │ │ └── NotAbleToChangeRoleException.java
│ │ │ ├── externalcalendar/
│ │ │ │ ├── application/
│ │ │ │ │ ├── ExternalCalendarClient.java
│ │ │ │ │ └── ExternalCalendarService.java
│ │ │ │ ├── dto/
│ │ │ │ │ ├── ExternalCalendar.java
│ │ │ │ │ └── ExternalCalendarsResponse.java
│ │ │ │ └── presentation/
│ │ │ │ └── ExternalCalendarController.java
│ │ │ ├── global/
│ │ │ │ ├── config/
│ │ │ │ │ ├── JpaConfig.java
│ │ │ │ │ ├── PropertiesConfig.java
│ │ │ │ │ ├── WebConfig.java
│ │ │ │ │ ├── cache/
│ │ │ │ │ │ ├── CacheConfig.java
│ │ │ │ │ │ └── ExpiringConcurrentMapCache.java
│ │ │ │ │ ├── properties/
│ │ │ │ │ │ └── GoogleProperties.java
│ │ │ │ │ └── replication/
│ │ │ │ │ ├── DataSourceConfiguration.java
│ │ │ │ │ ├── DataSourceKey.java
│ │ │ │ │ ├── RandomReplicaKeys.java
│ │ │ │ │ └── RoutingDataSource.java
│ │ │ │ ├── entity/
│ │ │ │ │ └── BaseEntity.java
│ │ │ │ └── error/
│ │ │ │ ├── ControllerAdvice.java
│ │ │ │ └── dto/
│ │ │ │ ├── ErrorReportRequest.java
│ │ │ │ └── ErrorResponse.java
│ │ │ ├── infrastructure/
│ │ │ │ ├── log/
│ │ │ │ │ ├── DiscordAppender.java
│ │ │ │ │ └── dto/
│ │ │ │ │ ├── DiscordWebhookRequest.java
│ │ │ │ │ ├── Embed.java
│ │ │ │ │ └── Field.java
│ │ │ │ └── oauth/
│ │ │ │ ├── client/
│ │ │ │ │ ├── GoogleExternalCalendarClient.java
│ │ │ │ │ └── GoogleOAuthClient.java
│ │ │ │ ├── dto/
│ │ │ │ │ ├── GoogleCalendarEventResponse.java
│ │ │ │ │ ├── GoogleCalendarEventsResponse.java
│ │ │ │ │ ├── GoogleCalendarListResponse.java
│ │ │ │ │ ├── GoogleCalendarResponse.java
│ │ │ │ │ ├── GoogleDateFormat.java
│ │ │ │ │ ├── GoogleTokenResponse.java
│ │ │ │ │ └── UserInfo.java
│ │ │ │ ├── exception/
│ │ │ │ │ └── OAuthException.java
│ │ │ │ └── uri/
│ │ │ │ ├── DevGoogleOAuthUri.java
│ │ │ │ └── GoogleOAuthUri.java
│ │ │ ├── member/
│ │ │ │ ├── application/
│ │ │ │ │ └── MemberService.java
│ │ │ │ ├── domain/
│ │ │ │ │ ├── Member.java
│ │ │ │ │ ├── MemberRepository.java
│ │ │ │ │ └── SocialType.java
│ │ │ │ ├── dto/
│ │ │ │ │ ├── request/
│ │ │ │ │ │ └── MemberUpdateRequest.java
│ │ │ │ │ └── response/
│ │ │ │ │ └── MemberResponse.java
│ │ │ │ ├── exception/
│ │ │ │ │ ├── InvalidMemberException.java
│ │ │ │ │ └── NoSuchMemberException.java
│ │ │ │ └── presentation/
│ │ │ │ └── MemberController.java
│ │ │ ├── schedule/
│ │ │ │ ├── application/
│ │ │ │ │ ├── CheckedSchedulesFinder.java
│ │ │ │ │ └── ScheduleService.java
│ │ │ │ ├── domain/
│ │ │ │ │ ├── IntegrationSchedule.java
│ │ │ │ │ ├── IntegrationScheduleComparator.java
│ │ │ │ │ ├── IntegrationSchedules.java
│ │ │ │ │ ├── Period.java
│ │ │ │ │ ├── Schedule.java
│ │ │ │ │ ├── ScheduleRepository.java
│ │ │ │ │ ├── ScheduleType.java
│ │ │ │ │ ├── TypedSchedules.java
│ │ │ │ │ └── scheduler/
│ │ │ │ │ └── Scheduler.java
│ │ │ │ ├── dto/
│ │ │ │ │ ├── MaterialToFindSchedules.java
│ │ │ │ │ ├── request/
│ │ │ │ │ │ ├── DateRangeRequest.java
│ │ │ │ │ │ ├── ScheduleCreateRequest.java
│ │ │ │ │ │ └── ScheduleUpdateRequest.java
│ │ │ │ │ └── response/
│ │ │ │ │ ├── IntegrationScheduleResponse.java
│ │ │ │ │ ├── IntegrationScheduleResponses.java
│ │ │ │ │ └── ScheduleResponse.java
│ │ │ │ ├── exception/
│ │ │ │ │ ├── InvalidScheduleException.java
│ │ │ │ │ └── NoSuchScheduleException.java
│ │ │ │ └── presentation/
│ │ │ │ └── ScheduleController.java
│ │ │ └── subscription/
│ │ │ ├── application/
│ │ │ │ ├── ColorPicker.java
│ │ │ │ ├── RandomColorPicker.java
│ │ │ │ └── SubscriptionService.java
│ │ │ ├── domain/
│ │ │ │ ├── Color.java
│ │ │ │ ├── Subscription.java
│ │ │ │ ├── SubscriptionRepository.java
│ │ │ │ └── Subscriptions.java
│ │ │ ├── dto/
│ │ │ │ ├── request/
│ │ │ │ │ └── SubscriptionUpdateRequest.java
│ │ │ │ └── response/
│ │ │ │ ├── SubscriptionResponse.java
│ │ │ │ └── SubscriptionsResponse.java
│ │ │ ├── exception/
│ │ │ │ ├── ExistSubscriptionException.java
│ │ │ │ ├── InvalidSubscriptionException.java
│ │ │ │ ├── NoSuchSubscriptionException.java
│ │ │ │ └── NotAbleToUnsubscribeException.java
│ │ │ └── presentation/
│ │ │ └── SubscriptionController.java
│ │ └── resources/
│ │ ├── application-test.yml
│ │ ├── db/
│ │ │ └── prod/
│ │ │ └── schema.sql
│ │ └── logback-spring.xml
│ └── test/
│ ├── java/
│ │ └── com/
│ │ └── allog/
│ │ └── dallog/
│ │ ├── acceptance/
│ │ │ ├── AcceptanceTest.java
│ │ │ ├── AuthAcceptanceTest.java
│ │ │ ├── CategoryAcceptanceTest.java
│ │ │ ├── ExternalCalendarAcceptanceTest.java
│ │ │ ├── MemberAcceptanceTest.java
│ │ │ ├── ScheduleAcceptanceTest.java
│ │ │ ├── SubscriptionAcceptanceTest.java
│ │ │ └── fixtures/
│ │ │ ├── AuthAcceptanceFixtures.java
│ │ │ ├── CategoryAcceptanceFixtures.java
│ │ │ ├── CommonAcceptanceFixtures.java
│ │ │ ├── MemberAcceptanceFixtures.java
│ │ │ ├── ScheduleAcceptanceFixtures.java
│ │ │ └── SubscriptionAcceptanceFixtures.java
│ │ ├── auth/
│ │ │ ├── application/
│ │ │ │ ├── AuthServiceTest.java
│ │ │ │ ├── AuthTokenCreatorTest.java
│ │ │ │ ├── JwtTokenProviderTest.java
│ │ │ │ └── StubTokenProvider.java
│ │ │ ├── domain/
│ │ │ │ ├── AuthTokenTest.java
│ │ │ │ ├── InMemoryAuthTokenRepositoryTest.java
│ │ │ │ ├── OAuthTokenRepositoryTest.java
│ │ │ │ └── OAuthTokenTest.java
│ │ │ └── presentation/
│ │ │ └── AuthControllerTest.java
│ │ ├── category/
│ │ │ ├── application/
│ │ │ │ ├── CategoryServiceTest.java
│ │ │ │ └── ExternalCategoryDetailServiceTest.java
│ │ │ ├── domain/
│ │ │ │ ├── CategoryRepositoryTest.java
│ │ │ │ ├── CategoryTest.java
│ │ │ │ ├── CategoryTypeTest.java
│ │ │ │ └── ExternalCategoryDetailRepositoryTest.java
│ │ │ └── presentation/
│ │ │ └── CategoryControllerTest.java
│ │ ├── categoryrole/
│ │ │ ├── application/
│ │ │ │ └── CategoryRoleServiceTest.java
│ │ │ └── domain/
│ │ │ ├── CategoryRoleRepositoryTest.java
│ │ │ ├── CategoryRoleTest.java
│ │ │ └── CategoryRoleTypeTest.java
│ │ ├── common/
│ │ │ ├── Constants.java
│ │ │ ├── DatabaseCleaner.java
│ │ │ ├── annotation/
│ │ │ │ ├── ControllerTest.java
│ │ │ │ ├── RepositoryTest.java
│ │ │ │ └── ServiceTest.java
│ │ │ ├── builder/
│ │ │ │ ├── BuilderSupporter.java
│ │ │ │ └── GivenBuilder.java
│ │ │ ├── config/
│ │ │ │ ├── ExternalApiConfig.java
│ │ │ │ └── TokenConfig.java
│ │ │ └── fixtures/
│ │ │ ├── AuthFixtures.java
│ │ │ ├── CategoryFixtures.java
│ │ │ ├── ExternalCalendarFixtures.java
│ │ │ ├── ExternalCategoryFixtures.java
│ │ │ ├── IntegrationScheduleFixtures.java
│ │ │ ├── MemberFixtures.java
│ │ │ ├── OAuthFixtures.java
│ │ │ ├── OAuthTokenFixtures.java
│ │ │ ├── ScheduleFixtures.java
│ │ │ └── SubscriptionFixtures.java
│ │ ├── externalcalendar/
│ │ │ ├── application/
│ │ │ │ └── ExternalCalendarServiceTest.java
│ │ │ └── presentation/
│ │ │ └── ExternalCalendarControllerTest.java
│ │ ├── infrastructure/
│ │ │ └── oauth/
│ │ │ ├── client/
│ │ │ │ ├── StubExternalCalendarClient.java
│ │ │ │ └── StubOAuthClient.java
│ │ │ └── uri/
│ │ │ └── StubOAuthUri.java
│ │ ├── member/
│ │ │ ├── application/
│ │ │ │ └── MemberServiceTest.java
│ │ │ ├── domain/
│ │ │ │ ├── MemberRepositoryTest.java
│ │ │ │ └── MemberTest.java
│ │ │ └── presentation/
│ │ │ └── MemberControllerTest.java
│ │ ├── schedule/
│ │ │ ├── application/
│ │ │ │ ├── CheckedSchedulesFinderTest.java
│ │ │ │ └── ScheduleServiceTest.java
│ │ │ ├── domain/
│ │ │ │ ├── IntegrationScheduleTest.java
│ │ │ │ ├── IntegrationSchedulesTest.java
│ │ │ │ ├── PeriodTest.java
│ │ │ │ ├── ScheduleRepositoryTest.java
│ │ │ │ ├── ScheduleTest.java
│ │ │ │ └── scheduler/
│ │ │ │ └── SchedulerTest.java
│ │ │ └── presentation/
│ │ │ └── ScheduleControllerTest.java
│ │ └── subscription/
│ │ ├── application/
│ │ │ └── SubscriptionServiceTest.java
│ │ ├── domain/
│ │ │ ├── ColorTest.java
│ │ │ ├── SubscriptionRepositoryTest.java
│ │ │ ├── SubscriptionTest.java
│ │ │ └── SubscriptionsTest.java
│ │ └── presentation/
│ │ └── SubscriptionControllerTest.java
│ └── resources/
│ └── application.yml
├── frontend/
│ ├── .eslintrc.json
│ ├── .gitignore
│ ├── .prettierrc.json
│ ├── .storybook/
│ │ ├── main.js
│ │ ├── preview-body.html
│ │ └── preview.js
│ ├── babel.config.js
│ ├── jest.config.js
│ ├── package.json
│ ├── setupFile.js
│ ├── src/
│ │ ├── @types/
│ │ │ ├── calendar.ts
│ │ │ ├── category.ts
│ │ │ ├── custom.d.ts
│ │ │ ├── emotion.d.ts
│ │ │ ├── googleCalendar.ts
│ │ │ ├── index.ts
│ │ │ ├── profile.ts
│ │ │ ├── schedule.ts
│ │ │ ├── subscription.ts
│ │ │ └── util.ts
│ │ ├── App.tsx
│ │ ├── api/
│ │ │ ├── category.ts
│ │ │ ├── googleCalendar.ts
│ │ │ ├── index.ts
│ │ │ ├── login.ts
│ │ │ ├── profile.ts
│ │ │ ├── schedule.ts
│ │ │ └── subscription.ts
│ │ ├── components/
│ │ │ ├── @common/
│ │ │ │ ├── Button/
│ │ │ │ │ ├── Button.stories.tsx
│ │ │ │ │ ├── Button.styles.ts
│ │ │ │ │ ├── Button.test.tsx
│ │ │ │ │ └── Button.tsx
│ │ │ │ ├── ErrorBoundary/
│ │ │ │ │ └── ErrorBoundary.tsx
│ │ │ │ ├── Fieldset/
│ │ │ │ │ ├── Fieldset.stories.tsx
│ │ │ │ │ ├── Fieldset.styles.ts
│ │ │ │ │ ├── Fieldset.test.tsx
│ │ │ │ │ └── Fieldset.tsx
│ │ │ │ ├── ModalPortal/
│ │ │ │ │ ├── ModalPortal.styles.ts
│ │ │ │ │ └── ModalPortal.tsx
│ │ │ │ ├── PageLayout/
│ │ │ │ │ ├── PageLayout.styles.ts
│ │ │ │ │ └── PageLayout.tsx
│ │ │ │ ├── Responsive/
│ │ │ │ │ ├── Responsive.styles.ts
│ │ │ │ │ └── Responsive.tsx
│ │ │ │ ├── Select/
│ │ │ │ │ ├── Select.styles.ts
│ │ │ │ │ └── Select.tsx
│ │ │ │ ├── Skeleton/
│ │ │ │ │ ├── Skeleton.stories.tsx
│ │ │ │ │ ├── Skeleton.styles.ts
│ │ │ │ │ └── Skeleton.tsx
│ │ │ │ └── Spinner/
│ │ │ │ ├── Spinner.stories.tsx
│ │ │ │ ├── Spinner.styles.ts
│ │ │ │ └── Spinner.tsx
│ │ │ ├── AdminCategoryManageModal/
│ │ │ │ ├── AdminCategoryManageModal.styles.ts
│ │ │ │ └── AdminCategoryManageModal.tsx
│ │ │ ├── AdminItem/
│ │ │ │ ├── AdminItem.styles.ts
│ │ │ │ └── AdminItem.tsx
│ │ │ ├── Calendar/
│ │ │ │ ├── Calendar.fallback.tsx
│ │ │ │ ├── Calendar.styles.ts
│ │ │ │ └── Calendar.tsx
│ │ │ ├── CategoryAddModal/
│ │ │ │ ├── CategoryAddModal.stories.tsx
│ │ │ │ ├── CategoryAddModal.styles.ts
│ │ │ │ └── CategoryAddModal.tsx
│ │ │ ├── CategoryControl/
│ │ │ │ ├── CategoryControl.tsx
│ │ │ │ └── CategoryCotrol.styles.ts
│ │ │ ├── CategoryList/
│ │ │ │ ├── CategoryList.fallback.stories.tsx
│ │ │ │ ├── CategoryList.fallback.tsx
│ │ │ │ ├── CategoryList.stories.tsx
│ │ │ │ ├── CategoryList.styles.ts
│ │ │ │ └── CategoryList.tsx
│ │ │ ├── CategoryModifyModal/
│ │ │ │ ├── CategoryModifyModal.styles.ts
│ │ │ │ └── CategoryModifyModal.tsx
│ │ │ ├── DateCell/
│ │ │ │ ├── DateCell.styles.ts
│ │ │ │ └── DateCell.tsx
│ │ │ ├── Footer/
│ │ │ │ ├── Footer.styles.ts
│ │ │ │ └── Footer.tsx
│ │ │ ├── GoogleCategoryManageModal/
│ │ │ │ ├── GoogleCategoryManageModal.styles.ts
│ │ │ │ └── GoogleCategoryManageModal.tsx
│ │ │ ├── GoogleImportModal/
│ │ │ │ ├── GoogleImportModal.styles.ts
│ │ │ │ └── GoogleImportModal.tsx
│ │ │ ├── MoreScheduleModal/
│ │ │ │ ├── MoreScheduleModal.styles.ts
│ │ │ │ └── MoreScheduleModal.tsx
│ │ │ ├── NavBar/
│ │ │ │ ├── NavBar.stories.tsx
│ │ │ │ ├── NavBar.styles.ts
│ │ │ │ └── NavBar.tsx
│ │ │ ├── Profile/
│ │ │ │ ├── Profile.fallback.stories.tsx
│ │ │ │ ├── Profile.fallback.tsx
│ │ │ │ ├── Profile.styles.ts
│ │ │ │ └── Profile.tsx
│ │ │ ├── ProtectRoute/
│ │ │ │ └── ProtectRoute.tsx
│ │ │ ├── Schedule/
│ │ │ │ ├── Schedule.styles.ts
│ │ │ │ └── Schedule.tsx
│ │ │ ├── ScheduleAddButton/
│ │ │ │ ├── ScheduleAddButton.stories.tsx
│ │ │ │ ├── ScheduleAddButton.styles.ts
│ │ │ │ └── ScheduleAddButton.tsx
│ │ │ ├── ScheduleAddModal/
│ │ │ │ ├── ScheduleAddModal.stories.tsx
│ │ │ │ ├── ScheduleAddModal.styles.ts
│ │ │ │ └── ScheduleAddModal.tsx
│ │ │ ├── ScheduleModal/
│ │ │ │ ├── ScheduleModal.styles.ts
│ │ │ │ └── ScheduleModal.tsx
│ │ │ ├── ScheduleModifyModal/
│ │ │ │ ├── ScheduleModifyModal.styles.ts
│ │ │ │ └── ScheduleModifyModal.tsx
│ │ │ ├── SideAdminList/
│ │ │ │ ├── SideAdminList.styles.ts
│ │ │ │ └── SideAdminList.tsx
│ │ │ ├── SideBar/
│ │ │ │ ├── SideBar.fallback.stories.tsx
│ │ │ │ ├── SideBar.fallback.tsx
│ │ │ │ ├── SideBar.styles.ts
│ │ │ │ └── SideBar.tsx
│ │ │ ├── SideBarButton/
│ │ │ │ ├── SideBarButton.styles.ts
│ │ │ │ └── SideBarButton.tsx
│ │ │ ├── SideGoogleList/
│ │ │ │ ├── SideGoogleList.styles.ts
│ │ │ │ └── SideGoogleList.tsx
│ │ │ ├── SideItem/
│ │ │ │ ├── SideItem.styles.ts
│ │ │ │ └── SideItem.tsx
│ │ │ ├── SideSubscribedList/
│ │ │ │ ├── SideSubscribedList.styles.ts
│ │ │ │ └── SideSubscribedList.tsx
│ │ │ ├── SnackBar/
│ │ │ │ ├── SnackBar.styles.ts
│ │ │ │ └── SnackBar.tsx
│ │ │ ├── SubscribedCategoryItem/
│ │ │ │ ├── SubscribedCategoryItem.styles.ts
│ │ │ │ └── SubscribedCategoryItem.tsx
│ │ │ ├── SubscriberItem/
│ │ │ │ ├── SubscriberItem.styles.ts
│ │ │ │ └── SubscriberItem.tsx
│ │ │ ├── SubscriptionModifyModal/
│ │ │ │ ├── SubscriptionModifyModal.styles.ts
│ │ │ │ └── SubscriptionModifyModal.tsx
│ │ │ ├── UnsubscribedCategoryItem/
│ │ │ │ ├── UnsubscribedCategoryItem.styles.ts
│ │ │ │ └── UnsubscribedCategoryItem.tsx
│ │ │ └── WithdrawalModal/
│ │ │ ├── WithdrawalModal.stories.tsx
│ │ │ ├── WithdrawalModal.styles.ts
│ │ │ └── WithdrawalModal.tsx
│ │ ├── constants/
│ │ │ ├── api.ts
│ │ │ ├── category.ts
│ │ │ ├── date.ts
│ │ │ ├── index.ts
│ │ │ ├── message.ts
│ │ │ ├── schedule.ts
│ │ │ ├── style.ts
│ │ │ └── validate.ts
│ │ ├── domains/
│ │ │ └── schedule.ts
│ │ ├── hooks/
│ │ │ ├── @queries/
│ │ │ │ ├── category.ts
│ │ │ │ ├── googleCalendar.ts
│ │ │ │ ├── login.ts
│ │ │ │ ├── profile.ts
│ │ │ │ ├── schedule.ts
│ │ │ │ └── subscription.ts
│ │ │ ├── useCalendar.ts
│ │ │ ├── useControlledInput.ts
│ │ │ ├── useHoverCategoryItem.ts
│ │ │ ├── useIntersect.ts
│ │ │ ├── useModalPosition.ts
│ │ │ ├── useRootFontSize.ts
│ │ │ ├── useSnackBar.ts
│ │ │ ├── useToggle.ts
│ │ │ ├── useUserValue.ts
│ │ │ ├── useValidateCategory.ts
│ │ │ └── useValidateSchedule.ts
│ │ ├── index.html
│ │ ├── index.tsx
│ │ ├── pages/
│ │ │ ├── AuthPage/
│ │ │ │ └── AuthPage.tsx
│ │ │ ├── CalendarPage/
│ │ │ │ ├── CalendarPage.styles.ts
│ │ │ │ └── CalendarPage.tsx
│ │ │ ├── CategoryPage/
│ │ │ │ ├── CategoryPage.styles.ts
│ │ │ │ └── CategoryPage.tsx
│ │ │ ├── ErrorPage/
│ │ │ │ ├── ErrorPage.styles.ts
│ │ │ │ └── ErrorPage.tsx
│ │ │ ├── NotFoundPage/
│ │ │ │ ├── NotFoundPage.styles.ts
│ │ │ │ └── NotFoundPage.tsx
│ │ │ ├── PrivacyPolicyPage/
│ │ │ │ ├── PrivacyPolicyPage.styles.ts
│ │ │ │ └── PrivacyPolicyPage.tsx
│ │ │ └── StartPage/
│ │ │ ├── StartPage.styles.ts
│ │ │ └── StartPage.tsx
│ │ ├── recoil/
│ │ │ ├── atoms/
│ │ │ │ └── index.ts
│ │ │ └── selectors/
│ │ │ └── index.ts
│ │ ├── styles/
│ │ │ ├── GlobalStyle.tsx
│ │ │ └── theme.ts
│ │ ├── utils/
│ │ │ ├── date.ts
│ │ │ ├── index.ts
│ │ │ └── storage.ts
│ │ └── validation/
│ │ └── index.ts
│ ├── tsconfig.json
│ └── webpack.config.js
└── jenkins/
├── backend-dev.jenkinsfile
├── backend-prod.jenkinsfile
├── frontend-dev.jenkinsfile
└── frontend-prod.jenkinsfile
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/ISSUE_TEMPLATE/bug-template.md
================================================
---
name: Bug Template
about: 버그를 이슈에 등록한다.
title: '이슈의 제목을 입력해주세요!'
labels: ''
assignees: ''
---
## 🤷 버그 내용
## ⚠ 버그 재현 방법
1.
2.
3.
## 📸 스크린샷
## 👄 참고 사항
================================================
FILE: .github/ISSUE_TEMPLATE/feature-template.md
================================================
---
name: Feature Template
about: 구현할 기능을 이슈에 등록한다.
title: '이슈의 제목을 입력해주세요!'
labels: ''
assignees: ''
---
## 🤷 구현할 기능
## 🔨 상세 작업 내용
- [ ] To-do 1
- [ ] To-do 2
- [ ] To-do 3
## 📄 참고 사항
## ⏰ 예상 소요 기간
================================================
FILE: .github/PULL_REQUEST_TEMPLATE.md
================================================
- [ ] 🔀 PR 제목의 형식을 잘 작성했나요? e.g. `[feat] PR을 등록한다.`
- [ ] 💯 테스트는 잘 통과했나요?
- [ ] 🏗️ 빌드는 성공했나요?
- [ ] 🧹 불필요한 코드는 제거했나요?
- [ ] 💭 이슈는 등록했나요?
- [ ] 🏷️ 라벨은 등록했나요?
- [ ] 💻 git rebase를 사용했나요?
- [ ] 🌈 알록달록한가요?
## 작업 내용
## 스크린샷
## 주의사항
Closes #{이슈 번호}
================================================
FILE: .github/workflows/backend-cd.yml
================================================
name: Backend CD
on:
push:
branches:
- main
- develop
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./backend
steps:
- uses: actions/checkout@v2
with:
token: ${{secrets.SUBMODULE_TOKEN}}
submodules: true
- name: Set up JDK 11
uses: actions/setup-java@v2
with:
java-version: "11"
distribution: "adopt"
- name: Build with Gradle For RestDocs
run: ./gradlew bootJar
- name: Build with Gradle
run: ./gradlew bootJar
- name: Deploy use SCP
uses: appleboy/scp-action@master
with:
host: ${{secrets.LINODE_HOST}}
username: ${{secrets.LINODE_USERNAME}}
password: ${{secrets.LINODE_PASSWORD}}
source: "./backend/build/libs/*.jar"
target: "/root/cd_application"
strip_components: 3
- name: Run Application use SSH
uses: appleboy/ssh-action@master
with:
host: ${{secrets.LINODE_HOST}}
username: ${{secrets.LINODE_USERNAME}}
password: ${{secrets.LINODE_PASSWORD}}
script_stop: true
script: sh /root/cd_application/run.sh
================================================
FILE: .github/workflows/backend-ci.yml
================================================
name: Backend CI
on:
pull_request:
branches:
- main
- develop
paths:
- backend/**
- .github/** # Github Actions 작업을 위한 포함
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./backend
steps:
- uses: actions/checkout@v3
- name: Set up JDK 11
uses: actions/setup-java@v3
with:
java-version: "11"
distribution: "temurin"
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build with Gradle
run: ./gradlew build
- name: Publish Unit Test Results
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
junit_files: ${{ github.workspace }}/backend/build/test-results/**/*.xml
================================================
FILE: .github/workflows/frontend-ci.yml
================================================
name: Frontend CI
on:
pull_request:
branches:
- main
- develop
paths:
- frontend/**
- .github/** # Github Actions 작업을 위한 포함
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./frontend
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '16'
cache: 'yarn'
cache-dependency-path: '**/yarn.lock'
- name: Install node packages
run: yarn --frozen-lockfile
- name: Check lint
run: yarn check-lint
- name: Check prettier
run: yarn check-prettier
- name: Build
run: yarn dev-build
- name: Component test
run: yarn test
================================================
FILE: .gitignore
================================================
.idea
.DS_Store
================================================
FILE: .gitmodules
================================================
[submodule "backend/src/main/resources/config"]
path = backend/src/main/resources/config
url = https://github.com/dallog/config.git
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2023 dallog
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
================================================
FILE: README.md
================================================

### 달력이 기록을 공유할 때, 달록 🌙
[

](https://dallog.me) [

](https://dallog.github.io) [

](https://github.com/woowacourse-teams/2022-dallog/releases/tag/v1.1.6)
[](https://dallog.me)
## 🌙 소개
달록은 우아한테크코스 공유 캘린더입니다. 우아한테크코스 공식 일정, 데일리 팀, 스터디 등 파편화된 여러 일정을 모아 달록에서 관리할 수 있습니다. 사용자는 관심있는 일정 카테고리를 구독하여 개인화된 캘린더를 사용할 수 있습니다.
**[달록을 더 자세히 알아보고 싶다면, 여기로!](https://sites.google.com/woowahan.com/woowacourse-demo-4th/%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8/%EB%8B%AC%EB%A1%9D)**
## 🖥 서비스 화면

## 🛠 Tech Stacks
### Front-end

### Back-end

## ⚙️ Infrastructure

## 🔀 CI/CD Pipeline

## 🌈 알록달록하게 일을 더 잘하는 9가지 방법

## 👥 Members
| Backend | Backend | Backend | Backend | Frontend | Frontend |
| :------------------------------------------: | :------------------------------------------------: | :----------------------------------------------: | :------------------------------------------: | :--------------------------------------------: | :-----------------------------------------: |
|  |  |  |  |  |  |
| [매트(최기현)](https://github.com/hyeonic) | [리버(구동희)](https://github.com/gudonghee2000) | [파랑(이하은)](https://github.com/summerlunaa) | [후디(조동현)](https://github.com/devHudi) | [티거(이다예)](https://github.com/daaaayeah) | [나인(장호영)](https://github.com/jhy979) |
================================================
FILE: backend/.gitignore
================================================
HELP.md
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/
### resources ###
/src/main/resources/static/docs
### logs ###
logs/
================================================
FILE: backend/build.gradle
================================================
plugins {
id 'org.springframework.boot' version '2.7.1'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'org.asciidoctor.jvm.convert' version '3.3.2'
id 'org.sonarqube' version '3.3'
id 'java'
id 'jacoco'
}
group = 'com.allog'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
ext {
snippetsDir = file('build/generated-snippets')
}
configurations {
asciidoctorExtensions
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-cache'
runtimeOnly 'mysql:mysql-connector-java'
runtimeOnly 'com.h2database:h2'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.rest-assured:rest-assured:4.4.0'
// JWT를 위한 의존성
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
// Restdocs를 위한 의존성
asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor'
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
// Sonarqube를 위한 의존성
implementation 'org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:3.3'
}
test {
outputs.dir snippetsDir
useJUnitPlatform()
finalizedBy 'jacocoTestReport'
}
jacoco {
toolVersion = "0.8.8"
}
jacocoTestReport {
reports {
xml.enabled true
csv.enabled true
html.enabled true
xml.destination file("${buildDir}/jacoco/index.xml")
csv.destination file("${buildDir}/jacoco/index.csv")
html.destination file("${buildDir}/jacoco/index.html")
}
afterEvaluate {
classDirectories.setFrom(
files(classDirectories.files.collect {
fileTree(dir: it, excludes: [
'**/*Application*',
'**/*Exception*',
'**/dto/**',
'**/infrastructure/**',
'**/global/config/**',
'**/BaseEntity*',
'**/ControllerAdvice*',
'**/AuthorizationExtractor*'
])
})
)
}
finalizedBy 'jacocoTestCoverageVerification'
}
jacocoTestCoverageVerification {
violationRules {
rule {
enabled = true
element = "CLASS"
// 모든 클래스 각각 라인 커버리지 75% 만족시 빌드 성공
limit {
counter = 'LINE'
value = 'COVEREDRATIO'
minimum = 0.75
}
excludes = [
'*.*Application',
'*.*Exception',
'*.dto.*',
'*.infrastructure.*',
'*.global.config.*',
'*.BaseEntity',
'*.ControllerAdvice',
'*.AuthorizationExtractor'
]
}
}
}
sonarqube {
properties {
property 'sonar.projectKey', 'dallog'
property "sonar.sources", "src"
property "sonar.language", "java"
property "sonar.sourceEncoding", "UTF-8"
property "sonar.profile", "Dallog Custom Java Ruleset"
property "sonar.java.binaries", "${buildDir}/classes"
property "sonar.test.inclusions", "**/*Test.java"
property 'sonar.exclusions', '**/jacoco/**'
property 'sonar.coverage.exclusions', '**/test/**/*, **/*Application*, **/global/config/**, **/dto/**, **/*Exception*, **/infrastructure/**, **/BaseEntity*, **/ControllerAdvice*, **/AuthorizationExtractor*'
property "sonar.coverage.jacoco.xmlReportPaths", "${buildDir}/jacoco/index.xml"
}
}
asciidoctor {
configurations 'asciidoctorExtensions'
baseDirFollowsSourceFile()
inputs.dir snippetsDir
dependsOn test
}
asciidoctor.doFirst {
delete file('src/main/resources/static/docs')
}
task copyDocument(type: Copy) {
dependsOn asciidoctor
from "${asciidoctor.outputDir}"
into file("src/main/resources/static/docs")
}
bootJar {
dependsOn copyDocument
}
================================================
FILE: backend/gradle/wrapper/gradle-wrapper.properties
================================================
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
================================================
FILE: backend/gradlew
================================================
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
APP_BASE_NAME=${0##*/}
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
================================================
FILE: backend/gradlew.bat
================================================
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
================================================
FILE: backend/src/docs/asciidoc/auth.adoc
================================================
== Auth(인증)
=== OAuth 로그인 링크 생성
==== HTTP Request
include::{snippets}/auth/generateLink/http-request.adoc[]
==== Path Parameters
include::{snippets}/auth/generateLink/path-parameters.adoc[]
==== Request Parameters
include::{snippets}/auth/generateLink/request-parameters.adoc[]
==== HTTP Response
include::{snippets}/auth/generateLink/http-response.adoc[]
==== Response Fields
include::{snippets}/auth/generateLink/response-fields.adoc[]
=== OAuth 로그인
==== HTTP Request
include::{snippets}/auth/generateTokens/http-request.adoc[]
==== Path Parameters
include::{snippets}/auth/generateTokens/path-parameters.adoc[]
==== Request Fields
include::{snippets}/auth/generateTokens/request-fields.adoc[]
==== HTTP Response
include::{snippets}/auth/generateTokens/http-response.adoc[]
==== Response Fields
include::{snippets}/auth/generateTokens/response-fields.adoc[]
=== OAuth 로그인 (Resource Server 에러)
==== HTTP Response
include::{snippets}/auth/generateTokens/failByResourceServerError/http-response.adoc[]
=== RefreshToken을 통한 자동 로그인
==== HTTP Request
include::{snippets}/auth/generateRenewalToken/http-request.adoc[]
==== Request Fields
include::{snippets}/auth/generateRenewalToken/request-fields.adoc[]
==== HTTP Response
include::{snippets}/auth/generateRenewalToken/http-response.adoc[]
==== Response Fields
include::{snippets}/auth/generateRenewalToken/response-fields.adoc[]
=== RefreshToken을 통한 자동 로그인 (Invalid Token 에러)
==== HTTP Response
include::{snippets}/auth/generateRenewalToken/invalidTokenError/http-response.adoc[]
================================================
FILE: backend/src/docs/asciidoc/category.adoc
================================================
== Category(카테고리)
=== 카테고리 생성
==== HTTP Request
include::{snippets}/category/save/http-request.adoc[]
==== Request Fields
include::{snippets}/category/save/request-fields.adoc[]
==== HTTP Response
include::{snippets}/category/save/http-response.adoc[]
==== Response Fields
include::{snippets}/category/save/response-fields.adoc[]
=== 카테고리 생성 (유효하지 않은 카테고리 이름)
==== HTTP Response
include::{snippets}/category/save/failByInvalidNameFormat/http-response.adoc[]
=== 전체 카테고리 조회
==== Request
include::{snippets}/category/findAllByName/allByNoName/http-request.adoc[]
==== HTTP Response
include::{snippets}/category/findAllByName/allByNoName/http-response.adoc[]
=== 전체 카테고리 목록 이름으로 필터링
==== HTTP Request
include::{snippets}/category/findAllByName/filterByName/http-request.adoc[]
==== Request Parameters
include::{snippets}/category/findAllByName/filterByName/request-parameters.adoc[]
==== HTTP Response
include::{snippets}/category/findAllByName/filterByName/http-response.adoc[]
=== 자신이 일정을 추가/수정/삭제할 수 있는 카테고리 목록 조회
==== HTTP Request
include::{snippets}/category/findScheduleEditableCategories/http-request.adoc[]
==== HTTP Response
include::{snippets}/category/findScheduleEditableCategories/http-response.adoc[]
=== 자신이 ADMIN으로 있는 카테고리 목록 조회
==== HTTP Request
include::{snippets}/category/findAdminCategories/http-request.adoc[]
==== HTTP Response
include::{snippets}/category/findAdminCategories/http-response.adoc[]
=== ID를 통한 카테고리 단건 조회
==== HTTP Request
include::{snippets}/category/findDetailCategoryById/http-request.adoc[]
==== Path Parameters
include::{snippets}/category/findDetailCategoryById/path-parameters.adoc[]
==== HTTP Response
include::{snippets}/category/findDetailCategoryById/http-response.adoc[]
=== ID를 통한 카테고리 단건 조회 (존재하지 않는 경우)
==== HTTP Response
include::{snippets}/category/findDetailCategoryById/failByNoCategory/http-response.adoc[]
=== 카테고리 수정
==== HTTP Request
include::{snippets}/category/update/http-request.adoc[]
==== Path Parameters
include::{snippets}/category/update/path-parameters.adoc[]
==== HTTP Response
include::{snippets}/category/update/http-response.adoc[]
=== 카테고리 수정 (존재하지 않는 경우)
==== HTTP Response
include::{snippets}/category/update/failByNoCategory/http-response.adoc[]
=== 카테고리 수정 (유효하지 않은 카테고리 이름)
==== HTTP Response
include::{snippets}/category/update/failByInvalidNameFormat/http-response.adoc[]
=== 카테고리 삭제
==== HTTP Request
include::{snippets}/category/delete/http-request.adoc[]
==== Path Parameters
include::{snippets}/category/delete/path-parameters.adoc[]
==== HTTP Response
include::{snippets}/category/delete/http-response.adoc[]
=== 카테고리 삭제 (존재하지 않는 경우)
==== HTTP Response
include::{snippets}/category/delete/failByNoCategory/http-response.adoc[]
=== 카테고리 역할 수정
역할이 ADMIN인 회원은 카테고리 구독자(일반 구독자, 관리자, 자기자신 모두 포함)의 역할을 수정할 수 있습니다.
==== HTTP Request
include::{snippets}/category/updateRole/http-request.adoc[]
==== Path Parameters
include::{snippets}/category/updateRole/path-parameters.adoc[]
==== HTTP Response
include::{snippets}/category/updateRole/http-response.adoc[]
=== 카테고리 역할 수정 (권한이 없는 경우)
역할이 ADMIN인 경우만 구독자의 역할을 수정할 수 있습니다.
==== HTTP Response
include::{snippets}/category/updateRole/failByNoPermission/http-response.adoc[]
=== 카테고리 역할 수정 (구독을 하지 않은 경우)
카테고리 역할 수정 대상의 회원이 해당 카테고리를 구독하지 않은 상태인경우 역할 또한 존재하지 않으므로 역할을 찾을 수 없습니다.
==== HTTP Response
include::{snippets}/category/updateRole/failByCategoryRoleNotFound/http-response.adoc[]
=== 카테고리 역할 수정 (자신의 역할 수정시, 자신이 유일한 ADMIN인 경우)
자기자신의 카테고리 역할 수정시 자신이 해당 카테고리의 유일한 ADMIN인 경우 역할을 변경할 수 없습니다.
==== HTTP Response
include::{snippets}/category/updateRole/failBySoleAdmin/http-response.adoc[]
=== 카테고리 구독자 목록 조회
==== Request
include::{snippets}/category/findSubscribers/http-request.adoc[]
==== Path Parameters
include::{snippets}/category/findSubscribers/path-parameters.adoc[]
==== HTTP Response
include::{snippets}/category/findSubscribers/http-response.adoc[]
=== 카테고리 구독자 목록 조회 (호출자가 ADMIN이 아닌 경우)
==== HTTP Response
include::{snippets}/category/findSubscribers/failByNoAuthority/http-response.adoc[]
================================================
FILE: backend/src/docs/asciidoc/external-calendar.adoc
================================================
== External Calendar (외부 캘린더)
=== 자신의 외부 캘린더 조회
==== HTTP Request
include::{snippets}/externalCalendar/get/http-request.adoc[]
==== HTTP Response
include::{snippets}/externalCalendar/get/http-response.adoc[]
=== 자신의 외부 캘린더 저장
==== HTTP Request
include::{snippets}/externalCalendar/save/http-request.adoc[]
==== Request Fields
include::{snippets}/externalCalendar/save/request-fields.adoc[]
==== HTTP Response
include::{snippets}/externalCalendar/save/http-response.adoc[]
=== 자신의 외부 캘린더 저장 (중복 저장할 경우)
==== HTTP Response
include::{snippets}/externalCalendar/duplicated-save/http-response.adoc[]
================================================
FILE: backend/src/docs/asciidoc/index.adoc
================================================
= 달록 API 문서
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 3
include::auth.adoc[]
include::member.adoc[]
include::category.adoc[]
include::schedule.adoc[]
include::subscription.adoc[]
include::external-calendar.adoc[]
================================================
FILE: backend/src/docs/asciidoc/member.adoc
================================================
== Member(회원)
=== 내 정보 조회
==== HTTP Request
include::{snippets}/member/findMe/http-request.adoc[]
==== HTTP Response
include::{snippets}/member/findMe/http-response.adoc[]
==== Response Fields
include::{snippets}/member/findMe/response-fields.adoc[]
=== 내 정보 조회 (존재하지 않는 회원 조회 시)
==== HTTP Response
include::{snippets}/member/findMe/failNoMember/http-response.adoc[]
=== 내 정보 수정
==== HTTP Request
include::{snippets}/member/update/http-request.adoc[]
==== Request Fields
include::{snippets}/member/update/request-fields.adoc[]
==== HTTP Response
include::{snippets}/member/update/http-response.adoc[]
================================================
FILE: backend/src/docs/asciidoc/schedule.adoc
================================================
== Schedule(일정)
=== 회원 일정 목록 조회
==== HTTP Request
include::{snippets}/schedule/findSchedulesByMemberId/http-request.adoc[]
==== Request Parameters
include::{snippets}/schedule/findSchedulesByMemberId/request-parameters.adoc[]
==== HTTP Response
include::{snippets}/schedule/findSchedulesByMemberId/http-response.adoc[]
=== 카테고리 별 일정 목록 조회
==== HTTP Request
include::{snippets}/schedule/findSchedulesByCategoryId/http-request.adoc[]
==== Request Parameters
include::{snippets}/schedule/findSchedulesByCategoryId/request-parameters.adoc[]
==== HTTP Response
include::{snippets}/schedule/findSchedulesByCategoryId/http-response.adoc[]
=== 일정 등록
==== HTTP Request
include::{snippets}/schedule/save/http-request.adoc[]
==== HTTP Response
include::{snippets}/schedule/save/http-response.adoc[]
=== 일정 등록 (카테고리 권한이 없을 때)
==== HTTP Response
include::{snippets}/schedule/save/failByNoPermission/http-response.adoc[]
=== 일정 생성 (카테고리가 존재하지 않음)
==== HTTP Response
include::{snippets}/schedule/save/failByNoCategory/http-response.adoc[]
=== 일정 단건 조회
==== HTTP Request
include::{snippets}/schedule/findById/http-request.adoc[]
==== HTTP Response
include::{snippets}/schedule/findById/http-response.adoc[]
=== 일정 단건 조회 (일정이 존재하지 않을 때)
==== HTTP Response
include::{snippets}/schedule/findById/failByNoSchedule/http-response.adoc[]
=== 일정 수정
==== HTTP Request
include::{snippets}/schedule/update/http-request.adoc[]
==== Path Parameters
include::{snippets}/schedule/update/path-parameters.adoc[]
==== HTTP Response
include::{snippets}/schedule/update/http-response.adoc[]
=== 일정 수정 (카테고리 권한이 없을 때)
==== HTTP Response
include::{snippets}/schedule/update/failByNoPermission/http-response.adoc[]
=== 일정 수정 (카테고리가 존재하지 않을 때)
==== HTTP Response
include::{snippets}/schedule/update/failByNoSchedule/http-response.adoc[]
=== 일정 제거
==== HTTP Request
include::{snippets}/schedule/delete/http-request.adoc[]
==== Path Parameters
include::{snippets}/schedule/delete/path-parameters.adoc[]
==== HTTP Response
include::{snippets}/schedule/delete/http-response.adoc[]
=== 일정 제거 (카테고리 권한이 없을 때)
==== HTTP Response
include::{snippets}/schedule/delete/failByNoPermission/http-response.adoc[]
=== 일정 제거 (카테고리가 존재하지 않음)
==== HTTP Response
include::{snippets}/schedule/delete/failByNoSchedule/http-response.adoc[]
================================================
FILE: backend/src/docs/asciidoc/subscription.adoc
================================================
== Subscription(구독)
=== 구독 등록
==== HTTP Request
include::{snippets}/subscription/save/http-request.adoc[]
==== Path Parameters
include::{snippets}/subscription/save/path-parameters.adoc[]
==== HTTP Response
include::{snippets}/subscription/save/http-response.adoc[]
=== 구독 등록 (중복된 구독을 등록할 때)
==== HTTP Response
include::{snippets}/subscription/save/failByAlreadyExisting/http-response.adoc[]
=== 구독 등록 (3자의 개인 카테고리 구독 요청시)
==== HTTP Response
include::{snippets}/subscription/save/failBySubscribingPrivateCategoryOfOther/http-response.adoc[]
=== 자신의 구독 목록 조회
==== HTTP Request
include::{snippets}/subscription/findMine/http-request.adoc[]
==== HTTP Response
include::{snippets}/subscription/findMine/http-response.adoc[]
=== 내 구독 정보 수정
==== HTTP Request
include::{snippets}/subscription/update/http-request.adoc[]
==== Path Parameters
include::{snippets}/subscription/update/path-parameters.adoc[]
==== Request Headers
include::{snippets}/subscription/update/request-headers.adoc[]
==== Request Parameters
include::{snippets}/subscription/update/request-body.adoc[]
==== HTTP Response
include::{snippets}/subscription/update/http-response.adoc[]
=== 구독 삭제
==== HTTP Request
include::{snippets}/subscription/delete/http-request.adoc[]
==== Path Parameters
include::{snippets}/subscription/delete/path-parameters.adoc[]
==== HTTP Response
include::{snippets}/subscription/delete/http-response.adoc[]
=== 구독 삭제 (내 구독이 아닐 때)
==== HTTP Response
include::{snippets}/subscription/delete/failByNoPermission/http-response.adoc[]
================================================
FILE: backend/src/main/java/com/allog/dallog/DallogApplication.java
================================================
package com.allog.dallog;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DallogApplication {
public static void main(String[] args) {
SpringApplication.run(DallogApplication.class, args);
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/auth/application/AuthService.java
================================================
package com.allog.dallog.auth.application;
import com.allog.dallog.auth.domain.AuthToken;
import com.allog.dallog.auth.domain.OAuthToken;
import com.allog.dallog.auth.domain.OAuthTokenRepository;
import com.allog.dallog.auth.dto.OAuthMember;
import com.allog.dallog.auth.dto.request.TokenRenewalRequest;
import com.allog.dallog.auth.dto.response.AccessAndRefreshTokenResponse;
import com.allog.dallog.auth.dto.response.AccessTokenResponse;
import com.allog.dallog.auth.event.MemberSavedEvent;
import com.allog.dallog.member.domain.Member;
import com.allog.dallog.member.domain.MemberRepository;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Transactional(readOnly = true)
@Service
public class AuthService {
private final MemberRepository memberRepository;
private final OAuthTokenRepository oAuthTokenRepository;
private final TokenCreator tokenCreator;
private final ApplicationEventPublisher eventPublisher;
public AuthService(final MemberRepository memberRepository, final OAuthTokenRepository oAuthTokenRepository,
final TokenCreator tokenCreator, final ApplicationEventPublisher eventPublisher) {
this.memberRepository = memberRepository;
this.oAuthTokenRepository = oAuthTokenRepository;
this.tokenCreator = tokenCreator;
this.eventPublisher = eventPublisher;
}
@Transactional
public AccessAndRefreshTokenResponse generateAccessAndRefreshToken(final OAuthMember oAuthMember) {
Member foundMember = findMember(oAuthMember);
OAuthToken oAuthToken = getOAuthToken(oAuthMember, foundMember);
oAuthToken.change(oAuthMember.getRefreshToken());
AuthToken authToken = tokenCreator.createAuthToken(foundMember.getId());
return new AccessAndRefreshTokenResponse(authToken.getAccessToken(), authToken.getRefreshToken());
}
private Member findMember(final OAuthMember oAuthMember) {
String email = oAuthMember.getEmail();
if (memberRepository.existsByEmail(email)) {
return memberRepository.getByEmail(email);
}
return saveMember(oAuthMember);
}
private Member saveMember(final OAuthMember oAuthMember) {
Member savedMember = memberRepository.save(oAuthMember.toMember());
eventPublisher.publishEvent(new MemberSavedEvent(savedMember.getId()));
return savedMember;
}
private OAuthToken getOAuthToken(final OAuthMember oAuthMember, final Member member) {
Long memberId = member.getId();
if (oAuthTokenRepository.existsByMemberId(memberId)) {
return oAuthTokenRepository.getByMemberId(memberId);
}
return oAuthTokenRepository.save(new OAuthToken(member, oAuthMember.getRefreshToken()));
}
public AccessTokenResponse generateAccessToken(final TokenRenewalRequest tokenRenewalRequest) {
String refreshToken = tokenRenewalRequest.getRefreshToken();
AuthToken authToken = tokenCreator.renewAuthToken(refreshToken);
return new AccessTokenResponse(authToken.getAccessToken());
}
public Long extractMemberId(final String accessToken) {
Long memberId = tokenCreator.extractPayload(accessToken);
memberRepository.validateExistsById(memberId);
return memberId;
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/auth/application/AuthTokenCreator.java
================================================
package com.allog.dallog.auth.application;
import com.allog.dallog.auth.domain.AuthToken;
import com.allog.dallog.auth.domain.TokenRepository;
import org.springframework.stereotype.Component;
@Component
public class AuthTokenCreator implements TokenCreator {
private final TokenProvider tokenProvider;
private final TokenRepository tokenRepository;
public AuthTokenCreator(final TokenProvider tokenProvider, final TokenRepository tokenRepository) {
this.tokenProvider = tokenProvider;
this.tokenRepository = tokenRepository;
}
public AuthToken createAuthToken(final Long memberId) {
String accessToken = tokenProvider.createAccessToken(String.valueOf(memberId));
String refreshToken = createRefreshToken(memberId);
return new AuthToken(accessToken, refreshToken);
}
private String createRefreshToken(final Long memberId) {
if (tokenRepository.exist(memberId)) {
return tokenRepository.getToken(memberId);
}
String refreshToken = tokenProvider.createRefreshToken(String.valueOf(memberId));
return tokenRepository.save(memberId, refreshToken);
}
public AuthToken renewAuthToken(final String refreshToken) {
tokenProvider.validateToken(refreshToken);
Long memberId = Long.valueOf(tokenProvider.getPayload(refreshToken));
String accessTokenForRenew = tokenProvider.createAccessToken(String.valueOf(memberId));
String refreshTokenForRenew = tokenRepository.getToken(memberId);
AuthToken renewalAuthToken = new AuthToken(accessTokenForRenew, refreshTokenForRenew);
renewalAuthToken.validateHasSameRefreshToken(refreshToken);
return renewalAuthToken;
}
public Long extractPayload(final String accessToken) {
tokenProvider.validateToken(accessToken);
return Long.valueOf(tokenProvider.getPayload(accessToken));
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/auth/application/JwtTokenProvider.java
================================================
package com.allog.dallog.auth.application;
import com.allog.dallog.auth.exception.InvalidTokenException;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import javax.crypto.SecretKey;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class JwtTokenProvider implements TokenProvider {
private final SecretKey key;
private final long accessTokenValidityInMilliseconds;
private final long refreshTokenValidityInMilliseconds;
public JwtTokenProvider(@Value("${security.jwt.token.secret-key}") final String secretKey,
@Value("${security.jwt.token.access.expire-length}") final long accessTokenValidityInMilliseconds,
@Value("${security.jwt.token.refresh.expire-length}") final long refreshTokenValidityInMilliseconds) {
this.key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
this.accessTokenValidityInMilliseconds = accessTokenValidityInMilliseconds;
this.refreshTokenValidityInMilliseconds = refreshTokenValidityInMilliseconds;
}
@Override
public String createAccessToken(final String payload) {
return createToken(payload, accessTokenValidityInMilliseconds);
}
@Override
public String createRefreshToken(final String payload) {
return createToken(payload, refreshTokenValidityInMilliseconds);
}
private String createToken(final String payload, final Long validityInMilliseconds) {
Date now = new Date();
Date validity = new Date(now.getTime() + validityInMilliseconds);
return Jwts.builder()
.setSubject(payload)
.setIssuedAt(now)
.setExpiration(validity)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
@Override
public String getPayload(final String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
}
@Override
public void validateToken(final String token) {
try {
Jws claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
claims.getBody()
.getExpiration()
.before(new Date());
} catch (final JwtException | IllegalArgumentException e) {
throw new InvalidTokenException("권한이 없습니다.");
}
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/auth/application/OAuthClient.java
================================================
package com.allog.dallog.auth.application;
import com.allog.dallog.auth.dto.OAuthMember;
import com.allog.dallog.auth.dto.response.OAuthAccessTokenResponse;
public interface OAuthClient {
OAuthMember getOAuthMember(final String code, final String redirectUri);
OAuthAccessTokenResponse getAccessToken(final String refreshToken);
}
================================================
FILE: backend/src/main/java/com/allog/dallog/auth/application/OAuthUri.java
================================================
package com.allog.dallog.auth.application;
@FunctionalInterface
public interface OAuthUri {
String generate(final String redirectUri);
}
================================================
FILE: backend/src/main/java/com/allog/dallog/auth/application/TokenCreator.java
================================================
package com.allog.dallog.auth.application;
import com.allog.dallog.auth.domain.AuthToken;
public interface TokenCreator {
AuthToken createAuthToken(final Long memberId);
AuthToken renewAuthToken(final String outRefreshToken);
Long extractPayload(final String accessToken);
}
================================================
FILE: backend/src/main/java/com/allog/dallog/auth/application/TokenProvider.java
================================================
package com.allog.dallog.auth.application;
public interface TokenProvider {
String createAccessToken(final String payload);
String createRefreshToken(final String payload);
String getPayload(final String token);
void validateToken(final String token);
}
================================================
FILE: backend/src/main/java/com/allog/dallog/auth/domain/AuthToken.java
================================================
package com.allog.dallog.auth.domain;
import com.allog.dallog.auth.exception.NoSuchTokenException;
public class AuthToken {
private String accessToken;
private String refreshToken;
public AuthToken(final String accessToken, final String refreshToken) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
}
public String getAccessToken() {
return accessToken;
}
public String getRefreshToken() {
return refreshToken;
}
public void validateHasSameRefreshToken(final String otherRefreshToken) {
if (!refreshToken.equals(otherRefreshToken)) {
throw new NoSuchTokenException("회원의 리프레시 토큰이 아닙니다.");
}
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/auth/domain/InMemoryAuthTokenRepository.java
================================================
package com.allog.dallog.auth.domain;
import com.allog.dallog.auth.exception.NoSuchTokenException;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Component;
@Component
public class InMemoryAuthTokenRepository implements TokenRepository {
private static final Map TOKEN_REPOSITORY = new ConcurrentHashMap<>();
@Override
public String save(final Long memberId, final String refreshToken) {
TOKEN_REPOSITORY.put(memberId, refreshToken);
return TOKEN_REPOSITORY.get(memberId);
}
@Override
public void deleteAll() {
TOKEN_REPOSITORY.clear();
}
@Override
public void deleteByMemberId(final Long memberId) {
TOKEN_REPOSITORY.remove(memberId);
}
@Override
public boolean exist(final Long memberId) {
return TOKEN_REPOSITORY.containsKey(memberId);
}
@Override
public String getToken(final Long memberId) {
Optional token = Optional.ofNullable(TOKEN_REPOSITORY.get(memberId));
return token.orElseThrow(() -> new NoSuchTokenException("일치하는 토큰이 존재하지 않습니다."));
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/auth/domain/OAuthToken.java
================================================
package com.allog.dallog.auth.domain;
import com.allog.dallog.global.entity.BaseEntity;
import com.allog.dallog.member.domain.Member;
import java.util.Objects;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.OneToOne;
import javax.persistence.Table;
@Table(name = "oauth_tokens")
@Entity
public class OAuthToken extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "members_id", nullable = false)
private Member member;
@Column(name = "refresh_token", nullable = false)
private String refreshToken;
protected OAuthToken() {
}
public OAuthToken(final Member member, final String refreshToken) {
this.member = member;
this.refreshToken = refreshToken;
}
public void change(final String refreshToken) {
if (!Objects.isNull(refreshToken)) {
this.refreshToken = refreshToken;
}
}
public Long getId() {
return id;
}
public Member getMember() {
return member;
}
public String getRefreshToken() {
return refreshToken;
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/auth/domain/OAuthTokenRepository.java
================================================
package com.allog.dallog.auth.domain;
import com.allog.dallog.auth.exception.NoSuchOAuthTokenException;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
public interface OAuthTokenRepository extends JpaRepository {
boolean existsByMemberId(final Long memberId);
@Query("SELECT o "
+ "FROM OAuthToken o "
+ "WHERE o.member.id = :memberId")
Optional findByMemberId(final Long memberId);
default OAuthToken getByMemberId(final Long memberId) {
return findByMemberId(memberId)
.orElseThrow(NoSuchOAuthTokenException::new);
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/auth/domain/TokenRepository.java
================================================
package com.allog.dallog.auth.domain;
public interface TokenRepository {
String save(final Long memberId, final String refreshToken);
void deleteAll();
void deleteByMemberId(final Long memberId);
boolean exist(final Long memberId);
String getToken(final Long memberId);
}
================================================
FILE: backend/src/main/java/com/allog/dallog/auth/dto/LoginMember.java
================================================
package com.allog.dallog.auth.dto;
public class LoginMember {
private Long id;
private LoginMember() {
}
public LoginMember(final Long id) {
this.id = id;
}
public Long getId() {
return id;
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/auth/dto/OAuthMember.java
================================================
package com.allog.dallog.auth.dto;
import com.allog.dallog.member.domain.Member;
import com.allog.dallog.member.domain.SocialType;
public class OAuthMember {
private final String email;
private final String displayName;
private final String profileImageUrl;
private final String refreshToken;
public OAuthMember(final String email, final String displayName, final String profileImageUrl,
final String refreshToken) {
this.email = email;
this.displayName = displayName;
this.profileImageUrl = profileImageUrl;
this.refreshToken = refreshToken;
}
public String getEmail() {
return email;
}
public String getDisplayName() {
return displayName;
}
public String getProfileImageUrl() {
return profileImageUrl;
}
public String getRefreshToken() {
return refreshToken;
}
public Member toMember() {
return new Member(email, displayName, profileImageUrl, SocialType.GOOGLE);
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/auth/dto/request/TokenRenewalRequest.java
================================================
package com.allog.dallog.auth.dto.request;
import javax.validation.constraints.NotNull;
public class TokenRenewalRequest {
@NotNull(message = "리프레시 토큰은 공백일 수 없습니다.")
private String refreshToken;
private TokenRenewalRequest() {
}
public TokenRenewalRequest(final String refreshToken) {
this.refreshToken = refreshToken;
}
public String getRefreshToken() {
return refreshToken;
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/auth/dto/request/TokenRequest.java
================================================
package com.allog.dallog.auth.dto.request;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
public class TokenRequest {
@NotBlank(message = "인가 코드는 공백일 수 없습니다.")
private String code;
@NotNull(message = "Null일 수 없습니다.")
private String redirectUri;
private TokenRequest() {
}
public TokenRequest(final String code, final String redirectUri) {
this.code = code;
this.redirectUri = redirectUri;
}
public String getCode() {
return code;
}
public String getRedirectUri() {
return redirectUri;
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/auth/dto/response/AccessAndRefreshTokenResponse.java
================================================
package com.allog.dallog.auth.dto.response;
public class AccessAndRefreshTokenResponse {
private String accessToken;
private String refreshToken;
private AccessAndRefreshTokenResponse() {
}
public AccessAndRefreshTokenResponse(final String accessToken, final String refreshToken) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
}
public String getAccessToken() {
return accessToken;
}
public String getRefreshToken() {
return refreshToken;
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/auth/dto/response/AccessTokenResponse.java
================================================
package com.allog.dallog.auth.dto.response;
public class AccessTokenResponse {
private String accessToken;
private AccessTokenResponse() {
}
public AccessTokenResponse(final String accessToken) {
this.accessToken = accessToken;
}
public String getAccessToken() {
return accessToken;
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/auth/dto/response/OAuthAccessTokenResponse.java
================================================
package com.allog.dallog.auth.dto.response;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class OAuthAccessTokenResponse {
private String accessToken;
private OAuthAccessTokenResponse() {
}
public OAuthAccessTokenResponse(final String accessToken) {
this.accessToken = accessToken;
}
public String getAccessToken() {
return accessToken;
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/auth/dto/response/OAuthUriResponse.java
================================================
package com.allog.dallog.auth.dto.response;
// OAuth 인증 URI(소셜 로그인 링크)를 전달하는 DTO
public class OAuthUriResponse {
private String oAuthUri;
private OAuthUriResponse() {
}
public OAuthUriResponse(final String oAuthUri) {
this.oAuthUri = oAuthUri;
}
public String getoAuthUri() {
return oAuthUri;
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/auth/event/MemberSavedEvent.java
================================================
package com.allog.dallog.auth.event;
public class MemberSavedEvent {
private final Long memberId;
public MemberSavedEvent(final Long memberId) {
this.memberId = memberId;
}
public Long getMemberId() {
return memberId;
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/auth/exception/EmptyAuthorizationHeaderException.java
================================================
package com.allog.dallog.auth.exception;
public class EmptyAuthorizationHeaderException extends RuntimeException {
public EmptyAuthorizationHeaderException(final String message) {
super(message);
}
public EmptyAuthorizationHeaderException() {
this("header에 Authorization이 존재하지 않습니다.");
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/auth/exception/InvalidTokenException.java
================================================
package com.allog.dallog.auth.exception;
public class InvalidTokenException extends RuntimeException {
public InvalidTokenException(final String message) {
super(message);
}
public InvalidTokenException() {
this("유효하지 않은 토큰입니다.");
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/auth/exception/NoPermissionException.java
================================================
package com.allog.dallog.auth.exception;
public class NoPermissionException extends RuntimeException {
public NoPermissionException(final String message) {
super(message);
}
public NoPermissionException() {
this("권한이 없는 요청 입니다.");
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/auth/exception/NoSuchOAuthTokenException.java
================================================
package com.allog.dallog.auth.exception;
public class NoSuchOAuthTokenException extends RuntimeException {
public NoSuchOAuthTokenException(final String message) {
super(message);
}
public NoSuchOAuthTokenException() {
this("존재하지 않는 OAuthToken 입니다.");
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/auth/exception/NoSuchTokenException.java
================================================
package com.allog.dallog.auth.exception;
public class NoSuchTokenException extends RuntimeException {
public NoSuchTokenException(final String message) {
super(message);
}
public NoSuchTokenException() {
this("존재하지 않는 Token 입니다.");
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/auth/presentation/AuthController.java
================================================
package com.allog.dallog.auth.presentation;
import com.allog.dallog.auth.application.OAuthClient;
import com.allog.dallog.auth.application.OAuthUri;
import com.allog.dallog.auth.application.AuthService;
import com.allog.dallog.auth.dto.LoginMember;
import com.allog.dallog.auth.dto.OAuthMember;
import com.allog.dallog.auth.dto.request.TokenRenewalRequest;
import com.allog.dallog.auth.dto.request.TokenRequest;
import com.allog.dallog.auth.dto.response.AccessAndRefreshTokenResponse;
import com.allog.dallog.auth.dto.response.AccessTokenResponse;
import com.allog.dallog.auth.dto.response.OAuthUriResponse;
import javax.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/api/auth")
@RestController
public class AuthController {
private final OAuthUri oAuthUri;
private final OAuthClient oAuthClient;
private final AuthService authService;
public AuthController(final OAuthUri oAuthUri, final OAuthClient oAuthClient, final AuthService authService) {
this.oAuthUri = oAuthUri;
this.oAuthClient = oAuthClient;
this.authService = authService;
}
@GetMapping("/{oauthProvider}/oauth-uri")
public ResponseEntity generateLink(@PathVariable final String oauthProvider,
@RequestParam final String redirectUri) {
OAuthUriResponse oAuthUriResponse = new OAuthUriResponse(oAuthUri.generate(redirectUri));
return ResponseEntity.ok(oAuthUriResponse);
}
@PostMapping("/{oauthProvider}/token")
public ResponseEntity generateAccessAndRefreshToken(
@PathVariable final String oauthProvider, @Valid @RequestBody final TokenRequest tokenRequest) {
OAuthMember oAuthMember = oAuthClient.getOAuthMember(tokenRequest.getCode(), tokenRequest.getRedirectUri());
AccessAndRefreshTokenResponse response = authService.generateAccessAndRefreshToken(oAuthMember);
return ResponseEntity.ok(response);
}
@PostMapping("/token/access")
public ResponseEntity generateAccessToken(
@Valid @RequestBody final TokenRenewalRequest tokenRenewalRequest) {
AccessTokenResponse response = authService.generateAccessToken(tokenRenewalRequest);
return ResponseEntity.ok(response);
}
@GetMapping("/validate/token")
public ResponseEntity validateToken(@AuthenticationPrincipal final LoginMember loginMember) {
return ResponseEntity.ok().build();
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/auth/presentation/AuthenticationPrincipal.java
================================================
package com.allog.dallog.auth.presentation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthenticationPrincipal {
}
================================================
FILE: backend/src/main/java/com/allog/dallog/auth/presentation/AuthenticationPrincipalArgumentResolver.java
================================================
package com.allog.dallog.auth.presentation;
import com.allog.dallog.auth.application.AuthService;
import com.allog.dallog.auth.dto.LoginMember;
import javax.servlet.http.HttpServletRequest;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
@Component
public class AuthenticationPrincipalArgumentResolver implements HandlerMethodArgumentResolver {
private final AuthService authService;
public AuthenticationPrincipalArgumentResolver(final AuthService authService) {
this.authService = authService;
}
@Override
public boolean supportsParameter(final MethodParameter parameter) {
return parameter.hasParameterAnnotation(AuthenticationPrincipal.class);
}
@Override
public Object resolveArgument(final MethodParameter parameter, final ModelAndViewContainer mavContainer,
final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) {
HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
String accessToken = AuthorizationExtractor.extract(request);
Long id = authService.extractMemberId(accessToken);
return new LoginMember(id);
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/auth/presentation/AuthorizationExtractor.java
================================================
package com.allog.dallog.auth.presentation;
import com.allog.dallog.auth.exception.InvalidTokenException;
import com.allog.dallog.auth.exception.EmptyAuthorizationHeaderException;
import java.util.Objects;
import javax.servlet.http.HttpServletRequest;
import org.springframework.http.HttpHeaders;
public class AuthorizationExtractor {
private static final String BEARER_TYPE = "Bearer ";
public static String extract(final HttpServletRequest request) {
String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
if (Objects.isNull(authorizationHeader)) {
throw new EmptyAuthorizationHeaderException();
}
validateAuthorizationFormat(authorizationHeader);
return authorizationHeader.substring(BEARER_TYPE.length()).trim();
}
private static void validateAuthorizationFormat(final String authorizationHeader) {
if (!authorizationHeader.toLowerCase().startsWith(BEARER_TYPE.toLowerCase())) {
throw new InvalidTokenException("token 형식이 잘못 되었습니다.");
}
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/category/application/CategoryService.java
================================================
package com.allog.dallog.category.application;
import static com.allog.dallog.category.domain.CategoryType.NORMAL;
import static com.allog.dallog.category.domain.CategoryType.PERSONAL;
import com.allog.dallog.auth.event.MemberSavedEvent;
import com.allog.dallog.category.dto.request.CategoryCreateRequest;
import com.allog.dallog.category.dto.request.CategoryUpdateRequest;
import com.allog.dallog.category.dto.request.ExternalCategoryCreateRequest;
import com.allog.dallog.category.dto.response.CategoriesResponse;
import com.allog.dallog.category.dto.response.CategoryDetailResponse;
import com.allog.dallog.category.dto.response.CategoryResponse;
import com.allog.dallog.category.exception.InvalidCategoryException;
import com.allog.dallog.categoryrole.domain.CategoryAuthority;
import com.allog.dallog.categoryrole.domain.CategoryRoleType;
import com.allog.dallog.category.domain.Category;
import com.allog.dallog.category.domain.CategoryRepository;
import com.allog.dallog.category.domain.CategoryType;
import com.allog.dallog.category.domain.ExternalCategoryDetail;
import com.allog.dallog.category.domain.ExternalCategoryDetailRepository;
import com.allog.dallog.categoryrole.domain.CategoryRole;
import com.allog.dallog.categoryrole.domain.CategoryRoleRepository;
import com.allog.dallog.member.domain.Member;
import com.allog.dallog.member.domain.MemberRepository;
import com.allog.dallog.schedule.domain.ScheduleRepository;
import com.allog.dallog.subscription.application.ColorPicker;
import com.allog.dallog.subscription.domain.Color;
import com.allog.dallog.subscription.domain.Subscription;
import com.allog.dallog.subscription.domain.SubscriptionRepository;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Transactional(readOnly = true)
@Service
public class CategoryService {
private static final String PERSONAL_CATEGORY_NAME = "내 일정";
private final CategoryRepository categoryRepository;
private final ExternalCategoryDetailRepository externalCategoryDetailRepository;
private final MemberRepository memberRepository;
private final SubscriptionRepository subscriptionRepository;
private final ScheduleRepository scheduleRepository;
private final CategoryRoleRepository categoryRoleRepository;
private final ColorPicker colorPicker;
public CategoryService(final CategoryRepository categoryRepository,
final ExternalCategoryDetailRepository externalCategoryDetailRepository,
final MemberRepository memberRepository, final SubscriptionRepository subscriptionRepository,
final ScheduleRepository scheduleRepository,
final CategoryRoleRepository categoryRoleRepository, final ColorPicker colorPicker) {
this.categoryRepository = categoryRepository;
this.externalCategoryDetailRepository = externalCategoryDetailRepository;
this.memberRepository = memberRepository;
this.subscriptionRepository = subscriptionRepository;
this.scheduleRepository = scheduleRepository;
this.categoryRoleRepository = categoryRoleRepository;
this.colorPicker = colorPicker;
}
@Transactional
public CategoryResponse save(final Long memberId, final CategoryCreateRequest request) {
categoryRoleRepository.validateManagingCategoryLimit(memberId, CategoryRoleType.ADMIN);
Member member = memberRepository.getById(memberId);
Category category = request.toEntity(member);
Category savedCategory = categoryRepository.save(category);
subscribeCategory(member, category);
createCategoryRoleAsAdminToCreator(member, category);
return new CategoryResponse(savedCategory);
}
@Transactional
public CategoryResponse save(final Long memberId, final ExternalCategoryCreateRequest request) {
List externalCategories = categoryRepository
.findByMemberIdAndCategoryType(memberId, CategoryType.GOOGLE);
externalCategoryDetailRepository
.validateExistByExternalIdAndCategoryIn(request.getExternalId(), externalCategories);
CategoryResponse response = save(memberId, new CategoryCreateRequest(request.getName(), CategoryType.GOOGLE));
Category category = categoryRepository.getById(response.getId());
externalCategoryDetailRepository.save(new ExternalCategoryDetail(category, request.getExternalId()));
return response;
}
@Transactional
@EventListener
public void savePersonalCategory(final MemberSavedEvent event) {
Member member = memberRepository.getById(event.getMemberId());
Category category = categoryRepository.save(new Category(PERSONAL_CATEGORY_NAME, member, PERSONAL));
subscribeCategory(member, category);
createCategoryRoleAsAdminToCreator(member, category);
}
private void subscribeCategory(final Member member, final Category category) {
Color color = Color.pick(colorPicker.pickNumber());
subscriptionRepository.save(new Subscription(member, category, color));
}
private void createCategoryRoleAsAdminToCreator(final Member member, final Category category) {
CategoryRole categoryRole = new CategoryRole(category, member, CategoryRoleType.ADMIN);
categoryRoleRepository.save(categoryRole);
}
public CategoriesResponse findNormalByName(final String name) {
List categories = categoryRepository.findByCategoryTypeAndNameContaining(NORMAL, name);
return new CategoriesResponse(categories);
}
// 회원이 ADMIN이 아니어도 일정 추가/제거/수정이 가능하므로, findAdminCategories와 별도의 메소드로 분리해야함
public CategoriesResponse findScheduleEditableCategories(final Long memberId) {
List categoryRoles = categoryRoleRepository.findByMemberId(memberId);
Set roleTypes = CategoryRoleType.getHavingAuthorities(Set.of(CategoryAuthority.ADD_SCHEDULE, CategoryAuthority.UPDATE_SCHEDULE));
return new CategoriesResponse(toCategories(categoryRoles, roleTypes));
}
public CategoriesResponse findAdminCategories(final Long memberId) {
List categoryRoles = categoryRoleRepository.findByMemberId(memberId);
return new CategoriesResponse(toCategories(categoryRoles, Set.of(CategoryRoleType.ADMIN)));
}
private List toCategories(final List categoryRoles, final Set roleTypes) {
return categoryRoles.stream()
.filter(categoryRole -> roleTypes.contains(categoryRole.getCategoryRoleType()))
.map(CategoryRole::getCategory)
.collect(Collectors.toList());
}
public CategoryDetailResponse findDetailCategoryById(final Long id) {
Category category = categoryRepository.getById(id);
List subscriptions = subscriptionRepository.findByCategoryId(id);
return new CategoryDetailResponse(category, subscriptions.size());
}
@Transactional
public void update(final Long memberId, final Long id, final CategoryUpdateRequest request) {
Category category = categoryRepository.getById(id);
CategoryRole role = categoryRoleRepository.getByMemberIdAndCategoryId(memberId, category.getId());
role.validateAuthority(CategoryAuthority.UPDATE_CATEGORY);
category.changeName(request.getName());
}
@Transactional
public void delete(final Long memberId, final Long id) {
Category category = categoryRepository.getById(id);
validateNotPersonalCategory(category);
CategoryRole role = categoryRoleRepository.getByMemberIdAndCategoryId(memberId, category.getId());
role.validateAuthority(CategoryAuthority.DELETE_CATEGORY);
scheduleRepository.deleteByCategoryIdIn(List.of(id));
subscriptionRepository.deleteByCategoryIdIn(List.of(id));
externalCategoryDetailRepository.deleteByCategoryId(id);
categoryRoleRepository.deleteByCategoryId(id);
categoryRepository.deleteById(id);
}
private void validateNotPersonalCategory(final Category category) {
if (category.isPersonal()) {
throw new InvalidCategoryException("내 일정 카테고리는 삭제할 수 없습니다.");
}
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/category/application/ExternalCategoryDetailService.java
================================================
package com.allog.dallog.category.application;
import com.allog.dallog.category.domain.Category;
import com.allog.dallog.category.domain.ExternalCategoryDetail;
import com.allog.dallog.category.domain.ExternalCategoryDetailRepository;
import com.allog.dallog.subscription.domain.SubscriptionRepository;
import com.allog.dallog.subscription.domain.Subscriptions;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Transactional(readOnly = true)
@Service
public class ExternalCategoryDetailService {
private final ExternalCategoryDetailRepository externalCategoryDetailRepository;
private final SubscriptionRepository subscriptionRepository;
public ExternalCategoryDetailService(final ExternalCategoryDetailRepository externalCategoryDetailRepository,
final SubscriptionRepository subscriptionRepository) {
this.externalCategoryDetailRepository = externalCategoryDetailRepository;
this.subscriptionRepository = subscriptionRepository;
}
public List findByMemberId(final Long memberId) {
Subscriptions subscriptions = new Subscriptions(subscriptionRepository.findByMemberId(memberId));
List categories = subscriptions.findExternalCategory();
return externalCategoryDetailRepository.findByCategoryIn(categories);
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/category/domain/Category.java
================================================
package com.allog.dallog.category.domain;
import com.allog.dallog.auth.exception.NoPermissionException;
import com.allog.dallog.category.exception.InvalidCategoryException;
import com.allog.dallog.global.entity.BaseEntity;
import com.allog.dallog.member.domain.Member;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.EnumType;
import javax.persistence.Enumerated;
import javax.persistence.FetchType;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Table;
@Table(name = "categories")
@Entity
public class Category extends BaseEntity {
public static final int MAX_NAME_LENGTH = 20;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
@Column(name = "name", nullable = false)
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "members_id")
private Member member;
@Enumerated(value = EnumType.STRING)
@Column(name = "category_type", nullable = false)
private CategoryType categoryType;
protected Category() {
}
public Category(final String name, final Member member) {
validateNameLength(name);
this.name = name;
this.member = member;
this.categoryType = CategoryType.NORMAL;
}
public Category(final String name, final Member member, final CategoryType categoryType) {
validateNameLength(name);
this.name = name;
this.member = member;
this.categoryType = categoryType;
}
public void changeName(final String name) {
validatePersonal();
validateNameLength(name);
this.name = name;
}
private void validatePersonal() {
if (isPersonal()) {
throw new InvalidCategoryException("'내 일정' 카테고리는 수정할 수 없습니다.");
}
}
private void validateNameLength(final String name) {
if (name.isBlank()) {
throw new InvalidCategoryException("카테고리 이름은 공백일 수 없습니다.");
}
if (name.length() > MAX_NAME_LENGTH) {
throw new InvalidCategoryException(String.format("카테고리 이름의 길이는 %d을 초과할 수 없습니다.", MAX_NAME_LENGTH));
}
}
public void validateSubscriptionPossible(final Member member) {
if (this.categoryType == CategoryType.PERSONAL && !isCreatorId(member.getId())) {
throw new NoPermissionException("구독 권한이 없는 카테고리입니다.");
}
}
public void validateNotExternalCategory() {
if (categoryType == CategoryType.GOOGLE) {
throw new NoPermissionException("외부 연동 카테고리에는 일정을 추가할 수 없습니다.");
}
}
public boolean isCreatorId(final Long creatorId) {
return member.hasSameId(creatorId);
}
public boolean isNormal() {
return categoryType == CategoryType.NORMAL;
}
public boolean isPersonal() {
return categoryType == CategoryType.PERSONAL;
}
public boolean isInternal() {
return categoryType != CategoryType.GOOGLE;
}
public boolean isExternal() {
return categoryType == CategoryType.GOOGLE;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public Member getMember() {
return member;
}
public void setMember(final Member member) {
this.member = member;
}
public CategoryType getCategoryType() {
return categoryType;
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/category/domain/CategoryRepository.java
================================================
package com.allog.dallog.category.domain;
import com.allog.dallog.category.exception.NoSuchCategoryException;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
public interface CategoryRepository extends JpaRepository {
@Query("SELECT c "
+ "FROM Subscription s "
+ "JOIN s.category c "
+ "WHERE c.categoryType = :categoryType AND c.name LIKE %:name% "
+ "GROUP BY c.id "
+ "ORDER BY COUNT(c.id) DESC")
List findByCategoryTypeAndNameContaining(final CategoryType categoryType, final String name);
@Query("SELECT c "
+ "FROM Category c "
+ "WHERE c.member.id = :memberId AND c.categoryType = :categoryType")
List findByMemberIdAndCategoryType(final Long memberId, final CategoryType categoryType);
@Query("SELECT c "
+ "FROM Category c "
+ "WHERE c.member.id = :memberId")
List findByMemberId(final Long memberId);
default Category getById(final Long id) {
return this.findById(id)
.orElseThrow(NoSuchCategoryException::new);
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/category/domain/CategoryType.java
================================================
package com.allog.dallog.category.domain;
import com.allog.dallog.category.exception.NoSuchCategoryException;
public enum CategoryType {
NORMAL, PERSONAL, GOOGLE;
public static CategoryType from(final String value) {
try {
return CategoryType.valueOf(value.toUpperCase());
} catch (final IllegalArgumentException e) {
throw new NoSuchCategoryException("(" + value + ")는 존재하지 않는 카테고리 타입입니다.");
}
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/category/domain/ExternalCategoryDetail.java
================================================
package com.allog.dallog.category.domain;
import com.allog.dallog.global.entity.BaseEntity;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.OneToOne;
import javax.persistence.Table;
@Table(name = "external_category_details")
@Entity
public class ExternalCategoryDetail extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "categories_id", nullable = false)
private Category category;
@Column(name = "external_id", nullable = false)
private String externalId;
protected ExternalCategoryDetail() {
}
public ExternalCategoryDetail(final Category category, final String externalId) {
this.category = category;
this.externalId = externalId;
}
public Long getId() {
return id;
}
public Category getCategory() {
return category;
}
public String getExternalId() {
return externalId;
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/category/domain/ExternalCategoryDetailRepository.java
================================================
package com.allog.dallog.category.domain;
import com.allog.dallog.category.exception.ExistExternalCategoryException;
import com.allog.dallog.category.exception.NoSuchExternalCategoryDetailException;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ExternalCategoryDetailRepository extends JpaRepository {
Optional findByCategory(final Category category);
List findByCategoryIn(final List categories);
boolean existsByExternalIdAndCategoryIn(final String externalId, final List categories);
void deleteByCategoryId(final Long categoryId);
void deleteByCategoryIdIn(final List categoryIds);
default ExternalCategoryDetail getByCategory(final Category category) {
return this.findByCategory(category)
.orElseThrow(NoSuchExternalCategoryDetailException::new);
}
default void validateExistByExternalIdAndCategoryIn(final String externalId,
final List externalCategories) {
if (existsByExternalIdAndCategoryIn(externalId, externalCategories)) {
throw new ExistExternalCategoryException();
}
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/category/dto/request/CategoryCreateRequest.java
================================================
package com.allog.dallog.category.dto.request;
import com.allog.dallog.category.domain.Category;
import com.allog.dallog.category.domain.CategoryType;
import com.allog.dallog.member.domain.Member;
import javax.validation.constraints.NotBlank;
public class CategoryCreateRequest {
@NotBlank(message = "공백일 수 없습니다.")
private String name;
@NotBlank(message = "공백일 수 없습니다.")
private String categoryType;
private CategoryCreateRequest() {
}
public CategoryCreateRequest(final String name, final CategoryType categoryType) {
this.name = name;
this.categoryType = categoryType.name();
}
public Category toEntity(final Member member) {
return new Category(name, member, CategoryType.from(categoryType));
}
public String getName() {
return name;
}
public String getCategoryType() {
return categoryType;
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/category/dto/request/CategoryUpdateRequest.java
================================================
package com.allog.dallog.category.dto.request;
import javax.validation.constraints.NotBlank;
public class CategoryUpdateRequest {
@NotBlank(message = "카테고리 이름이 공백일 수 없습니다.")
private String name;
private CategoryUpdateRequest() {
}
public CategoryUpdateRequest(final String name) {
this.name = name;
}
public String getName() {
return name;
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/category/dto/request/ExternalCategoryCreateRequest.java
================================================
package com.allog.dallog.category.dto.request;
import javax.validation.constraints.NotBlank;
public class ExternalCategoryCreateRequest {
@NotBlank(message = "외부 캘린더 아이디가 공백일 수 없습니다.")
private String externalId;
@NotBlank(message = "외부 캘린더 이름이 공백일 수 없습니다.")
private String name;
private ExternalCategoryCreateRequest() {
}
public ExternalCategoryCreateRequest(final String externalId, final String name) {
this.externalId = externalId;
this.name = name;
}
public String getExternalId() {
return externalId;
}
public String getName() {
return name;
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/category/dto/response/CategoriesResponse.java
================================================
package com.allog.dallog.category.dto.response;
import com.allog.dallog.category.domain.Category;
import java.util.List;
import java.util.stream.Collectors;
public class CategoriesResponse {
private List categories;
private CategoriesResponse() {
}
public CategoriesResponse(final List categories) {
this.categories = toResponses(categories);
}
private List toResponses(final List categories) {
return categories.stream()
.map(CategoryResponse::new)
.collect(Collectors.toList());
}
public List getCategories() {
return categories;
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/category/dto/response/CategoryDetailResponse.java
================================================
package com.allog.dallog.category.dto.response;
import com.allog.dallog.category.domain.Category;
import com.allog.dallog.member.dto.response.MemberResponse;
import java.time.LocalDateTime;
public class CategoryDetailResponse {
private Long id;
private String name;
private String categoryType;
private int subscriberCount;
private MemberResponse creator;
private LocalDateTime createdAt;
private CategoryDetailResponse() {
}
public CategoryDetailResponse(final Category category, final int subscriberCount) {
this(category.getId(), category.getName(), category.getCategoryType().name(), subscriberCount,
new MemberResponse(category.getMember()), category.getCreatedAt());
}
public CategoryDetailResponse(final Long id, final String name, final String categoryType,
final int subscriberCount, final MemberResponse creator,
final LocalDateTime createdAt) {
this.id = id;
this.name = name;
this.categoryType = categoryType;
this.subscriberCount = subscriberCount;
this.creator = creator;
this.createdAt = createdAt;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public String getCategoryType() {
return categoryType;
}
public int getSubscriberCount() {
return subscriberCount;
}
public MemberResponse getCreator() {
return creator;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/category/dto/response/CategoryResponse.java
================================================
package com.allog.dallog.category.dto.response;
import com.allog.dallog.category.domain.Category;
import com.allog.dallog.member.dto.response.MemberResponse;
import java.time.LocalDateTime;
public class CategoryResponse {
private Long id;
private String name;
private String categoryType;
private MemberResponse creator;
private LocalDateTime createdAt;
private CategoryResponse() {
}
public CategoryResponse(final Category category) {
this(category.getId(), category.getName(), category.getCategoryType().name(),
new MemberResponse(category.getMember()), category.getCreatedAt());
}
public CategoryResponse(final Long id, final String name, final String categoryType, final MemberResponse creator,
final LocalDateTime createdAt) {
this.id = id;
this.name = name;
this.categoryType = categoryType;
this.creator = creator;
this.createdAt = createdAt;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public String getCategoryType() {
return categoryType;
}
public MemberResponse getCreator() {
return creator;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/category/exception/ExistExternalCategoryException.java
================================================
package com.allog.dallog.category.exception;
public class ExistExternalCategoryException extends RuntimeException {
public ExistExternalCategoryException(final String message) {
super(message);
}
public ExistExternalCategoryException() {
this("이미 저장된 연동 카테고리입니다.");
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/category/exception/InvalidCategoryException.java
================================================
package com.allog.dallog.category.exception;
public class InvalidCategoryException extends RuntimeException {
public InvalidCategoryException(final String message) {
super(message);
}
public InvalidCategoryException() {
this("잘못된 카테고리입니다.");
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/category/exception/NoSuchCategoryException.java
================================================
package com.allog.dallog.category.exception;
public class NoSuchCategoryException extends RuntimeException {
public NoSuchCategoryException(final String message) {
super(message);
}
public NoSuchCategoryException() {
this("존재하지 않는 카테고리입니다.");
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/category/exception/NoSuchExternalCategoryDetailException.java
================================================
package com.allog.dallog.category.exception;
public class NoSuchExternalCategoryDetailException extends RuntimeException {
public NoSuchExternalCategoryDetailException(final String message) {
super(message);
}
public NoSuchExternalCategoryDetailException() {
this("존재하지 않는 외부 카테고리 정보 입니다.");
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/category/presentaion/CategoryController.java
================================================
package com.allog.dallog.category.presentaion;
import com.allog.dallog.auth.dto.LoginMember;
import com.allog.dallog.auth.presentation.AuthenticationPrincipal;
import com.allog.dallog.category.dto.request.CategoryCreateRequest;
import com.allog.dallog.category.dto.request.CategoryUpdateRequest;
import com.allog.dallog.category.dto.response.CategoriesResponse;
import com.allog.dallog.category.dto.response.CategoryDetailResponse;
import com.allog.dallog.category.dto.response.CategoryResponse;
import com.allog.dallog.categoryrole.dto.request.CategoryRoleUpdateRequest;
import com.allog.dallog.category.application.CategoryService;
import com.allog.dallog.categoryrole.application.CategoryRoleService;
import com.allog.dallog.categoryrole.dto.response.SubscribersResponse;
import java.net.URI;
import javax.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/api/categories")
@RestController
public class CategoryController {
private final CategoryService categoryService;
private final CategoryRoleService categoryRoleService;
public CategoryController(final CategoryService categoryService, final CategoryRoleService categoryRoleService) {
this.categoryService = categoryService;
this.categoryRoleService = categoryRoleService;
}
@PostMapping
public ResponseEntity save(@AuthenticationPrincipal final LoginMember loginMember,
@Valid @RequestBody final CategoryCreateRequest request) {
CategoryResponse categoryResponse = categoryService.save(loginMember.getId(), request);
return ResponseEntity.created(URI.create("/api/categories/" + categoryResponse.getId())).body(categoryResponse);
}
@GetMapping
public ResponseEntity findNormalByName(@RequestParam(defaultValue = "") final String name) {
return ResponseEntity.ok(categoryService.findNormalByName(name));
}
@GetMapping("/{categoryId}")
public ResponseEntity findDetailCategoryById(@PathVariable final Long categoryId) {
return ResponseEntity.ok(categoryService.findDetailCategoryById(categoryId));
}
@GetMapping("/me/schedule-editable") // 일정 추가, 수정 모달의 카테고리 목록에 사용됨
public ResponseEntity findScheduleEditableCategories(
@AuthenticationPrincipal final LoginMember loginMember) {
return ResponseEntity.ok(categoryService.findScheduleEditableCategories(loginMember.getId()));
}
@GetMapping("/me/admin") // 카테고리 관리 페이지에 접근할 수 있는지 판단하기 위해 사용됨
public ResponseEntity findAdminCategories(
@AuthenticationPrincipal final LoginMember loginMember) {
return ResponseEntity.ok(categoryService.findAdminCategories(loginMember.getId()));
}
@PatchMapping("/{categoryId}")
public ResponseEntity update(@AuthenticationPrincipal final LoginMember loginMember,
@PathVariable final Long categoryId,
@Valid @RequestBody final CategoryUpdateRequest request) {
categoryService.update(loginMember.getId(), categoryId, request);
return ResponseEntity.noContent().build();
}
@DeleteMapping("/{categoryId}")
public ResponseEntity delete(@AuthenticationPrincipal final LoginMember loginMember,
@PathVariable final Long categoryId) {
categoryService.delete(loginMember.getId(), categoryId);
return ResponseEntity.noContent().build();
}
@GetMapping("/{categoryId}/subscribers")
public ResponseEntity findSubscribers(@AuthenticationPrincipal final LoginMember loginMember,
@PathVariable final Long categoryId) {
SubscribersResponse subscribers = categoryRoleService.findSubscribers(loginMember.getId(), categoryId);
return ResponseEntity.ok(subscribers);
}
@PatchMapping("/{categoryId}/subscribers/{memberId}/role")
public ResponseEntity updateRole(@AuthenticationPrincipal final LoginMember loginMember,
@PathVariable final Long categoryId,
@PathVariable final Long memberId,
@RequestBody final CategoryRoleUpdateRequest request) {
categoryRoleService.updateRole(loginMember.getId(), memberId, categoryId, request);
return ResponseEntity.noContent().build();
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/categoryrole/application/CategoryRoleService.java
================================================
package com.allog.dallog.categoryrole.application;
import com.allog.dallog.categoryrole.domain.CategoryAuthority;
import com.allog.dallog.categoryrole.domain.CategoryRoleRepository;
import com.allog.dallog.categoryrole.dto.request.CategoryRoleUpdateRequest;
import com.allog.dallog.categoryrole.dto.response.SubscribersResponse;
import com.allog.dallog.category.domain.Category;
import com.allog.dallog.categoryrole.domain.CategoryRole;
import com.allog.dallog.categoryrole.exception.NotAbleToChangeRoleException;
import java.util.List;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Transactional(readOnly = true)
@Service
public class CategoryRoleService {
private final CategoryRoleRepository categoryRoleRepository;
public CategoryRoleService(final CategoryRoleRepository categoryRoleRepository) {
this.categoryRoleRepository = categoryRoleRepository;
}
public SubscribersResponse findSubscribers(final Long loginMemberId, final Long categoryId) {
CategoryRole categoryRole = categoryRoleRepository.getByMemberIdAndCategoryId(loginMemberId, categoryId);
categoryRole.validateAuthority(CategoryAuthority.FIND_SUBSCRIBERS);
List categoryRoles = categoryRoleRepository.findByCategoryId(categoryId);
return new SubscribersResponse(categoryRoles);
}
@Transactional
public void updateRole(final Long loginMemberId, final Long memberId, final Long categoryId,
final CategoryRoleUpdateRequest request) {
try {
List categoryRolesInCategory = categoryRoleRepository.findByCategoryId(categoryId);
CategoryRole roleOfTargetMember = getCategoryRole(memberId, categoryRolesInCategory);
validateLoginMemberAuthority(loginMemberId, categoryRolesInCategory); // 요청 유저 권한 검증
validateIsTargetMemberSoleAdmin(categoryRolesInCategory, roleOfTargetMember); // 대상 유저가 유일한 어드민이 아닌지 검증
validateCategoryType(roleOfTargetMember.getCategory()); // 카테고리가 개인, 외부 카테고리가 아닌지 검증
categoryRoleRepository.validateManagingCategoryLimit(memberId, request.getCategoryRoleType()); // 관리 개수 검증
roleOfTargetMember.changeRole(request.getCategoryRoleType());
} catch (final ObjectOptimisticLockingFailureException e) {
throw NotAbleToChangeRoleException.concurrentIssue();
}
}
private CategoryRole getCategoryRole(final Long memberId, final List categoryRoles) {
return categoryRoles.stream()
.filter(it -> it.getMember().getId().equals(memberId))
.findFirst()
.orElseThrow();
}
private void validateLoginMemberAuthority(final Long loginMemberId, final List categoryRoles) {
CategoryRole loginMemberCategoryRole = categoryRoles.stream()
.filter(categoryRole -> categoryRole.getMember().getId().equals(loginMemberId))
.findFirst()
.orElseThrow();
loginMemberCategoryRole.validateAuthority(CategoryAuthority.CHANGE_ROLE_OF_SUBSCRIBER);
}
private void validateIsTargetMemberSoleAdmin(final List categoryRoles,
final CategoryRole categoryRole) {
if (categoryRole.isAdmin() && categoryRoles.size() == 1) {
throw new NotAbleToChangeRoleException();
}
}
private void validateCategoryType(final Category category) {
if (!category.isNormal()) {
throw new NotAbleToChangeRoleException("개인 카테고리 또는 외부 카테고리에 대한 회원의 역할을 변경할 수 없습니다.");
}
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/categoryrole/domain/CategoryAuthority.java
================================================
package com.allog.dallog.categoryrole.domain;
public enum CategoryAuthority {
UPDATE_CATEGORY("카테고리 수정"),
DELETE_CATEGORY("카테고리 제거"),
ADD_SCHEDULE("일정 추가"),
UPDATE_SCHEDULE("일정 수정"),
DELETE_SCHEDULE("일정 제거"),
CHANGE_ROLE_OF_SUBSCRIBER("역할 변경"),
FIND_SUBSCRIBERS("카테고리 구독자 조회");
private final String name;
CategoryAuthority(final String name) {
this.name = name;
}
public String getName() {
return name;
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/categoryrole/domain/CategoryRole.java
================================================
package com.allog.dallog.categoryrole.domain;
import com.allog.dallog.category.domain.Category;
import com.allog.dallog.global.entity.BaseEntity;
import com.allog.dallog.categoryrole.exception.NoCategoryAuthorityException;
import com.allog.dallog.member.domain.Member;
import javax.persistence.Entity;
import javax.persistence.EnumType;
import javax.persistence.Enumerated;
import javax.persistence.FetchType;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Table;
import javax.persistence.Version;
@Table(name = "category_roles")
@Entity
public class CategoryRole extends BaseEntity {
public static final int MAX_MANAGING_CATEGORY_COUNT = 50;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "categories_id", nullable = false)
private Category category;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "members_id", nullable = false)
private Member member;
@Enumerated(EnumType.STRING)
private CategoryRoleType categoryRoleType;
@Version
private Long version;
protected CategoryRole() {
}
public CategoryRole(final Category category, final Member member, final CategoryRoleType categoryRoleType) {
this.category = category;
this.member = member;
this.categoryRoleType = categoryRoleType;
}
public boolean isAdmin() {
return categoryRoleType.equals(CategoryRoleType.ADMIN);
}
public boolean isNone() {
return categoryRoleType.equals(CategoryRoleType.NONE);
}
public void validateAuthority(final CategoryAuthority authority) {
if (!ableTo(authority)) {
throw new NoCategoryAuthorityException(authority.getName());
}
}
public boolean ableTo(final CategoryAuthority authority) {
return categoryRoleType.ableTo(authority);
}
public void changeRole(final CategoryRoleType categoryRoleType) {
this.categoryRoleType = categoryRoleType;
}
public Long getId() {
return id;
}
public Category getCategory() {
return category;
}
public Member getMember() {
return member;
}
public CategoryRoleType getCategoryRoleType() {
return categoryRoleType;
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/categoryrole/domain/CategoryRoleRepository.java
================================================
package com.allog.dallog.categoryrole.domain;
import com.allog.dallog.categoryrole.exception.ManagingCategoryLimitExcessException;
import com.allog.dallog.categoryrole.exception.NoSuchCategoryRoleException;
import java.util.List;
import java.util.Optional;
import javax.persistence.LockModeType;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
public interface CategoryRoleRepository extends JpaRepository {
@Lock(LockModeType.OPTIMISTIC)
@Query("SELECT cr "
+ "FROM CategoryRole cr "
+ "WHERE cr.member.id = :memberId AND cr.category.id = :categoryId")
Optional findByMemberIdAndCategoryId(final Long memberId, final Long categoryId);
@EntityGraph(attributePaths = {"member"})
List findByCategoryId(final Long categoryId);
@EntityGraph(attributePaths = {"category", "category.member"})
List findByMemberId(final Long memberId);
@Query("SELECT count(cr) "
+ "FROM CategoryRole cr "
+ "WHERE cr.categoryRoleType = :categoryRoleType "
+ "AND cr.member.id = :memberId")
int countByMemberIdAndCategoryRoleType(final Long memberId, final CategoryRoleType categoryRoleType);
int countByCategoryIdAndCategoryRoleType(final Long categoryId, final CategoryRoleType categoryRoleType);
void deleteByCategoryId(final Long categoryId);
default CategoryRole getByMemberIdAndCategoryId(final Long memberId, final Long categoryId) {
return findByMemberIdAndCategoryId(memberId, categoryId)
.orElseThrow(NoSuchCategoryRoleException::new);
}
default boolean isMemberSoleAdminInCategory(final Long memberId, final Long categoryId) {
CategoryRole categoryRole = getByMemberIdAndCategoryId(memberId, categoryId);
int adminCount = countByCategoryIdAndCategoryRoleType(categoryId, CategoryRoleType.ADMIN);
return categoryRole.isAdmin() && adminCount == 1;
}
default void validateManagingCategoryLimit(final Long memberId, final CategoryRoleType categoryRoleType) {
int memberAdminCount = countByMemberIdAndCategoryRoleType(memberId, CategoryRoleType.ADMIN);
if (!categoryRoleType.equals(CategoryRoleType.NONE)
&& memberAdminCount >= CategoryRole.MAX_MANAGING_CATEGORY_COUNT) {
throw new ManagingCategoryLimitExcessException();
}
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/categoryrole/domain/CategoryRoleType.java
================================================
package com.allog.dallog.categoryrole.domain;
import static com.allog.dallog.categoryrole.domain.CategoryAuthority.ADD_SCHEDULE;
import static com.allog.dallog.categoryrole.domain.CategoryAuthority.CHANGE_ROLE_OF_SUBSCRIBER;
import static com.allog.dallog.categoryrole.domain.CategoryAuthority.DELETE_CATEGORY;
import static com.allog.dallog.categoryrole.domain.CategoryAuthority.DELETE_SCHEDULE;
import static com.allog.dallog.categoryrole.domain.CategoryAuthority.FIND_SUBSCRIBERS;
import static com.allog.dallog.categoryrole.domain.CategoryAuthority.UPDATE_CATEGORY;
import static com.allog.dallog.categoryrole.domain.CategoryAuthority.UPDATE_SCHEDULE;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.Set;
import java.util.stream.Collectors;
public enum CategoryRoleType {
ADMIN(EnumSet.of(UPDATE_CATEGORY, DELETE_CATEGORY, ADD_SCHEDULE, UPDATE_SCHEDULE, DELETE_SCHEDULE,
CHANGE_ROLE_OF_SUBSCRIBER, FIND_SUBSCRIBERS)),
NONE(Set.of());
private final Set authorities;
CategoryRoleType(final Set authorities) {
this.authorities = authorities;
}
public static Set getHavingAuthorities(final Set authorities) {
return Arrays.stream(values())
.filter(categoryRoleType -> isCategoryRoleTypeContainsAuthorities(authorities, categoryRoleType))
.collect(Collectors.toSet());
}
private static boolean isCategoryRoleTypeContainsAuthorities(final Set authorities,
final CategoryRoleType categoryRoleType) {
return categoryRoleType.authorities.containsAll(authorities);
}
public boolean ableTo(final CategoryAuthority authority) {
return authorities.contains(authority);
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/categoryrole/dto/request/CategoryRoleUpdateRequest.java
================================================
package com.allog.dallog.categoryrole.dto.request;
import com.allog.dallog.categoryrole.domain.CategoryRoleType;
import javax.validation.constraints.NotBlank;
public class CategoryRoleUpdateRequest {
@NotBlank(message = "공백일 수 없습니다.")
private CategoryRoleType categoryRoleType;
private CategoryRoleUpdateRequest() {
}
public CategoryRoleUpdateRequest(final CategoryRoleType categoryRoleType) {
this.categoryRoleType = categoryRoleType;
}
public CategoryRoleType getCategoryRoleType() {
return categoryRoleType;
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/categoryrole/dto/response/MemberWithRoleTypeResponse.java
================================================
package com.allog.dallog.categoryrole.dto.response;
import com.allog.dallog.categoryrole.domain.CategoryRoleType;
import com.allog.dallog.categoryrole.domain.CategoryRole;
import com.allog.dallog.member.dto.response.MemberResponse;
public class MemberWithRoleTypeResponse {
private MemberResponse member;
private CategoryRoleType categoryRoleType;
private MemberWithRoleTypeResponse() {
}
public MemberWithRoleTypeResponse(final CategoryRole categoryRole) {
this.member = new MemberResponse(categoryRole.getMember());
this.categoryRoleType = categoryRole.getCategoryRoleType();
}
public MemberResponse getMember() {
return member;
}
public CategoryRoleType getCategoryRoleType() {
return categoryRoleType;
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/categoryrole/dto/response/SubscribersResponse.java
================================================
package com.allog.dallog.categoryrole.dto.response;
import com.allog.dallog.categoryrole.domain.CategoryRole;
import java.util.List;
import java.util.stream.Collectors;
public class SubscribersResponse {
private List subscribers;
private SubscribersResponse() {
}
public SubscribersResponse(final List categoryRoles) {
this.subscribers = toResponses(categoryRoles);
}
private List toResponses(final List categoryRoles) {
return categoryRoles.stream()
.map(MemberWithRoleTypeResponse::new)
.collect(Collectors.toList());
}
public List getSubscribers() {
return subscribers;
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/categoryrole/exception/ManagingCategoryLimitExcessException.java
================================================
package com.allog.dallog.categoryrole.exception;
public class ManagingCategoryLimitExcessException extends RuntimeException {
private static final int MAX_MANAGING_CATEGORY_COUNT = 50;
public ManagingCategoryLimitExcessException() {
super("한 사람이 관리할 수 있는 카테고리는 최대 " + MAX_MANAGING_CATEGORY_COUNT + "개 입니다.");
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/categoryrole/exception/NoCategoryAuthorityException.java
================================================
package com.allog.dallog.categoryrole.exception;
public class NoCategoryAuthorityException extends RuntimeException {
public NoCategoryAuthorityException(final String authorityName) {
super(authorityName + " 권한이 없습니다.");
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/categoryrole/exception/NoSuchCategoryRoleException.java
================================================
package com.allog.dallog.categoryrole.exception;
public class NoSuchCategoryRoleException extends RuntimeException {
public NoSuchCategoryRoleException(final String message) {
super(message);
}
public NoSuchCategoryRoleException() {
this("존재하지 않는 역할입니다.");
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/categoryrole/exception/NotAbleToChangeRoleException.java
================================================
package com.allog.dallog.categoryrole.exception;
public class NotAbleToChangeRoleException extends RuntimeException {
public NotAbleToChangeRoleException(final String message) {
super(message);
}
public NotAbleToChangeRoleException() {
super("역할을 변경할 수 없습니다.");
}
public static NotAbleToChangeRoleException concurrentIssue() {
return new NotAbleToChangeRoleException("회원님의 권한이 변경되어 카테고리 역할을 수정할 수 없습니다.");
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/externalcalendar/application/ExternalCalendarClient.java
================================================
package com.allog.dallog.externalcalendar.application;
import com.allog.dallog.externalcalendar.dto.ExternalCalendar;
import com.allog.dallog.schedule.domain.IntegrationSchedule;
import java.util.List;
public interface ExternalCalendarClient {
List getExternalCalendars(final String accessToken);
List getExternalCalendarSchedules(final String accessToken,
final Long internalCategoryId,
final String externalCalendarId,
final String startDateTime, final String endDateTime);
}
================================================
FILE: backend/src/main/java/com/allog/dallog/externalcalendar/application/ExternalCalendarService.java
================================================
package com.allog.dallog.externalcalendar.application;
import com.allog.dallog.auth.application.OAuthClient;
import com.allog.dallog.auth.domain.OAuthToken;
import com.allog.dallog.auth.domain.OAuthTokenRepository;
import com.allog.dallog.externalcalendar.dto.ExternalCalendarsResponse;
import org.springframework.stereotype.Service;
@Service
public class ExternalCalendarService {
private final OAuthClient oAuthClient;
private final ExternalCalendarClient externalCalendarClient;
private final OAuthTokenRepository oAuthTokenRepository;
public ExternalCalendarService(final OAuthClient oAuthClient, final ExternalCalendarClient externalCalendarClient,
final OAuthTokenRepository oAuthTokenRepository) {
this.oAuthClient = oAuthClient;
this.externalCalendarClient = externalCalendarClient;
this.oAuthTokenRepository = oAuthTokenRepository;
}
public ExternalCalendarsResponse findByMemberId(final Long memberId) {
OAuthToken oAuthToken = oAuthTokenRepository.getByMemberId(memberId);
String oAuthAccessToken = oAuthClient.getAccessToken(oAuthToken.getRefreshToken()).getAccessToken();
return new ExternalCalendarsResponse(externalCalendarClient.getExternalCalendars(oAuthAccessToken));
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/externalcalendar/dto/ExternalCalendar.java
================================================
package com.allog.dallog.externalcalendar.dto;
public class ExternalCalendar {
private String calendarId;
private String summary;
private ExternalCalendar() {
}
public ExternalCalendar(final String calendarId, final String summary) {
this.calendarId = calendarId;
this.summary = summary;
}
public String getCalendarId() {
return calendarId;
}
public String getSummary() {
return summary;
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/externalcalendar/dto/ExternalCalendarsResponse.java
================================================
package com.allog.dallog.externalcalendar.dto;
import java.util.List;
public class ExternalCalendarsResponse {
private List externalCalendars;
private ExternalCalendarsResponse() {
}
public ExternalCalendarsResponse(final List externalCalendars) {
this.externalCalendars = externalCalendars;
}
public List getExternalCalendars() {
return externalCalendars;
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/externalcalendar/presentation/ExternalCalendarController.java
================================================
package com.allog.dallog.externalcalendar.presentation;
import com.allog.dallog.auth.dto.LoginMember;
import com.allog.dallog.auth.presentation.AuthenticationPrincipal;
import com.allog.dallog.category.dto.request.ExternalCategoryCreateRequest;
import com.allog.dallog.category.dto.response.CategoryResponse;
import com.allog.dallog.category.application.CategoryService;
import com.allog.dallog.externalcalendar.application.ExternalCalendarService;
import com.allog.dallog.externalcalendar.dto.ExternalCalendarsResponse;
import java.net.URI;
import javax.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/api/external-calendars/me")
@RestController
public class ExternalCalendarController {
private final ExternalCalendarService externalCalendarService;
private final CategoryService categoryService;
public ExternalCalendarController(final ExternalCalendarService externalCalendarService,
final CategoryService categoryService) {
this.externalCalendarService = externalCalendarService;
this.categoryService = categoryService;
}
@GetMapping
public ResponseEntity getExternalCalendar(
@AuthenticationPrincipal final LoginMember loginMember) {
return ResponseEntity.ok(externalCalendarService.findByMemberId(loginMember.getId()));
}
@PostMapping
public ResponseEntity save(@AuthenticationPrincipal final LoginMember loginMember,
@Valid @RequestBody final ExternalCategoryCreateRequest request) {
CategoryResponse response = categoryService.save(loginMember.getId(), request);
return ResponseEntity.created(URI.create("/api/categories/" + response.getId())).body(response);
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/global/config/JpaConfig.java
================================================
package com.allog.dallog.global.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@Configuration
@EnableJpaAuditing
public class JpaConfig {
}
================================================
FILE: backend/src/main/java/com/allog/dallog/global/config/PropertiesConfig.java
================================================
package com.allog.dallog.global.config;
import com.allog.dallog.global.config.properties.GoogleProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties(GoogleProperties.class)
public class PropertiesConfig {
}
================================================
FILE: backend/src/main/java/com/allog/dallog/global/config/WebConfig.java
================================================
package com.allog.dallog.global.config;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final List allowOriginUrlPatterns;
private final HandlerMethodArgumentResolver authenticationPrincipalArgumentResolver;
public WebConfig(@Value("${cors.allow-origin.urls}") final List allowOriginUrlPatterns,
final HandlerMethodArgumentResolver authenticationPrincipalArgumentResolver) {
this.allowOriginUrlPatterns = allowOriginUrlPatterns;
this.authenticationPrincipalArgumentResolver = authenticationPrincipalArgumentResolver;
}
@Override
public void addCorsMappings(CorsRegistry registry) {
String[] patterns = allowOriginUrlPatterns.stream()
.toArray(String[]::new);
registry.addMapping("/**")
.allowedMethods("*")
.allowedOriginPatterns(patterns);
}
@Override
public void addArgumentResolvers(List argumentResolvers) {
argumentResolvers.add(authenticationPrincipalArgumentResolver);
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/global/config/cache/CacheConfig.java
================================================
package com.allog.dallog.global.config.cache;
import java.util.List;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.support.SimpleCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
@Configuration
@EnableCaching
@EnableScheduling
public class CacheConfig {
public static final String GOOGLE_CALENDAR = "googleCalendar";
private static final long EXPIRE_AFTER = 60 * 60 * 3;
@Bean
public CacheManager cacheManager() {
SimpleCacheManager simpleCacheManager = new SimpleCacheManager();
simpleCacheManager.setCaches(List.of(new ExpiringConcurrentMapCache(GOOGLE_CALENDAR, EXPIRE_AFTER)));
return simpleCacheManager;
}
@Scheduled(cron = "0 0 0 * * *")
private void evict() {
ExpiringConcurrentMapCache cache = (ExpiringConcurrentMapCache) cacheManager().getCache(GOOGLE_CALENDAR);
cache.evictAllExpired();
}
}
================================================
FILE: backend/src/main/java/com/allog/dallog/global/config/cache/ExpiringConcurrentMapCache.java
================================================
package com.allog.dallog.global.config.cache;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import org.springframework.cache.concurrent.ConcurrentMapCache;
public class ExpiringConcurrentMapCache extends ConcurrentMapCache {
private final Map