Skip to content

Commit aabb5b5

Browse files
authored
[TNT-223] feat: 트레이너 - PT 수업 추가 기능 구현 (#51)
* [TNT-223] feat: 트레이너 PT 수업 추가, 관리중인 회원 목록 요청 API(임시) 구현 * [TNT-223] refactor: 에러 관련 수정 * [TNT-223] test: 테스트 코드 작성
1 parent 1287413 commit aabb5b5

18 files changed

+406
-34
lines changed

src/main/java/com/tnt/application/pt/PtService.java

+44
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package com.tnt.application.pt;
22

3+
import static com.tnt.common.error.model.ErrorMessage.PT_LESSON_DUPLICATE_TIME;
34
import static com.tnt.common.error.model.ErrorMessage.PT_TRAINEE_ALREADY_EXIST;
45
import static com.tnt.common.error.model.ErrorMessage.PT_TRAINER_TRAINEE_ALREADY_EXIST;
56
import static com.tnt.common.error.model.ErrorMessage.PT_TRAINER_TRAINEE_NOT_FOUND;
67

78
import java.time.LocalDate;
9+
import java.time.LocalDateTime;
810
import java.util.LinkedHashMap;
911
import java.util.List;
1012
import java.util.stream.Collectors;
@@ -25,14 +27,18 @@
2527
import com.tnt.domain.trainer.Trainer;
2628
import com.tnt.dto.trainer.ConnectWithTrainerDto;
2729
import com.tnt.dto.trainer.request.ConnectWithTrainerRequest;
30+
import com.tnt.dto.trainer.request.CreatePtLessonRequest;
2831
import com.tnt.dto.trainer.response.ConnectWithTraineeResponse;
32+
import com.tnt.dto.trainer.response.GetActiveTraineesResponse;
33+
import com.tnt.dto.trainer.response.GetActiveTraineesResponse.TraineeDto;
2934
import com.tnt.dto.trainer.response.GetCalendarPtLessonCountResponse;
3035
import com.tnt.dto.trainer.response.GetCalendarPtLessonCountResponse.CalendarPtLessonCount;
3136
import com.tnt.dto.trainer.response.GetPtLessonsOnDateResponse;
3237
import com.tnt.dto.trainer.response.GetPtLessonsOnDateResponse.Lesson;
3338
import com.tnt.infrastructure.mysql.repository.pt.PtLessonRepository;
3439
import com.tnt.infrastructure.mysql.repository.pt.PtLessonSearchRepository;
3540
import com.tnt.infrastructure.mysql.repository.pt.PtTrainerTraineeRepository;
41+
import com.tnt.infrastructure.mysql.repository.pt.PtTrainerTraineeSearchRepository;
3642

3743
import lombok.RequiredArgsConstructor;
3844

@@ -46,6 +52,7 @@ public class PtService {
4652
private final PtTrainerTraineeRepository ptTrainerTraineeRepository;
4753
private final PtLessonRepository ptLessonRepository;
4854
private final PtLessonSearchRepository ptLessonSearchRepository;
55+
private final PtTrainerTraineeSearchRepository ptTrainerTraineeSearchRepository;
4956

5057
@Transactional
5158
public ConnectWithTrainerDto connectWithTrainer(Long memberId, ConnectWithTrainerRequest request) {
@@ -128,6 +135,37 @@ public GetCalendarPtLessonCountResponse getCalendarPtLessonCount(Long memberId,
128135
return new GetCalendarPtLessonCountResponse(counts);
129136
}
130137

138+
@Transactional(readOnly = true)
139+
public GetActiveTraineesResponse getActiveTrainees(Long memberId) {
140+
Trainer trainer = trainerService.getTrainerWithMemberId(memberId);
141+
142+
List<Trainee> trainees = ptTrainerTraineeSearchRepository.findAllTrainees(trainer.getId());
143+
List<TraineeDto> traineeDto = trainees.stream()
144+
.map(trainee -> new TraineeDto(trainee.getId(), trainee.getMember().getName()))
145+
.toList();
146+
147+
return new GetActiveTraineesResponse(traineeDto);
148+
}
149+
150+
@Transactional
151+
public void addPtLesson(Long memberId, CreatePtLessonRequest request) {
152+
trainerService.validateTrainerRegistration(memberId);
153+
154+
PtTrainerTrainee ptTrainerTrainee = getPtTrainerTraineeWithTraineeId(request.traineeId());
155+
156+
// 트레이너의 기존 pt 수업중에 중복되는 시간대가 있는지 확인
157+
validateLessonTime(ptTrainerTrainee, request.start(), request.end());
158+
159+
PtLesson ptLesson = PtLesson.builder()
160+
.ptTrainerTrainee(ptTrainerTrainee)
161+
.lessonStart(request.start())
162+
.lessonEnd(request.end())
163+
.memo(request.memo())
164+
.build();
165+
166+
ptLessonRepository.save(ptLesson);
167+
}
168+
131169
public boolean isPtTrainerTraineeExistWithTrainerId(Long trainerId) {
132170
return ptTrainerTraineeRepository.existsByTrainerIdAndDeletedAtIsNull(trainerId);
133171
}
@@ -165,4 +203,10 @@ private void validateIfNotConnected(Long trainerId, Long traineeId) {
165203
throw new NotFoundException(PT_TRAINER_TRAINEE_NOT_FOUND);
166204
}
167205
}
206+
207+
private void validateLessonTime(PtTrainerTrainee ptTrainerTrainee, LocalDateTime start, LocalDateTime end) {
208+
if (ptLessonSearchRepository.existsByStartAndEnd(ptTrainerTrainee, start, end)) {
209+
throw new ConflictException(PT_LESSON_DUPLICATE_TIME);
210+
}
211+
}
168212
}

src/main/java/com/tnt/application/trainer/TrainerService.java

+6
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,10 @@ public Trainer getTrainerWithInvitationCode(String invitationCode) {
5656
return trainerSearchRepository.findByInvitationCode(invitationCode)
5757
.orElseThrow(() -> new NotFoundException(TRAINER_NOT_FOUND));
5858
}
59+
60+
public void validateTrainerRegistration(Long memberId) {
61+
if (!trainerRepository.existsByMemberIdAndDeletedAtIsNull(memberId)) {
62+
throw new NotFoundException(TRAINER_NOT_FOUND);
63+
}
64+
}
5965
}

src/main/java/com/tnt/common/error/handler/GlobalExceptionHandler.java

+7-5
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package com.tnt.common.error.handler;
22

33
import static com.tnt.common.error.model.ErrorMessage.INPUT_VALUE_IS_INVALID;
4+
import static com.tnt.common.error.model.ErrorMessage.INVALID_REQUEST_BODY;
45
import static com.tnt.common.error.model.ErrorMessage.MISSING_REQUIRED_PARAMETER_ERROR;
56
import static com.tnt.common.error.model.ErrorMessage.PARAMETER_FORMAT_NOT_CORRECT;
7+
import static com.tnt.common.error.model.ErrorMessage.SERVER_ERROR;
68
import static org.springframework.http.HttpStatus.BAD_REQUEST;
79
import static org.springframework.http.HttpStatus.CONFLICT;
810
import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
@@ -42,7 +44,7 @@ protected ErrorResponse handleMissingServletRequestParameter(MissingServletReque
4244
String errorMessage = String.format(MISSING_REQUIRED_PARAMETER_ERROR.getMessage(),
4345
exception.getParameterName());
4446

45-
log.error(MISSING_REQUIRED_PARAMETER_ERROR.getMessage(), exception.getParameterName(), exception);
47+
log.error(errorMessage, exception.getParameterName(), exception);
4648

4749
return new ErrorResponse(errorMessage);
4850
}
@@ -53,7 +55,7 @@ protected ErrorResponse handleMissingServletRequestParameter(MissingServletReque
5355
protected ErrorResponse handleMethodArgumentTypeMismatch(MethodArgumentTypeMismatchException exception) {
5456
String errorMessage = String.format(PARAMETER_FORMAT_NOT_CORRECT.getMessage(), exception.getName());
5557

56-
log.error(PARAMETER_FORMAT_NOT_CORRECT.getMessage(), exception.getName(), exception);
58+
log.error(errorMessage, exception.getName(), exception);
5759

5860
return new ErrorResponse(errorMessage);
5961
}
@@ -90,7 +92,7 @@ protected ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotV
9092
protected ErrorResponse handleHttpMessageNotReadableException(HttpMessageNotReadableException exception) {
9193
log.error(exception.getMessage(), exception);
9294

93-
return new ErrorResponse(exception.getMessage());
95+
return new ErrorResponse(INVALID_REQUEST_BODY.getMessage());
9496
}
9597

9698
// 날짜/시간 형식 예외
@@ -139,7 +141,7 @@ protected ErrorResponse handleNotFoundException(NotFoundException exception) {
139141
protected ErrorResponse handleIllegalArgumentException(IllegalArgumentException exception) {
140142
log.error(exception.getMessage(), exception);
141143

142-
return new ErrorResponse(exception.getMessage());
144+
return new ErrorResponse(SERVER_ERROR.getMessage());
143145
}
144146

145147
// 기타 500 예외
@@ -148,6 +150,6 @@ protected ErrorResponse handleIllegalArgumentException(IllegalArgumentException
148150
protected ErrorResponse handleRuntimeException(RuntimeException exception) {
149151
log.error(exception.getMessage(), exception);
150152

151-
return new ErrorResponse(exception.getMessage());
153+
return new ErrorResponse(SERVER_ERROR.getMessage());
152154
}
153155
}

src/main/java/com/tnt/common/error/model/ErrorMessage.java

+4-1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ public enum ErrorMessage {
1616
IMAGE_NOT_FOUND("이미지가 존재하지 않습니다."),
1717
IMAGE_NOT_SUPPORT("지원하지 않는 이미지 형식입니다. jpg, jpeg, png, svg만 가능합니다."),
1818
INVALID_REQUEST_NOT_MATCH("요청자와 요청이 일치하지 않습니다."),
19+
INVALID_REQUEST_BODY("요청 바디가 올바르지 않습니다."),
1920

2021
CLIENT_BAD_REQUEST("잘못된 요청입니다."),
2122
FAILED_TO_PROCESS_REQUEST("요청 진행에 실패했습니다."),
@@ -27,6 +28,7 @@ public enum ErrorMessage {
2728
INVALID_FORMAT_DATETIME("DateTime 형식이 잘못되었습니다."),
2829

2930
AUTHORIZATION_HEADER_ERROR("Authorization 헤더가 존재하지 않거나 올바르지 않은 형식입니다."),
31+
UNAUTHORIZED("인증되지 않은 사용자입니다."),
3032
NO_EXIST_SESSION_IN_STORAGE("세션 스토리지에 세션이 존재하지 않습니다."),
3133

3234
UNSUPPORTED_SOCIAL_TYPE("지원하지 않는 소셜 서비스입니다."),
@@ -73,7 +75,8 @@ public enum ErrorMessage {
7375
PT_TRAINER_TRAINEE_NOT_FOUND("존재하지 않는 연결 정보입니다."),
7476
PT_TRAINER_TRAINEE_NULL("트레이너 - 트레이니 연결 정보가 null 입니다."),
7577

76-
PT_LESSON_INVALID_MEMO("수업 메모의 길이는 공백 포함 30자 이하이어야 합니다.");
78+
PT_LESSON_INVALID_MEMO("수업 메모의 길이는 공백 포함 30자 이하이어야 합니다."),
79+
PT_LESSON_DUPLICATE_TIME("이미 예약된 시간대입니다.");
7780

7881
private final String message;
7982
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.tnt.dto.trainer.request;
2+
3+
import java.time.LocalDateTime;
4+
5+
import org.hibernate.validator.constraints.Length;
6+
7+
import io.swagger.v3.oas.annotations.media.Schema;
8+
9+
@Schema(description = "PT 수업 생성 요청")
10+
public record CreatePtLessonRequest(
11+
@Schema(description = "수업 시작 날짜 및 시간", example = "2025-03-20T10:00:00", nullable = false)
12+
LocalDateTime start,
13+
14+
@Schema(description = "수업 끝 날짜 및 시간", example = "2025-03-20T11:00:00", nullable = false)
15+
LocalDateTime end,
16+
17+
@Schema(description = "메모", example = "하체 운동 시키기", nullable = false)
18+
@Length(max = 30)
19+
String memo,
20+
21+
@Schema(description = "트레이니 id", example = "213912408127", nullable = false)
22+
Long traineeId
23+
) {
24+
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.tnt.dto.trainer.response;
2+
3+
import java.util.List;
4+
5+
import io.swagger.v3.oas.annotations.media.Schema;
6+
7+
@Schema(description = "관리중인 트레이니 목록 응답")
8+
public record GetActiveTraineesResponse(
9+
@Schema(description = "트레이니 목록", nullable = false)
10+
List<TraineeDto> trainees
11+
) {
12+
13+
public record TraineeDto(
14+
@Schema(description = "트레이니 ID", example = "123523564", nullable = false)
15+
Long id,
16+
17+
@Schema(description = "트레이니 이름", example = "김정호", nullable = false)
18+
String name
19+
) {
20+
21+
}
22+
}

src/main/java/com/tnt/infrastructure/mysql/repository/pt/PtLessonSearchRepository.java

+15
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import com.querydsl.jpa.impl.JPAQueryFactory;
1717
import com.tnt.domain.pt.PtLesson;
18+
import com.tnt.domain.pt.PtTrainerTrainee;
1819

1920
import lombok.RequiredArgsConstructor;
2021

@@ -56,4 +57,18 @@ public List<PtLesson> findAllByTraineeIdForCalendar(Long traineeId, Integer year
5657
.orderBy(ptLesson.lessonStart.asc())
5758
.fetch();
5859
}
60+
61+
public boolean existsByStartAndEnd(PtTrainerTrainee pt, LocalDateTime start, LocalDateTime end) {
62+
return jpaQueryFactory
63+
.selectOne()
64+
.from(ptLesson)
65+
.join(ptLesson.ptTrainerTrainee, ptTrainerTrainee)
66+
.where(
67+
ptTrainerTrainee.eq(pt),
68+
ptLesson.lessonStart.lt(end),
69+
ptLesson.lessonEnd.gt(start),
70+
ptLesson.deletedAt.isNull()
71+
)
72+
.fetchFirst() != null;
73+
}
5974
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.tnt.infrastructure.mysql.repository.pt;
2+
3+
import static com.tnt.domain.member.QMember.member;
4+
import static com.tnt.domain.pt.QPtTrainerTrainee.ptTrainerTrainee;
5+
import static com.tnt.domain.trainee.QTrainee.trainee;
6+
import static com.tnt.domain.trainer.QTrainer.trainer;
7+
8+
import java.util.List;
9+
10+
import org.springframework.stereotype.Repository;
11+
12+
import com.querydsl.jpa.impl.JPAQueryFactory;
13+
import com.tnt.domain.trainee.Trainee;
14+
15+
import lombok.RequiredArgsConstructor;
16+
17+
@Repository
18+
@RequiredArgsConstructor
19+
public class PtTrainerTraineeSearchRepository {
20+
21+
private final JPAQueryFactory jpaQueryFactory;
22+
23+
public List<Trainee> findAllTrainees(Long trainerId) {
24+
return jpaQueryFactory
25+
.select(ptTrainerTrainee.trainee)
26+
.from(ptTrainerTrainee)
27+
.join(ptTrainerTrainee.trainer, trainer)
28+
.join(ptTrainerTrainee.trainee, trainee)
29+
.join(trainee.member, member)
30+
.where(
31+
ptTrainerTrainee.trainer.id.eq(trainerId),
32+
ptTrainerTrainee.deletedAt.isNull()
33+
)
34+
.fetch();
35+
}
36+
}

src/main/java/com/tnt/infrastructure/mysql/repository/trainer/TrainerRepository.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ public interface TrainerRepository extends JpaRepository<Trainer, Long> {
1010

1111
Optional<Trainer> findByMemberIdAndDeletedAtIsNull(Long memberId);
1212

13-
Optional<Trainer> findByInvitationCodeAndDeletedAtIsNull(String invitationCode);
13+
boolean existsByMemberIdAndDeletedAtIsNull(Long memberId);
1414

1515
boolean existsByInvitationCodeAndDeletedAtIsNull(String invitationCode);
1616
}

src/main/java/com/tnt/presentation/trainer/TrainerController.java

+19
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,19 @@
77

88
import org.springframework.web.bind.annotation.GetMapping;
99
import org.springframework.web.bind.annotation.PathVariable;
10+
import org.springframework.web.bind.annotation.PostMapping;
1011
import org.springframework.web.bind.annotation.PutMapping;
12+
import org.springframework.web.bind.annotation.RequestBody;
1113
import org.springframework.web.bind.annotation.RequestMapping;
1214
import org.springframework.web.bind.annotation.RequestParam;
1315
import org.springframework.web.bind.annotation.ResponseStatus;
1416
import org.springframework.web.bind.annotation.RestController;
1517

1618
import com.tnt.application.pt.PtService;
1719
import com.tnt.application.trainer.TrainerService;
20+
import com.tnt.dto.trainer.request.CreatePtLessonRequest;
1821
import com.tnt.dto.trainer.response.ConnectWithTraineeResponse;
22+
import com.tnt.dto.trainer.response.GetActiveTraineesResponse;
1923
import com.tnt.dto.trainer.response.GetCalendarPtLessonCountResponse;
2024
import com.tnt.dto.trainer.response.GetPtLessonsOnDateResponse;
2125
import com.tnt.dto.trainer.response.InvitationCodeResponse;
@@ -25,6 +29,7 @@
2529
import io.swagger.v3.oas.annotations.Operation;
2630
import io.swagger.v3.oas.annotations.Parameter;
2731
import io.swagger.v3.oas.annotations.tags.Tag;
32+
import jakarta.validation.Valid;
2833
import jakarta.validation.constraints.Max;
2934
import jakarta.validation.constraints.Min;
3035
import lombok.RequiredArgsConstructor;
@@ -83,4 +88,18 @@ public GetCalendarPtLessonCountResponse getCalendarPtLessonCount(@AuthMember Lon
8388
@Parameter(description = "월", example = "3") @RequestParam("month") @Min(1) @Max(12) Integer month) {
8489
return ptService.getCalendarPtLessonCount(memberId, year, month);
8590
}
91+
92+
@Operation(summary = "PT 수업 추가 API")
93+
@ResponseStatus(CREATED)
94+
@PostMapping("/lessons")
95+
public void addPtLesson(@AuthMember Long memberId, @RequestBody @Valid CreatePtLessonRequest request) {
96+
ptService.addPtLesson(memberId, request);
97+
}
98+
99+
@Operation(summary = "관리중인 회원 목록 요청 API")
100+
@ResponseStatus(OK)
101+
@GetMapping("/active-trainees")
102+
public GetActiveTraineesResponse getActiveTrainees(@AuthMember Long memberId) {
103+
return ptService.getActiveTrainees(memberId);
104+
}
86105
}

src/test/java/com/tnt/application/member/WithdrawServiceTest.java

+4-4
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ void withdraw_trainer_success() {
8080
void withdraw_trainee_success() {
8181
// given
8282
Member traineeMember = MemberFixture.getTraineeMember1WithId();
83-
Trainee trainee = TraineeFixture.getTrainee1(1L, traineeMember);
83+
Trainee trainee = TraineeFixture.getTrainee1WithId(1L, traineeMember);
8484

8585
List<PtGoal> ptGoals = List.of(PtGoal.builder().id(1L).traineeId(trainee.getId()).content("test").build());
8686

@@ -103,7 +103,7 @@ void withdraw_trainer_with_pt_success() {
103103
Member traineeMember = MemberFixture.getTraineeMember2WithId();
104104

105105
Trainer trainer = TrainerFixture.getTrainer1(1L, trainerMember);
106-
Trainee trainee = TraineeFixture.getTrainee1(1L, traineeMember);
106+
Trainee trainee = TraineeFixture.getTrainee1WithId(1L, traineeMember);
107107

108108
PtTrainerTrainee ptTrainerTrainee = PtTrainerTraineeFixture.getPtTrainerTrainee1(trainer, trainee);
109109

@@ -130,7 +130,7 @@ void withdraw_trainee_with_pt_success() {
130130
Member traineeMember = MemberFixture.getTraineeMember1WithId();
131131

132132
Trainer trainer = TrainerFixture.getTrainer1(1L, trainerMember);
133-
Trainee trainee = TraineeFixture.getTrainee1(1L, traineeMember);
133+
Trainee trainee = TraineeFixture.getTrainee1WithId(1L, traineeMember);
134134

135135
PtTrainerTrainee ptTrainerTrainee = PtTrainerTraineeFixture.getPtTrainerTrainee1(trainer, trainee);
136136

@@ -178,7 +178,7 @@ void withdraw_trainee_without_pt_success() {
178178
// given
179179
Member traineeMember = MemberFixture.getTraineeMember1WithId();
180180

181-
Trainee trainee = TraineeFixture.getTrainee1(1L, traineeMember);
181+
Trainee trainee = TraineeFixture.getTrainee1WithId(1L, traineeMember);
182182

183183
List<PtGoal> ptGoals = List.of(PtGoal.builder().id(1L).traineeId(trainee.getId()).content("test").build());
184184

0 commit comments

Comments
 (0)