Skip to content

Commit

Permalink
Merge pull request #113 from KCY-Fit-a-Pet/feat/93
Browse files Browse the repository at this point in the history
✨ 관리자 API (Push Notification 기능 미적용 버전)
  • Loading branch information
heejinnn authored Feb 17, 2024
2 parents a22ad61 + a33c493 commit 4467d86
Show file tree
Hide file tree
Showing 70 changed files with 1,180 additions and 139 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/kakao_ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ jobs:

- name: run kakao-chat at Windows
if: runner.os == 'Windows'
uses: psychology50/kakao-chat-ci@v1.6
uses: psychology50/kakao-chat-ci@v1.8
env:
KAKAO_CLIENT: ${{ secrets.KAKAO_CLIENT }}
KAKAO_EMAIL: ${{ secrets.KAKAO_EMAIL }}
Expand All @@ -90,7 +90,7 @@ jobs:

- name: run kakao-chat at macOS
if: runner.os == 'macOS'
uses: psychology50/kakao-chat-ci@v1.6
uses: psychology50/kakao-chat-ci@v1.8
env:
KAKAO_CLIENT: ${{ secrets.KAKAO_CLIENT }}
KAKAO_EMAIL: ${{ secrets.KAKAO_EMAIL }}
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@
- [Branch Convention](#branch-convention)

## Version Control
| Version # | Revision Date | Description | Author |
|:---------:|:-------------:|:--------------------|:------:|
| v0.0.1 | 2023.10.1 | 프로젝트 기본 기능 구현 및 배포 | 양재서 |
| Version # | Revision Date | Description | Author |
|:---------:|:-------------:|:-------------------|:------:|
| v0.0.1 | 2023.10.1 | 프로젝트 기본 기능 구현 및 배포 | 양재서 |
| v0.0.2 | 2024.02.17 | 멀티 모듈화 아키텍처 추가 | 양재서 |

## Dev Environment
- IntelliJ 2023.1.2
Expand Down Expand Up @@ -81,7 +82,7 @@
- [ ] 발견되는 버그와 개선사항들을 정리하고 쌓인 이슈들을 체계적으로 관리해보았다.
- [X] 코드를 지속적으로 리팩토링하고 디자인 패턴을 적용해보았다.
- [X] 위의 시도에서 더 좋은 설계와 더 빠른 개발 사이의 트레이드 오프를 고민해본 적이 있다.
- [ ] 반복되는 수정과 배포에 수반되는 작업들을 자동화 해보았다.
- [X] 반복되는 수정과 배포에 수반되는 작업들을 자동화 해보았다.
- [ ] 언어나 프레임워크만으로 구현할 수 없는 것들을 직접 구현해보았다.
- [ ] 내가 사용한 라이브러리나 프레임 워크의 한계를 느끼고 개선해보았다.
- [ ] 코드나 제품의 퀄리티를 유지하기 위한 분석툴이나 테스트 툴을 도입해보았다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
import kr.co.fitapet.api.common.util.cookie.CookieUtil;
import kr.co.fitapet.common.execption.GlobalErrorException;
import kr.co.fitapet.domain.common.redis.sms.type.SmsPrefix;
import kr.co.fitapet.domain.domains.member.domain.AccessToken;
import kr.co.fitapet.domain.common.redis.AccessToken;
import kr.co.fitapet.infra.client.sms.snes.exception.SmsErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,13 @@
import kr.co.fitapet.domain.common.redis.forbidden.ForbiddenTokenService;
import kr.co.fitapet.domain.common.redis.refresh.RefreshToken;
import kr.co.fitapet.domain.common.redis.refresh.RefreshTokenService;
import kr.co.fitapet.domain.domains.member.domain.AccessToken;
import kr.co.fitapet.domain.common.redis.AccessToken;
import org.springframework.util.StringUtils;

import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Map;

import static java.util.Calendar.ZONE_OFFSET;
import static kr.co.fitapet.api.common.security.jwt.consts.JwtType.*;

@Mapper
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import kr.co.fitapet.common.execption.BaseErrorCode;
import kr.co.fitapet.common.execption.GlobalErrorException;
import kr.co.fitapet.domain.common.redis.sms.type.SmsPrefix;
import kr.co.fitapet.domain.domains.member.domain.AccessToken;
import kr.co.fitapet.domain.common.redis.AccessToken;
import kr.co.fitapet.domain.domains.member.domain.Member;
import kr.co.fitapet.domain.domains.member.exception.AccountErrorCode;
import kr.co.fitapet.domain.domains.member.service.MemberSaveService;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import kr.co.fitapet.domain.domains.care_log.dto.CareLogInfo;
import kr.co.fitapet.domain.domains.care_log.service.CareLogSearchService;
import kr.co.fitapet.domain.domains.care_log.service.CareLogUpdateService;
import kr.co.fitapet.domain.domains.manager.service.ManagerSearchService;
import kr.co.fitapet.domain.domains.member.service.MemberSearchService;
import kr.co.fitapet.domain.domains.pet.domain.Pet;
import kr.co.fitapet.domain.domains.pet.exception.PetErrorCode;
Expand All @@ -36,6 +37,7 @@ public class CareUseCase {
private final CareUpdateService careUpdateService;

private final MemberSearchService memberSearchService;
private final ManagerSearchService managerSearchService;
private final PetSearchService petSearchService;
private final CareSearchService careSearchService;
private final CareLogSearchService careLogSearchService;
Expand Down Expand Up @@ -105,7 +107,7 @@ public void saveCare(Long userId, Long petId, CareSaveReq.Request request) {
List<CareSaveReq.AdditionalPetDto> additionalPetDtos = request.pets();

List<Long> petIds = additionalPetDtos.stream().map(CareSaveReq.AdditionalPetDto::petId).toList();
if (!memberSearchService.isManagerAll(userId, petIds)) {
if (!managerSearchService.isManagerAll(userId, petIds)) {
throw new GlobalErrorException(PetErrorCode.NOT_MANAGER_PET);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package kr.co.fitapet.api.apis.manager.controller;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import kr.co.fitapet.api.apis.manager.dto.InviteMemberReq;
import kr.co.fitapet.api.apis.manager.usecase.ManagerUseCase;
import kr.co.fitapet.api.common.response.SuccessResponse;
import kr.co.fitapet.api.common.security.authentication.CustomUserDetails;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

@Tag(name = "매니저 API", description = "반려동물 관리자 및 어드민의 기능을 제공하는 API")
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v2/pets/{pet_id}/managers")
public class MangerApi {
private final ManagerUseCase managerUseCase;

@Operation(summary = "매니저 목록 조회")
@Parameter(name = "pet_id", description = "반려동물 ID", in = ParameterIn.PATH, required = true)
@GetMapping("")
@PreAuthorize("isAuthenticated() and @managerAuthorize.isManager(principal.userId, #petId)")
public ResponseEntity<?> getManagers(@PathVariable("pet_id") Long petId, @AuthenticationPrincipal CustomUserDetails userDetails) {
return ResponseEntity.ok(SuccessResponse.from("managers", managerUseCase.findManagers(petId, userDetails.getUserId())));
}

@Operation(summary = "반려동물 관리자 초대 리스트 조회")
@Parameter(name = "pet_id", description = "반려동물 ID", in = ParameterIn.PATH, required = true)
@GetMapping("/invite")
@PreAuthorize("isAuthenticated() and @managerAuthorize.isManager(principal.userId, #petId)")
public ResponseEntity<?> getInvitedMembers(@PathVariable("pet_id") Long petId, @AuthenticationPrincipal CustomUserDetails userDetails) {
return ResponseEntity.ok(SuccessResponse.from("members", managerUseCase.findInvitedMembers(petId, userDetails.getUserId())));
}

// TODO: 2024-02-17 초대 요청 시 해당 유저에게 PUSH 알림을 전송해야 함
@Operation(summary = "매니저 초대", description = "요청자와 유저 아이디가 동일한 경우 에러 응답을 반환합니다. 초대 요청에 대한 승인 유효 기간은 1일입니다.")
@Parameter(name = "pet_id", description = "반려동물 ID", in = ParameterIn.PATH, required = true)
@PostMapping("/invite")
@PreAuthorize("isAuthenticated() and not #req.inviteId().equals(principal.userId) and @managerAuthorize.isManager(principal.userId, #petId)")
public ResponseEntity<?> inviteManager(@PathVariable("pet_id") Long petId, @RequestBody @Valid InviteMemberReq req, @AuthenticationPrincipal CustomUserDetails principal)
{
managerUseCase.invite(petId, req.inviteId());
return ResponseEntity.ok(SuccessResponse.noContent());
}

@Operation(summary = "매니저 초대 승인", description = "매니저 초대 요청에 대한 승인을 진행합니다. 1일 이내에 승인하지 않으면 초대 요청이 만료됩니다.")
@Parameter(name = "pet_id", description = "반려동물 ID", in = ParameterIn.PATH, required = true)
@PutMapping("/invite")
@PreAuthorize("isAuthenticated() and @managerAuthorize.isInvitedMember(principal.userId, #petId)")
public ResponseEntity<?> agreeInvite(@PathVariable("pet_id") Long petId, @AuthenticationPrincipal CustomUserDetails userDetails) {
managerUseCase.agreeInvite(petId, userDetails.getUserId());
return ResponseEntity.ok(SuccessResponse.noContent());
}

@Operation(summary = "매니저 초대 취소/거절", description = "매니저 초대 요청에 대한 취소 또는 거절을 진행합니다.")
@Parameters({
@Parameter(name = "pet_id", description = "반려동물 ID", in = ParameterIn.PATH, required = true),
@Parameter(name = "id", description = "초대를 취소/거부할 유저 ID", in = ParameterIn.QUERY, required = true)
})
@DeleteMapping("/invite")
@PreAuthorize("isAuthenticated() and (@managerAuthorize.isManager(principal.userId, #petId) or @managerAuthorize.isInvitedMember(principal.userId, #petId))")
public ResponseEntity<?> cancelInvite(@PathVariable("pet_id") Long petId, @RequestParam("id") Long invitedId) {
managerUseCase.cancelInvite(petId, invitedId);
return ResponseEntity.ok(SuccessResponse.noContent());
}

@Operation(summary = "마스터 위임", description = "마스터 권한을 다른 매니저에게 위임합니다.")
@Parameters({
@Parameter(name = "pet_id", description = "반려동물 ID", in = ParameterIn.PATH, required = true),
@Parameter(name = "manager_id", description = "위임할 매니저 ID", in = ParameterIn.PATH, required = true)
})
@PatchMapping("/{manager_id}")
@PreAuthorize("isAuthenticated() and @managerAuthorize.isMaster(principal.userId, #petId) and @managerAuthorize.isManager(#managerId, #petId)")
public ResponseEntity<?> delegateMaster(
@PathVariable("pet_id") Long petId,
@PathVariable("manager_id") Long managerId,
@AuthenticationPrincipal CustomUserDetails userDetails
) {
managerUseCase.delegateMaster(userDetails.getUserId(), managerId, petId);
return ResponseEntity.ok(SuccessResponse.noContent());
}

@Operation(summary = "매니저 탈퇴/추방", description = """
매니저가 반려동물을 탈퇴하거나 마스터가 추방하는 기능을 제공합니다. <br>
매니저가 반려동물을 탈퇴하면 해당 반려동물의 매니저 목록에서 제거됩니다. <br>
요청자와 매니저가 동일하지 않은 경우, 마스터 권한이 있는 경우에만 요청이 성공합니다. <br>
요청자와 매니저가 동일하지만 요청자가 마스터인 경우 요청이 실패합니다. (마스터가 탈퇴하려면 반드시 다른 관리자에게 위임하거나, 반려동물 삭제 API를 사용해야 합니다.)
""")
@Parameters({
@Parameter(name = "pet_id", description = "반려동물 ID", in = ParameterIn.PATH, required = true),
@Parameter(name = "manager_id", description = "매니저 ID", in = ParameterIn.PATH, required = true)
})
@DeleteMapping("/{manager_id}")
@PreAuthorize("isAuthenticated() and @managerAuthorize.isManager(#managerId, #petId) " +
"and ((@managerAuthorize.isMaster(principal.userId, #petId) and not #managerId.equals(principal.userId)) " +
"or (not @managerAuthorize.isMaster(principal.userId, #petId) and @managerAuthorize.isManager(principal.userId, #petId) and #managerId.equals(principal.userId)))")
public ResponseEntity<?> deleteManager(
@PathVariable("pet_id") Long petId,
@PathVariable("manager_id") Long managerId,
@AuthenticationPrincipal CustomUserDetails userDetails
) {
managerUseCase.expelManager(managerId, petId);
return ResponseEntity.ok(SuccessResponse.noContent());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package kr.co.fitapet.api.apis.manager.dto;

import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import io.swagger.v3.oas.annotations.media.Schema;
import kr.co.fitapet.domain.domains.member.dto.MemberInfo;
import lombok.Builder;

import java.time.LocalDateTime;

@Builder
@Schema(description = "매니저 초대 정보 응답")
public record InviteMemberInfoRes(
@Schema(description = "유저 ID", example = "1")
Long id,
@Schema(description = "유저 UID", example = "fitapet")
String uid,
@Schema(description = "유저 이름", example = "피타펫")
String name,
@Schema(description = "유저 프로필 이미지 URL", example = "https://fitapet.co.kr/fitapet.png")
String profileImageUrl,
@Schema(description = "초대 일시", example = "2021-08-01 00:00:00")
@JsonSerialize(using = LocalDateTimeSerializer.class)
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
LocalDateTime invitedAt,
@Schema(description = "초대 만료 여부", example = "false")
Boolean expired
) {
public static InviteMemberInfoRes valueOf(MemberInfo member, LocalDateTime invitedAt, Boolean expired) {
return InviteMemberInfoRes.builder()
.id(member.id())
.uid(member.uid())
.name(member.name())
.profileImageUrl(member.profileImageUrl())
.invitedAt(invitedAt)
.expired(expired)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package kr.co.fitapet.api.apis.manager.dto;

import jakarta.validation.constraints.NotNull;

public record InviteMemberReq(
@NotNull
Long inviteId
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package kr.co.fitapet.api.apis.manager.mapper;

import kr.co.fitapet.api.apis.manager.dto.InviteMemberInfoRes;
import kr.co.fitapet.common.annotation.Mapper;
import kr.co.fitapet.domain.common.redis.manager.InvitationDto;
import kr.co.fitapet.domain.common.redis.manager.ManagerInvitationService;
import kr.co.fitapet.domain.domains.manager.service.ManagerSaveService;
import kr.co.fitapet.domain.domains.manager.service.ManagerSearchService;
import kr.co.fitapet.domain.domains.manager.type.ManageType;
import kr.co.fitapet.domain.domains.member.domain.Member;
import kr.co.fitapet.domain.domains.member.dto.MemberInfo;
import kr.co.fitapet.domain.domains.member.exception.AccountErrorCode;
import kr.co.fitapet.domain.domains.member.exception.AccountErrorException;
import kr.co.fitapet.domain.domains.member.service.MemberSearchService;
import kr.co.fitapet.domain.domains.pet.domain.Pet;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Slf4j
@Mapper
@RequiredArgsConstructor
public class ManagerInvitationMapper {
private final MemberSearchService memberSearchService;
private final ManagerSearchService managerSearchService;
private final ManagerSaveService managerSaveService;
private final ManagerInvitationService managerInvitationService;

@Transactional(readOnly = true)
public void invite(Long petId, Long invitedId) {
if (!memberSearchService.isExistById(invitedId))
throw new AccountErrorException(AccountErrorCode.NOT_FOUND_MEMBER_ERROR);
if (managerSearchService.isManager(invitedId, petId))
throw new AccountErrorException(AccountErrorCode.ALREADY_MANAGER_ERROR);
managerInvitationService.save(invitedId, petId);
}

@Transactional(readOnly = true)
public List<InviteMemberInfoRes> findInvitedMembers(Long petId, Long requesterId) {
List<InvitationDto> invitationDtos = managerInvitationService.findAll(petId);
List<MemberInfo> members = memberSearchService.findMemberInfos(invitationDtos.stream().map(InvitationDto::inviteId).toList(), requesterId);
return members.stream().map(member -> {
InvitationDto invitationDto = invitationDtos.stream().filter(dto -> dto.inviteId().equals(member.id())).findFirst().orElseThrow();
return InviteMemberInfoRes.valueOf(member, invitationDto.ttl(), invitationDto.expired());
}).toList();
}

@Transactional
public void addManager(Long memberId, Pet pet) {
Member member = memberSearchService.findById(memberId);
managerSaveService.mappingMemberAndPet(member, pet, ManageType.MANAGER);
managerInvitationService.delete(memberId, pet.getId());
}

public void cancel(Long petId, Long memberId) {
managerInvitationService.delete(memberId, petId);
}
}
Loading

0 comments on commit 4467d86

Please sign in to comment.