Skip to content

Commit

Permalink
Hang-Log Prod 1.1.0 Release (#607)
Browse files Browse the repository at this point in the history
* city 패키지 위치 변경 (#527)

* ExpensePage 리팩토링 (#533)

* refactor: 상수화

* refactor: ExpensePage 관련 코드 리팩토링

* refactor: 오타 수정

* refactor: createTrip 테스트 코드 수정

* TripPage, TripEditPage 리팩토링 (#537)

* fix: TripEditPage 데이터 패칭 waterfall 문제 해결

* refactor: useQuery 옵션 defaultOptions로 옮기기

* fix: Header cursor pointer 문제 해결

* refactor: Footer 수정

* refactor: 회원가입, 로그인 버튼 하나로 합치기

* refactor: props로 넘겨주는 대신 TripInformation에서 직접 데이터 가져오기

* refactor: TripInformation 수정 버튼 네이밍 수정에서 완료로 변경

* refactor: TripItem 제목 스타일링 수정

* refactor: 모바일일때 image carousel navigation 보이게 변경

* refactor: 수정, 삭제 버튼 더보기 메뉴에서 보여주는 대신 아이콘으로 변경

* refactor: 기타 탭에서 아이템 생성 시 디폴트 카테고리 기타로 설정

* refactor: 이미지 용량 10mb 이상으로 인한 에러 발생 시 상황에 맞는 에러 메세지 보여주기

* refactor: 아이템 추가할 때 제목에 공백 입력 후 아이템 추가 시 에러 메세지 보여주기

* refactor: TripItemList 컴포넌트 수정

* fix: 테스트 코드 수정

* 삭제 컨펌 경고 모달 추가 (#540)

* refactor: 여행 삭제 시 삭제 컨펌 모달 보여주기

* refactor: 여행 아이템 삭제 시 삭제 컨펌 모달 보여주기

* MyPage 리팩토링 (#541)

* refactor: 탈퇴 버튼 스타일링 수정

* refactor: 계정 탈퇴 클릭 시 컨펌 모달 보여주기

* 금액순으로 카테고리 정렬 구현 (#531)

* feat: 금액순으로 카테고리 정렬 구현

* style : 코드 컨밴션 적용

* 번들 사이즈 최적화 (#546)

* chore: webpack bundle analyzer 설치

* chore: compress webpack plugin 설치

* refactor: 이미지 파일 최적화

* refactor: tree shaking 추가

* refactor: tsconfig.json module commonjs에서 esnext로 변경

* refactor: lazy loading을 활용한 코드 스플리팅

* refactor: IntroPage 이미지 png에서 jpg으로 변경

* refactor: 탈퇴 버튼 수정

* fix: npm run build 시 오래 걸리는 문제 해결 (#549)

* 모바일 환경에서 StarRatingInput 호버 문제 수정 (#550)

* refactor: 디자인시스템 1.3.0 버전업

* refactor: 모바일 환경에서 StarRatingInput 호버 문제 수정

* FLYWAY 적용 (#552)

* chore: flyway 적용

* chore: submodule 변경 적용

* chore: submodule 변경 적용

* 모바일 버전에서 지도 볼 수 있는 기능 (#544)

* feat: 모바일에서 지도보기 버튼 추가, 지도 보는 기능

* refactor: 트립페이지 모바일 버전 컴포넌트 분리

* feat: 모바일에서 지도볼때도 daylog tab보이도록 수정

* refactor: 버튼 스타일 수정, 패딩 수정

* chore: 리베이스로 인한 버그 수정

* 비공유 상태일 시 code null값 반영 및 sharedTrip soft delete 추가 (#530)

* feat: soft delete 추가

* refactor: 메서드 반환 타입 변경

* style : 코드 컨밴션 적용

* 토글버튼 부활, UI관련 수정  (#557)

* feat:데이로그 아이템 토글 기능 부활

* feat:이미지 클릭시 확대 모달창 여는 기능

* refactor: 지도 로딩시 스피너 중앙에 위치

* refactor: 이미지 사이즈 조정 로직 훅으로 분리

* refactor: 제목길이 25로 늘림

* refactor: api통신 횟수 조정, queryClient 함수 분리

* refactor: 여행설명, 아이템메모 사용자입력 줄바꿈 적용

* sharedtrip 변경사항 flyway script 작성 (#556)

* feat: sharedtrip 변경사항 flyway script 작성

* feat: sharedtrip 변경사항 flyway script 작성

* refactor: 스크립트 명 변경

* TripCity 삭제 로직 추가 (#529)

* refactor: 테스트코드 패키지 변경 (#563)

* webpack 최적화 및 common, prod, dev 파일 분리 (#560)

* refactor: webpack 최적화, dev/prod파일 분리

* refactor: webpack serve, build script수정

* refactor: webpack파일 분리로 인한 cypress 자동화 yml 수정

* 접속자별 접근 권한 검증 구현 (#571)

* feat: AuthArgumentResolver 로직 추가

Co-authored-by: mcodnjs <[email protected]>

* feat: MemberOnly 어노테이션 구현

Co-authored-by: mcodnjs <[email protected]>

* feat: MemberOnly 어노테이션 적용

Co-authored-by: mcodnjs <[email protected]>

* fix: cookie null 예외처리 추가

Co-authored-by: mcodnjs <[email protected]>

---------

Co-authored-by: mcodnjs <[email protected]>

* 닉네임 랜덤 숫자 추가 및 수정 시 닉네임 중복 체크 (#565)

* feat: member의 nickname에 unique 제약조건 추가

* feat: 회원가입 시 닉네임 랜덤넘버 추가

* feat: 닉네임 수정 시 중복 체크 로직 추가

* refactor: 랜덤 생성 로직 수정 및 상수화

* Trip, Like, PublishedTrip 테이블 생성 (#569)

* feat: like 엔티티 구현

* feat: publishedTrip 엔티티 구현

* feat: flyway 마이그레이션 스크립트 작성

* feat: 기본 생성시 unpublished 추가

* style: 코드 컨벤션 적용

* style: 코드 컨벤션 적용

* refactor: flyway 스크립트 버전 수정

* 이미지 압축 (#570)

* chore: browser image compression 설치

* feat: 이미지 최적화 추가

* refactor: preview 이미지 보여주는 방법 수정

* 마이페이지 수정 시 닉네임 변경되지 않은 경우 예외 발생 버그 해결 (#576)

* fix: 닉네임 변경 시에만 중복 닉네임 검사 로직 추가

* refactor: 멤버 수정 날짜 업데이트 로직 추가

* 여행 아이템 사진 확대해서 볼 수 있는 기능 추가 (#580)

* chore: 행록 디자인 시스템 버전 업데이트

* feat: 이미지 확대 모달 구현

* 성능 최적화 - 사용자 이미지 아이콘 width, height 추가 (#586)

* fix: 사용자 이미지 아이콘 width, height 추가

* chore: compression 관련 코드 삭제

* 경비 공유 페이지 생성 (#559)

* feat: 공유된 페이지에 가계부 버튼 노출

* fix: 메모, 상세설명 줄바꿈 되도록 수정

* feat: 가계부 공부 페이지 api 구현

* feat: 공유 가계부에서 공유 트립으로 이동

* refactor:버그 수정, 여행추가 버튼 위치 변경

* refactor:지도 확대 가능, 지도 클릭 불가능 기능

* feat:공유페이지 모바일 버전 적용

* refactor: 파일 명 변경

* refactor: 로그인시 메인이 아닌 전 페이지로 이동

* fix: 비로그인 시 공유경비 페이지 못보는 오류 해결

* refactor:디자인 변경, 변수명 리네임

* refactor: 여행정보사진수정 커스텀 훅 multi->single로 변경

* refactor:버그로 인한 이미지 업로드 롤백

* fix:CF배포로 인한 api 주소 변경 (#588)

* fix:dev서버에서 백서버로 api요청 안가는 오류 해결 ->axios base url환경변수 변경 (#594)

* 공유된 여행 가계부 조회 기능 추가 (#595)

* refactor: sharedCode index 설정 추가 (#591)

Co-authored-by: hgo641 <[email protected]>
Co-authored-by: jjongwa <[email protected]>

* feat: cache-control 메타데이터 추가 (#597)

* refactor: cache-control 메타데이터 값 변경 (#600)

* cors origin과 method 추가 (#603)

* RefreshToken memberId Unique 해제 (#606)

---------

Co-authored-by: Ashley Heo <[email protected]>
Co-authored-by: waterricecake <[email protected]>
Co-authored-by: 임우찬 <[email protected]>
Co-authored-by: 이지우 <[email protected]>
Co-authored-by: Dahye Yun <[email protected]>
Co-authored-by: mcodnjs <[email protected]>
Co-authored-by: Chaewon Moon <[email protected]>
Co-authored-by: hgo641 <[email protected]>
  • Loading branch information
9 people authored Sep 20, 2023
1 parent 349cd86 commit 300a1ba
Show file tree
Hide file tree
Showing 183 changed files with 2,563 additions and 823 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/frontend-e2e-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ jobs:
uses: cypress-io/github-action@v5
with:
browser: chrome
start: npm run dev
wait-on: 'http://localhost:3000'
start: npm run serve:dev
wait-on: "http://localhost:3000"
record: false
working-directory: ./frontend
env:
Expand Down
2 changes: 1 addition & 1 deletion backend/backend-submodule
4 changes: 4 additions & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ dependencies {

implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-prometheus'


implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-mysql'
}

test {
Expand Down
10 changes: 10 additions & 0 deletions backend/src/docs/asciidoc/docs.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -298,3 +298,13 @@ include::{snippets}/shared-trip-controller-test/update-shared-status/path-parame
==== 응답
include::{snippets}/shared-trip-controller-test/update-shared-status/http-response.adoc[]
include::{snippets}/shared-trip-controller-test/update-shared-status/path-parameters.adoc[]

=== 공유된 여행 경비 조회 (GET /shared-trips/:shareCode/expense)

==== 요청
include::{snippets}/shared-trip-controller-test/get-shared-expenses/http-request.adoc[]
include::{snippets}/shared-trip-controller-test/get-shared-expenses/path-parameters.adoc[]

==== 응답
include::{snippets}/shared-trip-controller-test/get-shared-expenses/http-response.adoc[]
include::{snippets}/shared-trip-controller-test/get-shared-expenses/response-fields.adoc[]
2 changes: 0 additions & 2 deletions backend/src/main/java/hanglog/auth/Auth.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,4 @@
@Target(PARAMETER)
@Retention(RUNTIME)
public @interface Auth {

boolean required() default true;
}
49 changes: 39 additions & 10 deletions backend/src/main/java/hanglog/auth/AuthArgumentResolver.java
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
package hanglog.auth;

import static hanglog.global.exception.ExceptionCode.NULL_REFRESH_TOKEN;
import static hanglog.global.exception.ExceptionCode.INVALID_REQUEST;
import static hanglog.global.exception.ExceptionCode.NOT_FOUND_REFRESH_TOKEN;
import static org.springframework.http.HttpHeaders.AUTHORIZATION;

import hanglog.auth.domain.Accessor;
import hanglog.auth.domain.BearerAuthorizationExtractor;
import hanglog.auth.domain.JwtProvider;
import hanglog.auth.domain.MemberTokens;
import hanglog.global.exception.AuthException;
import hanglog.auth.domain.repository.RefreshTokenRepository;
import hanglog.global.exception.BadRequestException;
import hanglog.global.exception.RefreshTokenException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Arrays;
import lombok.RequiredArgsConstructor;
Expand All @@ -27,28 +32,52 @@ public class AuthArgumentResolver implements HandlerMethodArgumentResolver {

private final BearerAuthorizationExtractor extractor;

private final RefreshTokenRepository refreshTokenRepository;

@Override
public boolean supportsParameter(final MethodParameter parameter) {
return parameter.withContainingClass(Long.class)
.hasParameterAnnotation(Auth.class);
}

@Override
public Long resolveArgument(
public Accessor resolveArgument(
final MethodParameter parameter,
final ModelAndViewContainer mavContainer,
final NativeWebRequest webRequest,
final WebDataBinderFactory binderFactory
) throws Exception {
) {
final HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
final String refreshToken = Arrays.stream(request.getCookies())
.filter(cookie -> REFRESH_TOKEN.equals(cookie.getName()))
if (request == null) {
throw new BadRequestException(INVALID_REQUEST);
}

try {
final String refreshToken = extractRefreshToken(request.getCookies());
final String accessToken = extractor.extractAccessToken(webRequest.getHeader(AUTHORIZATION));
jwtProvider.validateTokens(new MemberTokens(refreshToken, accessToken));

final Long memberId = Long.valueOf(jwtProvider.getSubject(accessToken));
return Accessor.member(memberId);
} catch (final RefreshTokenException e) {
return Accessor.guest();
}
}

private String extractRefreshToken(final Cookie... cookies) {
if (cookies == null) {
throw new RefreshTokenException(NOT_FOUND_REFRESH_TOKEN);
}
return Arrays.stream(cookies)
.filter(this::isValidRefreshToken)
.findFirst()
.orElseThrow(() -> new AuthException(NULL_REFRESH_TOKEN))
.orElseThrow(() -> new RefreshTokenException(NOT_FOUND_REFRESH_TOKEN))
.getValue();
}

final String accessToken = extractor.extractAccessToken(webRequest.getHeader(AUTHORIZATION));
jwtProvider.validateTokens(new MemberTokens(refreshToken, accessToken));
return Long.valueOf(jwtProvider.getSubject(accessToken));
private boolean isValidRefreshToken(final Cookie cookie) {
// TODO: refreshToken 만료 기한 검사 필요
return REFRESH_TOKEN.equals(cookie.getName()) &&
refreshTokenRepository.existsByToken(cookie.getValue());
}
}
12 changes: 12 additions & 0 deletions backend/src/main/java/hanglog/auth/MemberOnly.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package hanglog.auth;

import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

import java.lang.annotation.Retention;
import java.lang.annotation.Target;

@Target(METHOD)
@Retention(RUNTIME)
public @interface MemberOnly {
}
26 changes: 26 additions & 0 deletions backend/src/main/java/hanglog/auth/MemberOnlyChecker.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package hanglog.auth;

import static hanglog.global.exception.ExceptionCode.INVALID_AUTHORITY;

import hanglog.auth.domain.Accessor;
import hanglog.global.exception.AuthException;
import java.util.Arrays;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class MemberOnlyChecker {

@Before("@annotation(hanglog.auth.MemberOnly)")
public void check(final JoinPoint joinPoint) {
Arrays.stream(joinPoint.getArgs())
.filter(Accessor.class::isInstance)
.map(Accessor.class::cast)
.filter(Accessor::isMember)
.findFirst()
.orElseThrow(() -> new AuthException(INVALID_AUTHORITY));
}
}
29 changes: 29 additions & 0 deletions backend/src/main/java/hanglog/auth/domain/Accessor.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package hanglog.auth.domain;

import static hanglog.auth.domain.Authority.MEMBER;

import lombok.Getter;

@Getter
public class Accessor {

private final Long memberId;
private final Authority authority;

private Accessor(final Long memberId, final Authority authority) {
this.memberId = memberId;
this.authority = authority;
}

public static Accessor guest() {
return new Accessor(0L, Authority.GUEST);
}

public static Accessor member(final Long memberId) {
return new Accessor(memberId, MEMBER);
}

public boolean isMember() {
return MEMBER.equals(authority);
}
}
5 changes: 5 additions & 0 deletions backend/src/main/java/hanglog/auth/domain/Authority.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package hanglog.auth.domain;

public enum Authority {
GUEST, MEMBER
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public class RefreshToken {
@Id
private String token;

@Column(nullable = false, unique = true)
@Column(nullable = false)
private Long memberId;

public RefreshToken(final String token, final Long memberId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {

Optional<RefreshToken> findByToken(final String token);

boolean existsByToken(final String token);

void deleteByMemberId(final Long memberId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import static org.springframework.http.HttpStatus.CREATED;

import hanglog.auth.Auth;
import hanglog.auth.MemberOnly;
import hanglog.auth.domain.Accessor;
import hanglog.auth.domain.MemberTokens;
import hanglog.auth.dto.AccessTokenResponse;
import hanglog.auth.dto.LoginRequest;
Expand Down Expand Up @@ -56,14 +58,16 @@ public ResponseEntity<AccessTokenResponse> extendLogin(
}

@DeleteMapping("/logout")
public ResponseEntity<Void> logout(@Auth final Long memberId) {
authService.removeMemberRefreshToken(memberId);
@MemberOnly
public ResponseEntity<Void> logout(@Auth final Accessor accessor) {
authService.removeMemberRefreshToken(accessor.getMemberId());
return ResponseEntity.noContent().build();
}

@DeleteMapping("/account")
public ResponseEntity<Void> deleteAccount(@Auth final Long memberId) {
authService.deleteAccount(memberId);
@MemberOnly
public ResponseEntity<Void> deleteAccount(@Auth final Accessor accessor) {
authService.deleteAccount(accessor.getMemberId());
return ResponseEntity.noContent().build();
}
}
27 changes: 23 additions & 4 deletions backend/src/main/java/hanglog/auth/service/AuthService.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
package hanglog.auth.service;

import static hanglog.global.exception.ExceptionCode.FAIL_TO_VALIDATE_TOKEN;
import static hanglog.global.exception.ExceptionCode.INVALID_REFRESH_TOKEN;

import hanglog.auth.domain.BearerAuthorizationExtractor;
import hanglog.auth.domain.JwtProvider;
import hanglog.auth.domain.MemberTokens;
Expand All @@ -19,11 +16,16 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import static hanglog.global.exception.ExceptionCode.*;

@Service
@Transactional
@RequiredArgsConstructor
public class AuthService {

private static final int MAX_TRY_COUNT = 5;
private static final int FOUR_DIGIT_RANGE = 10000;

private final MemberRepository memberRepository;
private final OauthProviders oauthProviders;
private final RefreshTokenRepository refreshTokenRepository;
Expand All @@ -47,7 +49,24 @@ public MemberTokens login(final String providerName, final String code) {

private Member findOrCreateMember(final String socialLoginId, final String nickname, final String imageUrl) {
return memberRepository.findBySocialLoginId(socialLoginId)
.orElseGet(() -> memberRepository.save(new Member(socialLoginId, nickname, imageUrl)));
.orElseGet(() -> createMember(socialLoginId, nickname, imageUrl));
}

private Member createMember(final String socialLoginId, final String nickname, final String imageUrl) {
int tryCount = 0;
while (tryCount < MAX_TRY_COUNT) {
final String nicknameWithRandomNumber = nickname + generateRandomFourDigitCode();
if (!memberRepository.existsByNickname(nicknameWithRandomNumber)) {
return memberRepository.save(new Member(socialLoginId, nicknameWithRandomNumber, imageUrl));
}
tryCount += 1;
}
throw new AuthException(FAIL_TO_GENERATE_RANDOM_NICKNAME);
}

public String generateRandomFourDigitCode() {
final int randomNumber = (int) (Math.random() * FOUR_DIGIT_RANGE);
return String.format("%04d", randomNumber);
}

public String renewalAccessToken(final String refreshTokenRequest, final String authorizationHeader) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package hanglog.trip.domain;
package hanglog.city.domain;

import static jakarta.persistence.GenerationType.IDENTITY;
import static lombok.AccessLevel.PROTECTED;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package hanglog.trip.domain.repository;
package hanglog.city.domain.repository;

import hanglog.trip.domain.City;
import hanglog.city.domain.City;
import org.springframework.data.jpa.repository.JpaRepository;

public interface CityRepository extends JpaRepository<City, Long> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import static lombok.AccessLevel.PRIVATE;

import hanglog.trip.domain.City;
import hanglog.city.domain.City;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package hanglog.trip.dto.response;
package hanglog.city.dto.response;

import static lombok.AccessLevel.PRIVATE;

import hanglog.trip.domain.City;
import hanglog.city.domain.City;
import java.math.BigDecimal;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package hanglog.trip.presentation;
package hanglog.city.presentation;

import hanglog.city.dto.response.CityResponse;
import hanglog.trip.service.CityService;
import hanglog.city.service.CityService;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package hanglog.trip.service;
package hanglog.city.service;

import hanglog.city.dto.response.CityResponse;
import hanglog.trip.domain.City;
import hanglog.trip.domain.repository.CityRepository;
import hanglog.city.domain.City;
import hanglog.city.domain.repository.CityRepository;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package hanglog.expense.presentation;

import hanglog.auth.Auth;
import hanglog.auth.MemberOnly;
import hanglog.auth.domain.Accessor;
import hanglog.expense.dto.response.TripExpenseResponse;
import hanglog.expense.service.ExpenseService;
import hanglog.trip.service.TripService;
Expand All @@ -20,8 +22,12 @@ public class ExpenseController {
private final TripService tripService;

@GetMapping
public ResponseEntity<TripExpenseResponse> getExpenses(@Auth final Long memberId, @PathVariable final Long tripId) {
tripService.validateTripByMember(memberId, tripId);
@MemberOnly
public ResponseEntity<TripExpenseResponse> getExpenses(
@Auth final Accessor accessor,
@PathVariable final Long tripId
) {
tripService.validateTripByMember(accessor.getMemberId(), tripId);
final TripExpenseResponse tripExpenseResponse = expenseService.getAllExpenses(tripId);
return ResponseEntity.ok().body(tripExpenseResponse);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ public TripExpenseResponse getAllExpenses(final Long tripId) {

final List<CategoryExpense> categoryExpenses = categoryAmounts.entrySet().stream()
.map(entry -> new CategoryExpense(entry.getKey(), entry.getValue(), totalAmount))
.sorted((o1,o2) -> o2.getAmount().compareTo(o1.getAmount()))
.toList();

final List<DayLogExpense> dayLogExpenses = dayLogAmounts.entrySet().stream()
Expand Down
Loading

0 comments on commit 300a1ba

Please sign in to comment.