Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
477 changes: 477 additions & 0 deletions scripts/seed-datecourse-test.sql

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
import com.hufs.capstone.backend.course.api.controller.swagger.DateCourseApi;
import com.hufs.capstone.backend.course.api.request.DateCourseGenerationRequest;
import com.hufs.capstone.backend.course.api.request.DateCourseSaveRequest;
import com.hufs.capstone.backend.course.api.request.DateCourseUpdateRequest;
import com.hufs.capstone.backend.course.api.response.DateCourseGenerationResponse;
import com.hufs.capstone.backend.course.api.response.DateCoursePageResponse;
import com.hufs.capstone.backend.course.api.response.DateCourseResponse;
import com.hufs.capstone.backend.course.application.DateCourseDeleteService;
import com.hufs.capstone.backend.course.application.DateCourseEditService;
import com.hufs.capstone.backend.course.application.DateCourseGenerationService;
import com.hufs.capstone.backend.course.application.DateCourseQueryService;
import com.hufs.capstone.backend.course.application.DateCourseSaveService;
Expand All @@ -28,6 +31,8 @@ public class DateCourseController implements DateCourseApi {
private final DateCourseGenerationService generationService;
private final DateCourseSaveService saveService;
private final DateCourseQueryService queryService;
private final DateCourseEditService editService;
private final DateCourseDeleteService deleteService;

@Override
public CommonResponse<List<RegionOptionResponse>> listCourseGenerationSidos(@PathVariable String roomId) {
Expand Down Expand Up @@ -68,7 +73,7 @@ public CommonResponse<Void> saveCourse(
@RequestHeader(name = "X-XSRF-TOKEN", required = false) String csrfToken
) {
Long userId = SecurityUtils.currentUserIdOrThrow();
saveService.save(roomId, dateCourseId, request.courseName(), userId);
saveService.save(roomId, dateCourseId, request.courseName(), request.roomPlaceIds(), userId);
return CommonResponse.ok(null);
}

Expand All @@ -92,4 +97,28 @@ public CommonResponse<DateCourseResponse> getCourse(
Long userId = SecurityUtils.currentUserIdOrThrow();
return CommonResponse.ok(DateCourseResponse.from(queryService.getCourse(roomId, dateCourseId, userId)));
}

@Override
public CommonResponse<DateCourseResponse> updateCourse(
@PathVariable String roomId,
@PathVariable String dateCourseId,
@Valid @RequestBody DateCourseUpdateRequest request,
@RequestHeader(name = "X-XSRF-TOKEN", required = false) String csrfToken
) {
Long userId = SecurityUtils.currentUserIdOrThrow();
return CommonResponse.ok(DateCourseResponse.from(
editService.update(roomId, dateCourseId, request.courseName(), request.roomPlaceIds(), userId)
));
}

@Override
public CommonResponse<Void> deleteCourse(
@PathVariable String roomId,
@PathVariable String dateCourseId,
@RequestHeader(name = "X-XSRF-TOKEN", required = false) String csrfToken
) {
Long userId = SecurityUtils.currentUserIdOrThrow();
deleteService.delete(roomId, dateCourseId, userId);
return CommonResponse.okMessage("데이트 코스가 삭제되었습니다.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.hufs.capstone.backend.course.api.request.DateCourseGenerationRequest;
import com.hufs.capstone.backend.course.api.request.DateCourseSaveRequest;
import com.hufs.capstone.backend.course.api.request.DateCourseUpdateRequest;
import com.hufs.capstone.backend.course.api.response.DateCourseGenerationResponse;
import com.hufs.capstone.backend.course.api.response.DateCoursePageResponse;
import com.hufs.capstone.backend.course.api.response.DateCourseResponse;
Expand All @@ -13,9 +14,11 @@
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
Expand Down Expand Up @@ -65,8 +68,14 @@ CommonResponse<DateCourseGenerationResponse> generateCourse(
@Operation(
tags = {"Date course"},
summary = "데이트 코스 저장 API",
description = "생성된 코스 후보 중 하나를 선택해 저장합니다."
+ "이미 저장된 코스와 동일한 장소 순서이면 409를 반환합니다."
description = """
생성된 코스 후보 중 하나를 선택해 저장합니다.
- roomPlaceIds를 생략하면 추천 생성 시 만들어진 원본 장소 구성 그대로 저장합니다.
- roomPlaceIds를 전달하면 저장 전에 해당 장소 구성으로 전체 교체한 뒤 저장합니다.
프론트에서 후보 코스를 편집한 뒤 "저장하기"를 누르는 경우 최종 순서대로 roomPlaceIds를 전달합니다.
- 추가하는 장소는 반드시 해당 방에 이미 저장된 장소(roomPlaceId)여야 합니다.
- 이미 저장된 코스와 동일한 장소 순서이면 409를 반환합니다.
"""
)
@ApiResponse(responseCode = "200", description = "저장 성공")
@PostMapping("/{dateCourseId}/save")
Expand Down Expand Up @@ -101,4 +110,48 @@ CommonResponse<DateCourseResponse> getCourse(
@PathVariable String roomId,
@PathVariable String dateCourseId
);

@Operation(
tags = {"Date course"},
summary = "데이트 코스 수정 API",
description = """
저장된 데이트 코스의 이름과 장소 구성(순서 포함)을 전체 교체합니다.
- 코스 이름 변경, 장소 순서 변경, 장소 삭제, 장소 추가 4가지를 한 번에 처리합니다.
- roomPlaceIds는 최종 순서대로 전달합니다 (index = sequenceOrder).
- 추가하는 장소는 반드시 해당 방에 이미 저장된 장소(roomPlaceId)여야 합니다.
- 코스 생성자 또는 저장자만 수정할 수 있습니다.
- 수정 결과가 같은 방의 다른 저장 코스와 동일한 장소 구성·순서이면 409를 반환합니다
(code=E409_DUPLICATE_DATE_COURSE).
"""
)
@ApiResponse(responseCode = "200", description = "수정 성공, 수정된 코스 반환")
@ApiResponse(responseCode = "400", description = "입력값 오류 (장소 0개, 중복 ID, 방 미소속 장소 등)")
@ApiResponse(responseCode = "403", description = "수정 권한 없음")
@ApiResponse(responseCode = "404", description = "코스를 찾을 수 없음")
@ApiResponse(responseCode = "409", description = "동일한 데이트 코스가 이미 저장되어 있음 (code=E409_DUPLICATE_DATE_COURSE)")
@PutMapping("/{dateCourseId}")
CommonResponse<DateCourseResponse> updateCourse(
@PathVariable String roomId,
@PathVariable String dateCourseId,
@Valid @RequestBody DateCourseUpdateRequest request,
@RequestHeader(name = "X-XSRF-TOKEN", required = false) String csrfToken
);

@Operation(
tags = {"Date course"},
summary = "데이트 코스 삭제 API",
description = """
저장된 데이트 코스를 soft delete합니다. 삭제 후 목록/상세 조회에서 제외됩니다.
코스 생성자 또는 저장자만 삭제할 수 있습니다.
"""
)
@ApiResponse(responseCode = "200", description = "삭제 성공")
@ApiResponse(responseCode = "403", description = "삭제 권한 없음")
@ApiResponse(responseCode = "404", description = "코스를 찾을 수 없음")
@DeleteMapping("/{dateCourseId}")
CommonResponse<Void> deleteCourse(
@PathVariable String roomId,
@PathVariable String dateCourseId,
@RequestHeader(name = "X-XSRF-TOKEN", required = false) String csrfToken
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@

import com.hufs.capstone.backend.course.domain.DateCourseNamePolicy;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;

public record DateCourseSaveRequest(
@NotBlank(message = "데이트 코스 이름은 필수입니다.")
@Size(max = DateCourseNamePolicy.MAX_LENGTH, message = "데이트 코스 이름은 20자를 초과할 수 없습니다.")
String courseName
String courseName,

List<@NotNull(message = "장소 ID는 null일 수 없습니다.") Long> roomPlaceIds
) {

public DateCourseSaveRequest {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.hufs.capstone.backend.course.api.request;

import com.hufs.capstone.backend.course.domain.DateCourseNamePolicy;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;

public record DateCourseUpdateRequest(
@NotBlank(message = "데이트 코스 이름은 필수입니다.")
@Size(max = DateCourseNamePolicy.MAX_LENGTH, message = "데이트 코스 이름은 20자를 초과할 수 없습니다.")
String courseName,

@NotEmpty(message = "장소는 최소 1개 이상이어야 합니다.")
List<@NotNull(message = "장소 ID는 null일 수 없습니다.") Long> roomPlaceIds
) {

public DateCourseUpdateRequest {
if (courseName != null) {
courseName = courseName.trim();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.hufs.capstone.backend.course.application;

import com.hufs.capstone.backend.course.domain.entity.DateCourse;
import com.hufs.capstone.backend.course.domain.repository.DateCourseRepository;
import com.hufs.capstone.backend.global.exception.BusinessException;
import com.hufs.capstone.backend.global.exception.ErrorCode;
import com.hufs.capstone.backend.room.application.RoomAccessService;
import com.hufs.capstone.backend.room.domain.entity.Room;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class DateCourseDeleteService {

private final RoomAccessService roomAccessService;
private final DateCourseRepository dateCourseRepository;

@Transactional
public void delete(String roomPublicId, String dateCourseId, Long userId) {
// 1. 방 멤버 검증
Room room = roomAccessService.requireMemberRoom(roomPublicId, userId);

// 2. 코스 존재 확인 (soft delete 제외)
DateCourse course = dateCourseRepository.findByDateCourseIdAndRoomIdAndDeletedAtIsNull(
dateCourseId, room.getId())
.orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "데이트 코스를 찾을 수 없습니다."));

// 3. 저장된 코스만 삭제 대상 (미저장 후보는 사용자 가시 대상 아님)
if (course.getSavedByUserId() == null) {
throw new BusinessException(ErrorCode.E404_NOT_FOUND, "데이트 코스를 찾을 수 없습니다.");
}

// 4. 권한 검증 — 생성자 또는 저장자만 삭제 가능
boolean isOwner = userId.equals(course.getCreatedByUserId())
|| userId.equals(course.getSavedByUserId());
if (!isOwner) {
throw new BusinessException(ErrorCode.E403_FORBIDDEN, "데이트 코스를 삭제할 권한이 없습니다.");
}

// 5. Soft delete — DateCoursePlace 행은 보존하되 코스가 모든 조회에서 제외됨
course.softDelete();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@ boolean existsSavedCourseWithSamePlacesExcluding(
.contains(dateCoursePlaceSignature(places));
}

/**
* 코스 수정 시 — 최종 확정 RoomPlace 목록(순서 포함)이 자기 자신을 제외한
* 다른 저장 코스와 동일한지 검사한다.
*/
boolean existsSavedCourseWithSameRoomPlacesExcluding(
Long roomId,
Long excludedCourseId,
List<RoomPlace> orderedPlaces
) {
return savedCourseSignaturesExcluding(roomId, excludedCourseId)
.contains(roomPlaceSignature(orderedPlaces));
}

private Set<List<Long>> savedCourseSignatures(Long roomId) {
return signatures(dateCoursePlaceRepository.findSavedPlacesByRoomId(roomId));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package com.hufs.capstone.backend.course.application;

import com.hufs.capstone.backend.course.application.dto.DateCoursePlaceResult;
import com.hufs.capstone.backend.course.application.dto.DateCourseResult;
import com.hufs.capstone.backend.course.domain.DateCourseNamePolicy;
import com.hufs.capstone.backend.course.domain.entity.DateCourse;
import com.hufs.capstone.backend.course.domain.entity.DateCoursePlace;
import com.hufs.capstone.backend.course.domain.repository.DateCoursePlaceRepository;
import com.hufs.capstone.backend.course.domain.repository.DateCourseRepository;
import com.hufs.capstone.backend.global.exception.BusinessException;
import com.hufs.capstone.backend.global.exception.ErrorCode;
import com.hufs.capstone.backend.global.exception.FieldValidationException;
import com.hufs.capstone.backend.place.domain.entity.RoomPlace;
import com.hufs.capstone.backend.place.domain.repository.RoomPlaceRepository;
import com.hufs.capstone.backend.room.application.RoomAccessService;
import com.hufs.capstone.backend.room.domain.entity.Room;
import com.hufs.capstone.backend.user.domain.entity.User;
import com.hufs.capstone.backend.user.domain.repository.UserRepository;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class DateCourseEditService {

private final RoomAccessService roomAccessService;
private final DateCourseRepository dateCourseRepository;
private final DateCoursePlaceRepository dateCoursePlaceRepository;
private final RoomPlaceRepository roomPlaceRepository;
private final DateCourseDuplicatePolicy duplicatePolicy;
private final UserRepository userRepository;

@Transactional
public DateCourseResult update(
String roomPublicId,
String dateCourseId,
String courseName,
List<Long> roomPlaceIds,
Long userId
) {
// 1. 방 멤버 검증
Room room = roomAccessService.requireMemberRoom(roomPublicId, userId);

// 2. 코스 존재 확인 (soft delete 제외)
DateCourse course = dateCourseRepository.findByDateCourseIdAndRoomIdAndDeletedAtIsNull(
dateCourseId, room.getId())
.orElseThrow(() -> new BusinessException(ErrorCode.E404_NOT_FOUND, "데이트 코스를 찾을 수 없습니다."));

// 3. 저장된 코스만 편집 대상 (미저장 후보는 사용자 가시 대상 아님)
if (course.getSavedByUserId() == null) {
throw new BusinessException(ErrorCode.E404_NOT_FOUND, "데이트 코스를 찾을 수 없습니다.");
}

// 4. 권한 검증 — 생성자 또는 저장자만 수정 가능
boolean isOwner = userId.equals(course.getCreatedByUserId())
|| userId.equals(course.getSavedByUserId());
if (!isOwner) {
throw new BusinessException(ErrorCode.E403_FORBIDDEN, "데이트 코스를 수정할 권한이 없습니다.");
}

// 5. 코스 이름 정규화/검증
String normalizedName = DateCourseNamePolicy.normalizeAndValidate(courseName);

// 6. roomPlaceIds 검증
if (roomPlaceIds == null || roomPlaceIds.isEmpty()) {
throw new FieldValidationException("roomPlaceIds", "장소는 최소 1개 이상이어야 합니다.");
}

// 중복 id 검증 — 같은 장소를 두 번 포함 불가 (DB 유니크 제약과 일치)
long distinctCount = roomPlaceIds.stream().distinct().count();
if (distinctCount != roomPlaceIds.size()) {
throw new FieldValidationException("roomPlaceIds", "중복된 장소 ID가 포함되어 있습니다.");
}

// 방에 속한 장소인지 배치 검증 — 결과 수와 요청 수가 다르면 방에 없는 장소 포함
List<RoomPlace> foundRoomPlaces = roomPlaceRepository.findAllByIdInAndRoomId(roomPlaceIds, room.getId());
if (foundRoomPlaces.size() != roomPlaceIds.size()) {
throw new FieldValidationException("roomPlaceIds", "이 방에 저장된 장소만 추가할 수 있습니다.");
}

// 요청 순서대로 정렬
Map<Long, RoomPlace> roomPlaceById = foundRoomPlaces.stream()
.collect(Collectors.toMap(RoomPlace::getId, rp -> rp));
List<RoomPlace> orderedPlaces = roomPlaceIds.stream()
.map(roomPlaceById::get)
.toList();

// 7. 중복 코스 검사 (자기 자신 제외)
if (duplicatePolicy.existsSavedCourseWithSameRoomPlacesExcluding(
room.getId(), course.getId(), orderedPlaces)) {
throw new BusinessException(ErrorCode.E409_DUPLICATE_DATE_COURSE,
"동일한 데이트 코스가 이미 저장되어 있습니다.");
}

// 8. 변경 적용
course.rename(normalizedName);
course.clearSkippedSlots();

// 장소 전체 교체 (유니크 제약 충돌 방지: 삭제 후 재삽입)
dateCoursePlaceRepository.deleteByDateCourseId(course.getId());

List<DateCoursePlace> newPlaces = new ArrayList<>();
for (int i = 0; i < orderedPlaces.size(); i++) {
newPlaces.add(DateCoursePlace.create(course, orderedPlaces.get(i), i));
}
List<DateCoursePlace> savedPlaces = dateCoursePlaceRepository.saveAll(newPlaces);

// 9. 결과 반환
User saver = userRepository.findByIdAndDeletedAtIsNull(course.getSavedByUserId()).orElse(null);
return toCourseResult(course, savedPlaces, saver);
}

private DateCourseResult toCourseResult(DateCourse course, List<DateCoursePlace> places, User saver) {
List<DateCoursePlaceResult> placeResults = places.stream()
.sorted(Comparator.comparingInt(DateCoursePlace::getSequenceOrder))
.map(dcp -> DateCoursePlaceMapper.toPlaceResult(dcp.getRoomPlace(), dcp.getSequenceOrder()))
.toList();

return new DateCourseResult(
course.getDateCourseId(),
course.getCourseName(),
course.getCourseMode(),
course.getGenerationBatchId(),
course.getStartDateTime(),
course.getEndDateTime(),
course.getCreatedAt(),
placeResults,
List.of(),
saver != null ? saver.getId() : null,
saver != null ? saver.getNickname() : null,
saver != null ? saver.getProfileImageUrl() : null,
course.getSavedAt()
);
}
}
Loading
Loading