diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/code/SuccessCode.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/code/SuccessCode.java index 9f5f985..d4cc574 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/code/SuccessCode.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/code/SuccessCode.java @@ -11,6 +11,7 @@ public enum SuccessCode implements SuccessResultCode { ACTIVITY_COMPLETED(HttpStatus.OK, "활동 기록 저장에 성공했습니다."), + ACTIVITY_DELETED(HttpStatus.OK, "활동 기록 삭제에 성공했습니다."), ACTIVITY_EDIT_LIST_GET_SUCCESS(HttpStatus.OK, "활동 수정 목록 조회에 성공했습니다."), ACTIVITY_RECAP_GET_SUCCESS(HttpStatus.OK, "활동 리캡 조회에 성공했습니다."), ACTIVITY_STATISTICS_GET_SUCCESS(HttpStatus.OK, "활동 통계 기록 조회에 성공했습니다."), @@ -19,6 +20,7 @@ public enum SuccessCode implements SuccessResultCode { ACTIVITY_TRACK_GET_SUCCESS(HttpStatus.OK, "활동 경로 데이터 조회에 성공했습니다."), ARCHIVE_ACTIVITY_LIST_GET_SUCCESS(HttpStatus.OK, "특정 날짜의 활동 목록 조회에 성공했습니다."), IMAGE_UPLOAD_URL_CREATED(HttpStatus.OK, "이미지 업로드 URL 발급에 성공했습니다."), + ROUTE_LIST_UPDATE_SUCCESS(HttpStatus.OK, "루트 목록 수정에 성공했습니다."), ROUTE_LIST_GET_SUCCESS(HttpStatus.OK, "루트 목록 조회에 성공했습니다."), TIMELINE_DELETED(HttpStatus.OK, "타임라인 삭제에 성공했습니다."), TIMELINE_LIST_GET_SUCCESS(HttpStatus.OK, "타임라인 목록 조회에 성공했습니다."), diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/ActivityController.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/ActivityController.java index 4908ea9..4d5c695 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/ActivityController.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/ActivityController.java @@ -10,21 +10,21 @@ import org.sopt.routee.activity.internal.controller.dto.request.ActivityStatusUpdateRequest; import org.sopt.routee.activity.internal.controller.dto.request.ActivityTitleUpdateRequest; import org.sopt.routee.activity.internal.controller.dto.request.ImageUrlRequest; +import org.sopt.routee.activity.internal.controller.dto.response.ActivitiesByDateResponse; import org.sopt.routee.activity.internal.controller.dto.response.ActivityCreateResponse; import org.sopt.routee.activity.internal.controller.dto.response.ActivityEditListResponse; import org.sopt.routee.activity.internal.controller.dto.response.ActivityRecapResponse; -import org.sopt.routee.activity.internal.controller.dto.response.ActivityStatusResponse; import org.sopt.routee.activity.internal.controller.dto.response.ActivityStatisticsResponse; -import org.sopt.routee.activity.internal.controller.dto.response.ActivitiesByDateResponse; +import org.sopt.routee.activity.internal.controller.dto.response.ActivityStatusResponse; import org.sopt.routee.activity.internal.controller.dto.response.ActivityTitleResponse; import org.sopt.routee.activity.internal.controller.dto.response.ActivityTrackResponse; import org.sopt.routee.activity.internal.controller.dto.response.ImageUrlResponse; import org.sopt.routee.activity.internal.service.ActivityService; import org.sopt.routee.activity.internal.service.dto.command.GetActivityRecapCommand; +import org.sopt.routee.activity.internal.service.dto.result.ActivitiesByDateResult; import org.sopt.routee.activity.internal.service.dto.result.ActivityEditListResult; import org.sopt.routee.activity.internal.service.dto.result.ActivityRecapResult; import org.sopt.routee.activity.internal.service.dto.result.ActivityStatisticsResult; -import org.sopt.routee.activity.internal.service.dto.result.ActivitiesByDateResult; import org.sopt.routee.activity.internal.service.dto.result.ActivityTrackResult; import org.sopt.routee.activity.internal.service.dto.result.CreateActivityResult; import org.sopt.routee.activity.internal.service.dto.result.ImageUrlResult; @@ -39,9 +39,10 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; +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.PatchMapping; +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; @@ -140,6 +141,17 @@ public ResponseEntity> complete( .body(ApiResponse.success(SuccessCode.ACTIVITY_COMPLETED)); } + @DeleteMapping("/activity/{activityId}") + public ResponseEntity> delete( + @AuthenticationPrincipal Long memberId, + @PathVariable(name = "activityId") Long activityId + ) { + activityService.delete(activityId, memberId); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(SuccessCode.ACTIVITY_DELETED)); + } + @GetMapping("/activity/{activityId}/statistics") public ResponseEntity> getStatistics( @AuthenticationPrincipal Long memberId, @@ -149,7 +161,8 @@ public ResponseEntity> getStatistics ActivityStatisticsResult result = activityService.getStatistics(activityId, memberId, timeZone); return ResponseEntity.status(HttpStatus.OK) - .body(ApiResponse.success(SuccessCode.ACTIVITY_STATISTICS_GET_SUCCESS, ActivityStatisticsResponse.from(result))); + .body(ApiResponse.success(SuccessCode.ACTIVITY_STATISTICS_GET_SUCCESS, + ActivityStatisticsResponse.from(result))); } @GetMapping("/activity/{activityId}/track") @@ -186,7 +199,8 @@ public ResponseEntity> getActivityEdit ); return ResponseEntity.status(HttpStatus.OK) - .body(ApiResponse.success(SuccessCode.ACTIVITY_EDIT_LIST_GET_SUCCESS, ActivityEditListResponse.from(result))); + .body( + ApiResponse.success(SuccessCode.ACTIVITY_EDIT_LIST_GET_SUCCESS, ActivityEditListResponse.from(result))); } @GetMapping("/archive/activity") @@ -198,6 +212,7 @@ public ResponseEntity> getActivitiesBy ActivitiesByDateResult result = activityService.getActivitiesByDate(memberId, date, timeZone); return ResponseEntity.status(HttpStatus.OK) - .body(ApiResponse.success(SuccessCode.ARCHIVE_ACTIVITY_LIST_GET_SUCCESS, ActivitiesByDateResponse.from(result))); + .body(ApiResponse.success(SuccessCode.ARCHIVE_ACTIVITY_LIST_GET_SUCCESS, + ActivitiesByDateResponse.from(result))); } } diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/ActivityControllerDocs.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/ActivityControllerDocs.java index 9f7919f..b15ac3d 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/ActivityControllerDocs.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/ActivityControllerDocs.java @@ -256,6 +256,23 @@ ResponseEntity> complete( @Valid @RequestBody ActivityCompleteRequest request ); + @Operation(summary = "활동 삭제", description = "인증된 사용자의 활동 기록을 삭제합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "활동 삭제 성공", + content = @Content( + examples = @ExampleObject(value = "{\"status\":200,\"code\":\"ACTIVITY_DELETED\",\"message\":\"활동 기록 삭제에 성공했습니다.\",\"data\":null}"))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = FailureResponse.class))), + @ApiResponse(responseCode = "404", description = "활동 기록이 존재하지 않음", + content = @Content(schema = @Schema(implementation = FailureResponse.class), + examples = @ExampleObject(name = "ACTIVITY_NOT_FOUND", + value = "{\"status\":404,\"code\":\"ACTIVITY_NOT_FOUND\",\"message\":\"활동 기록이 존재하지 않습니다.\"}"))) + }) + ResponseEntity> delete( + Long memberId, + @PathVariable(name = "activityId") Long activityId + ); + @Operation(summary = "활동 통계 기록 조회", description = "인증된 사용자의 활동 통계 기록을 조회합니다.") @ApiResponses({ @ApiResponse(responseCode = "200", description = "활동 통계 기록 조회 성공", diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/RouteController.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/RouteController.java index 24b66b0..2c2e9de 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/RouteController.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/RouteController.java @@ -3,7 +3,8 @@ import java.util.List; import org.sopt.routee.activity.internal.code.SuccessCode; -import org.sopt.routee.activity.internal.controller.dto.request.CreateRoutesRequest; +import org.sopt.routee.activity.internal.controller.dto.request.RoutesCreateRequest; +import org.sopt.routee.activity.internal.controller.dto.request.RoutesUpdateRequest; import org.sopt.routee.activity.internal.controller.dto.response.RouteListResponse; import org.sopt.routee.activity.internal.service.RouteService; import org.sopt.routee.activity.internal.service.dto.result.RouteResult; @@ -14,6 +15,7 @@ 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.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -31,7 +33,7 @@ public class RouteController implements RouteControllerDocs { @PostMapping("/{activityId}/route") public ResponseEntity> createRoutes( @PathVariable(name = "activityId") Long activityId, - @Valid @RequestBody CreateRoutesRequest request + @Valid @RequestBody RoutesCreateRequest request ) { List results = routeService.createRoutes(activityId, request.toCommands()); @@ -39,6 +41,16 @@ public ResponseEntity> createRoutes( .body(ApiResponse.success(SuccessCode.ROUTE_LIST_CREATE_SUCCESS, RouteListResponse.of(activityId, results))); } + @PutMapping("/{activityId}/route") + public ResponseEntity> updateRoutes( + @PathVariable(name = "activityId") Long activityId, + @Valid @RequestBody RoutesUpdateRequest request + ) { + List results = routeService.updateRoutes(activityId, request.toCommands()); + + return ResponseEntity.ok(ApiResponse.success(SuccessCode.ROUTE_LIST_UPDATE_SUCCESS, RouteListResponse.of(activityId, results))); + } + @GetMapping("/{activityId}/route") public ResponseEntity> getRoutes( @PathVariable(name = "activityId") Long activityId diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/RouteControllerDocs.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/RouteControllerDocs.java index c13f9dd..47cf34e 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/RouteControllerDocs.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/RouteControllerDocs.java @@ -1,6 +1,7 @@ package org.sopt.routee.activity.internal.controller; -import org.sopt.routee.activity.internal.controller.dto.request.CreateRoutesRequest; +import org.sopt.routee.activity.internal.controller.dto.request.RoutesCreateRequest; +import org.sopt.routee.activity.internal.controller.dto.request.RoutesUpdateRequest; import org.sopt.routee.activity.internal.controller.dto.response.RouteListResponse; import org.sopt.routee.response.FailureResponse; import org.sopt.routee.response.SuccessResponse; @@ -49,7 +50,23 @@ public interface RouteControllerDocs { }) ResponseEntity> createRoutes( @PathVariable(name = "activityId") Long activityId, - @Valid @RequestBody CreateRoutesRequest request + @Valid @RequestBody RoutesCreateRequest request + ); + + @Operation(summary = "루트 목록 수정", description = "활동 기록의 기존 루트 목록을 요청한 루트 목록 전체로 수정합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "루트 목록 수정 성공", + content = @Content(schema = @Schema(implementation = RouteListResponse.class))), + @ApiResponse(responseCode = "400", description = "요청 값이 올바르지 않음", + content = @Content(schema = @Schema(implementation = FailureResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = FailureResponse.class))), + @ApiResponse(responseCode = "404", description = "활동 기록이 존재하지 않음", + content = @Content(schema = @Schema(implementation = FailureResponse.class))) + }) + ResponseEntity> updateRoutes( + @PathVariable(name = "activityId") Long activityId, + @Valid @RequestBody RoutesUpdateRequest request ); @Operation(summary = "루트 목록 조회", description = "활동 기록에 속하는 루트 목록을 조회합니다.") diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/CreateRoutesRequest.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/RoutesCreateRequest.java similarity index 94% rename from routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/CreateRoutesRequest.java rename to routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/RoutesCreateRequest.java index 05ccaa3..4b11341 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/CreateRoutesRequest.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/RoutesCreateRequest.java @@ -8,7 +8,7 @@ import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.Size; -public record CreateRoutesRequest( +public record RoutesCreateRequest( @Valid @NotEmpty(message = "루트 목록 생성 시 최소 1개 이상의 루트를 입력해야 합니다.") @Size(max = 20, message = "루트는 최대 20개까지 등록할 수 있습니다.") diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/RoutesUpdateRequest.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/RoutesUpdateRequest.java new file mode 100644 index 0000000..31eded3 --- /dev/null +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/controller/dto/request/RoutesUpdateRequest.java @@ -0,0 +1,22 @@ +package org.sopt.routee.activity.internal.controller.dto.request; + +import java.util.List; + +import org.sopt.routee.activity.internal.service.dto.command.UpdateRouteCommand; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record RoutesUpdateRequest( + @Valid + @NotNull(message = "routes는 필수입니다.") + @Size(max = 20, message = "루트는 최대 20개까지 등록할 수 있습니다.") + List<@NotNull(message = "루트는 null일 수 없습니다.") RouteRequest> routes +) { + public List toCommands() { + return routes.stream() + .map(route -> new UpdateRouteCommand(route.name(), route.sequence())) + .toList(); + } +} diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/mapper/RouteMapper.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/mapper/RouteMapper.java index 019ff13..26320dd 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/mapper/RouteMapper.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/mapper/RouteMapper.java @@ -3,6 +3,7 @@ import org.sopt.routee.activity.internal.entity.activity.Activity; import org.sopt.routee.activity.internal.entity.route.Route; import org.sopt.routee.activity.internal.service.dto.command.CreateRouteCommand; +import org.sopt.routee.activity.internal.service.dto.command.UpdateRouteCommand; import org.sopt.routee.activity.internal.service.dto.result.RouteResult; public class RouteMapper { @@ -15,6 +16,14 @@ public static Route toEntity(CreateRouteCommand command, Activity activity) { .build(); } + public static Route toEntity(UpdateRouteCommand command, Activity activity) { + return Route.builder() + .name(command.name()) + .sequence(command.sequence()) + .activity(activity) + .build(); + } + public static RouteResult toResult(Route route) { return new RouteResult(route.getId(), route.getName(), route.getSequence()); } diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityDailySummaryRepository.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityDailySummaryRepository.java index 3eb8e90..3f33fe3 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityDailySummaryRepository.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/ActivityDailySummaryRepository.java @@ -46,12 +46,29 @@ void upsertDailySummary( @Modifying @Query(""" UPDATE ActivityDailySummary ads SET ads.coverActivityId = :coverActivityId, ads.coverImageObjectKey = :coverImageObjectKey - WHERE ads.memberId = :memberId AND ads.activityDate = :activityDate + WHERE ads.memberId = :memberId AND ads.activityDate = :activityDate AND ads.coverActivityId = :previousCoverActivityId """) void updateCoverImage( @Param("memberId") Long memberId, @Param("activityDate") LocalDate activityDate, @Param("coverActivityId") Long coverActivityId, - @Param("coverImageObjectKey") String coverImageObjectKey + @Param("coverImageObjectKey") String coverImageObjectKey, + @Param("previousCoverActivityId") Long previousCoverActivityId + ); + + @Modifying + @Query(""" + UPDATE ActivityDailySummary ads + SET ads.totalDurationSec = ads.totalDurationSec - :durationSec, ads.activityCount = ads.activityCount - 1 + WHERE ads.memberId = :memberId AND ads.activityDate = :activityDate + """) + void decrementDailySummary( + @Param("memberId") Long memberId, + @Param("activityDate") LocalDate activityDate, + @Param("durationSec") Integer durationSec ); + + @Modifying + @Query("DELETE FROM ActivityDailySummary ads WHERE ads.memberId = :memberId AND ads.activityDate = :activityDate AND ads.activityCount <= 0") + void deleteIfEmpty(@Param("memberId") Long memberId, @Param("activityDate") LocalDate activityDate); } diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/RouteRepository.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/RouteRepository.java index 7d05dd8..dd79b2a 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/RouteRepository.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/repository/RouteRepository.java @@ -14,6 +14,10 @@ public interface RouteRepository extends JpaRepository { boolean existsByActivityId(Long activityId); + @Modifying + @Query("DELETE FROM Route r WHERE r.activity.id = :activityId") + void deleteByActivityId(@Param("activityId") Long activityId); + @Modifying @Query("DELETE FROM Route r WHERE r.activity.memberId = :memberId") void deleteByMemberId(@Param("memberId") Long memberId); diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityDailySummaryService.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityDailySummaryService.java index 3ee492c..4b2e398 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityDailySummaryService.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityDailySummaryService.java @@ -3,10 +3,14 @@ import java.time.LocalDate; import java.time.YearMonth; import java.util.List; +import java.util.Optional; +import org.sopt.routee.activity.internal.entity.activity.Activity; +import org.sopt.routee.activity.internal.entity.activity.ActivityStatus; import org.sopt.routee.activity.internal.entity.summary.ActivityDailySummary; import org.sopt.routee.activity.internal.mapper.ActivityDailySummaryMapper; import org.sopt.routee.activity.internal.repository.ActivityDailySummaryRepository; +import org.sopt.routee.activity.internal.repository.ActivityRepository; import org.sopt.routee.activity.internal.service.dto.result.ActivityDailySummaryResult; import org.sopt.routee.external.api.command.FileImageAccessUrlCommand; import org.sopt.routee.external.api.port.FileImageAccessUrlPort; @@ -23,6 +27,7 @@ public class ActivityDailySummaryService { private final ActivityDailySummaryRepository activityDailySummaryRepository; + private final ActivityRepository activityRepository; private final FileImageAccessUrlPort fileImageAccessUrlPort; @Transactional(readOnly = true) @@ -50,8 +55,22 @@ public void deleteActivityDailySummariesByMemberId(long memberId) { } @Transactional - public void refreshCoverImage(Long memberId, LocalDate activityDate, Long coverActivityId, String coverImageObjectKey) { - activityDailySummaryRepository.updateCoverImage(memberId, activityDate, coverActivityId, coverImageObjectKey); + public void refreshCoverAfterActivityChanged(Long memberId, LocalDate activityDate, Long previousCoverActivityId) { + Optional replacementCover = activityRepository + .findFirstByMemberIdAndActivityDateWithTimezoneAndActivityStatusAndCoverImageObjectKeyIsNotNullOrderByStartedAtAsc( + memberId, activityDate, ActivityStatus.ACTIVITY_COMPLETED); + + activityDailySummaryRepository.updateCoverImage( + memberId, activityDate, + replacementCover.map(Activity::getId).orElse(null), + replacementCover.map(Activity::getCoverImageObjectKey).orElse(null), + previousCoverActivityId); + } + + @Transactional + public void removeActivity(Long memberId, LocalDate activityDate, Integer durationSec) { + activityDailySummaryRepository.decrementDailySummary(memberId, activityDate, durationSec); + activityDailySummaryRepository.deleteIfEmpty(memberId, activityDate); } private String generateCoverImageUrl(Long memberId, ActivityDailySummary summary) { diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java index f5bc6ac..f9aff13 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/ActivityService.java @@ -373,6 +373,36 @@ public void deleteActivitiesByMemberId(long memberId) { activityRepository.deleteByMemberId(memberId); } + public void delete(Long activityId, Long memberId) { + Activity deletedActivity = transactionTemplate.execute(status -> { + Activity activity = activityRepository.findByIdAndMemberId(activityId, memberId) + .orElseThrow(ActivityNotFoundException::new); + + routeRepository.deleteByActivityIdIn(List.of(activityId)); + timelineRepository.deleteByActivityIdIn(List.of(activityId)); + activityRepository.delete(activity); + + if (activity.getActivityStatus().isCompleted()) { + refreshDailySummaryAfterDelete(activity); + } + + return activity; + }); + + log.info("Activity deleted. activityId={}, memberId={}", activityId, memberId); + Thread.startVirtualThread(() -> deleteActivityImageDirectories(memberId, List.of(deletedActivity.getId()))); + } + + private void refreshDailySummaryAfterDelete(Activity activity) { + LocalDate activityDate = activity.getActivityDateWithTimezone(); + if (activityDate == null) { + return; + } + + activityDailySummaryService.removeActivity(activity.getMemberId(), activityDate, activity.getDurationSec()); + activityDailySummaryService.refreshCoverAfterActivityChanged(activity.getMemberId(), activityDate, activity.getId()); + } + private String generateTimelineImageUrl(Long memberId, Long activityId, Timeline timeline, FileUploadImageSize imageSize) { FileImageAccessUrlCommand command = new FileImageAccessUrlCommand( diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/RouteService.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/RouteService.java index 2886182..f474ffc 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/RouteService.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/RouteService.java @@ -1,5 +1,6 @@ package org.sopt.routee.activity.internal.service; +import java.util.Comparator; import java.util.List; import org.sopt.routee.activity.internal.entity.activity.Activity; @@ -10,6 +11,7 @@ import org.sopt.routee.activity.internal.repository.ActivityRepository; import org.sopt.routee.activity.internal.repository.RouteRepository; import org.sopt.routee.activity.internal.service.dto.command.CreateRouteCommand; +import org.sopt.routee.activity.internal.service.dto.command.UpdateRouteCommand; import org.sopt.routee.activity.internal.service.dto.result.RouteResult; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -42,6 +44,38 @@ public List createRoutes(Long activityId, List .toList(); } + @Transactional + public List updateRoutes(Long activityId, List commands) { + if (!activityRepository.existsById(activityId)) { + throw new ActivityNotFoundException(); + } + + List sortedCommands = commands.stream() + .sorted(Comparator.comparingInt(UpdateRouteCommand::sequence)) + .toList(); + + List existingRoutes = routeRepository.findByActivityIdOrderBySequenceAsc(activityId); + List existingCommands = existingRoutes.stream() + .map(route -> new UpdateRouteCommand(route.getName(), route.getSequence())) + .toList(); + if (existingCommands.equals(sortedCommands)) { + return existingRoutes.stream() + .map(RouteMapper::toResult) + .toList(); + } + + routeRepository.deleteByActivityId(activityId); + Activity activity = activityRepository.getReferenceById(activityId); + + List routes = sortedCommands.stream() + .map(command -> RouteMapper.toEntity(command, activity)) + .toList(); + + return routeRepository.saveAll(routes).stream() + .map(RouteMapper::toResult) + .toList(); + } + @Transactional(readOnly = true) public List getRoutes(Long activityId) { if (!activityRepository.existsById(activityId)) { diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java index 032caba..c785cbf 100644 --- a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/TimelineService.java @@ -5,7 +5,6 @@ import java.util.List; import org.sopt.routee.activity.internal.entity.activity.Activity; -import org.sopt.routee.activity.internal.entity.activity.ActivityStatus; import org.sopt.routee.activity.internal.entity.timeline.Timeline; import org.sopt.routee.activity.internal.entity.timeline.TimelineStatus; import org.sopt.routee.activity.internal.exception.ActivityNotFoundException; @@ -64,7 +63,8 @@ public List getTimelines(Long activityId, Long memberId) { } return timelineRepository.findByActivityIdOrderByCreatedAtAsc(activityId).stream() - .map(timeline -> TimelineMapper.toTimelineResult(timeline, generateImageUrl(memberId, activityId, timeline))) + .map( + timeline -> TimelineMapper.toTimelineResult(timeline, generateImageUrl(memberId, activityId, timeline))) .toList(); } @@ -131,15 +131,7 @@ private void refreshDailySummaryCoverIfNeeded(Activity activity) { return; } - Activity firstActivityWithCover = activityRepository - .findFirstByMemberIdAndActivityDateWithTimezoneAndActivityStatusAndCoverImageObjectKeyIsNotNullOrderByStartedAtAsc( - activity.getMemberId(), activityDate, ActivityStatus.ACTIVITY_COMPLETED) - .orElse(null); - - Long coverActivityId = firstActivityWithCover == null ? null : firstActivityWithCover.getId(); - String coverImageObjectKey = firstActivityWithCover == null ? null : firstActivityWithCover.getCoverImageObjectKey(); - - activityDailySummaryService.refreshCoverImage(activity.getMemberId(), activityDate, coverActivityId, coverImageObjectKey); + activityDailySummaryService.refreshCoverAfterActivityChanged(activity.getMemberId(), activityDate, activity.getId()); } private Timeline findOwnedTimeline(Long activityId, Long timelineId, Long memberId) { @@ -152,7 +144,8 @@ private Timeline findOwnedTimeline(Long activityId, Long timelineId, Long member private void deleteTimelineImage(Long memberId, Long activityId, String objectKey) { try { fileDeletePort.deleteImage( - new FileDeleteCommand(FileUploadDirectory.TIMELINE, memberId.toString(), activityId.toString(), objectKey)); + new FileDeleteCommand(FileUploadDirectory.TIMELINE, memberId.toString(), activityId.toString(), + objectKey)); } catch (BaseException e) { log.warn("Timeline image delete failed. activityId={}, objectKey={}", activityId, objectKey, e); } diff --git a/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/command/UpdateRouteCommand.java b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/command/UpdateRouteCommand.java new file mode 100644 index 0000000..c5168c4 --- /dev/null +++ b/routee-activity/src/main/java/org/sopt/routee/activity/internal/service/dto/command/UpdateRouteCommand.java @@ -0,0 +1,7 @@ +package org.sopt.routee.activity.internal.service.dto.command; + +public record UpdateRouteCommand( + String name, + int sequence +) { +} diff --git a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityDailySummaryServiceTest.java b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityDailySummaryServiceTest.java new file mode 100644 index 0000000..ad79ef3 --- /dev/null +++ b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityDailySummaryServiceTest.java @@ -0,0 +1,75 @@ +package org.sopt.routee.activity.internal.service; + +import static org.mockito.Mockito.*; + +import java.time.LocalDate; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.sopt.routee.activity.internal.entity.activity.Activity; +import org.sopt.routee.activity.internal.entity.activity.ActivityStatus; +import org.sopt.routee.activity.internal.repository.ActivityDailySummaryRepository; +import org.sopt.routee.activity.internal.repository.ActivityRepository; +import org.sopt.routee.external.api.port.FileImageAccessUrlPort; + +@ExtendWith(MockitoExtension.class) +class ActivityDailySummaryServiceTest { + + private static final Long MEMBER_ID = 1L; + private static final LocalDate ACTIVITY_DATE = LocalDate.of(2026, 7, 7); + private static final Long DELETED_COVER_ACTIVITY_ID = 10L; + + @Mock + private ActivityDailySummaryRepository activityDailySummaryRepository; + + @Mock + private ActivityRepository activityRepository; + + @Mock + private FileImageAccessUrlPort fileImageAccessUrlPort; + + private ActivityDailySummaryService activityDailySummaryService; + + @BeforeEach + void setUp() { + activityDailySummaryService = new ActivityDailySummaryService( + activityDailySummaryRepository, + activityRepository, + fileImageAccessUrlPort + ); + } + + @Test + void refreshCoverAfterActivityChanged_대표_커버_삭제_후_남은_활동에_이미지가_있으면_다음_활동을_새_커버로_갱신한다() { + Activity nextActivity = Activity.builder() + .id(20L) + .coverImageObjectKey("next-cover.jpg") + .build(); + when(activityRepository + .findFirstByMemberIdAndActivityDateWithTimezoneAndActivityStatusAndCoverImageObjectKeyIsNotNullOrderByStartedAtAsc( + MEMBER_ID, ACTIVITY_DATE, ActivityStatus.ACTIVITY_COMPLETED)) + .thenReturn(Optional.of(nextActivity)); + + activityDailySummaryService.refreshCoverAfterActivityChanged(MEMBER_ID, ACTIVITY_DATE, DELETED_COVER_ACTIVITY_ID); + + verify(activityDailySummaryRepository).updateCoverImage( + MEMBER_ID, ACTIVITY_DATE, 20L, "next-cover.jpg", DELETED_COVER_ACTIVITY_ID); + } + + @Test + void refreshCoverAfterActivityChanged_대표_커버_삭제_후_남은_활동에_이미지가_없으면_커버를_null로_갱신한다() { + when(activityRepository + .findFirstByMemberIdAndActivityDateWithTimezoneAndActivityStatusAndCoverImageObjectKeyIsNotNullOrderByStartedAtAsc( + MEMBER_ID, ACTIVITY_DATE, ActivityStatus.ACTIVITY_COMPLETED)) + .thenReturn(Optional.empty()); + + activityDailySummaryService.refreshCoverAfterActivityChanged(MEMBER_ID, ACTIVITY_DATE, DELETED_COVER_ACTIVITY_ID); + + verify(activityDailySummaryRepository).updateCoverImage( + MEMBER_ID, ACTIVITY_DATE, null, null, DELETED_COVER_ACTIVITY_ID); + } +} diff --git a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityServiceTest.java b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityServiceTest.java index 1be6922..775d65b 100644 --- a/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityServiceTest.java +++ b/routee-activity/src/test/java/org/sopt/routee/activity/internal/service/ActivityServiceTest.java @@ -5,6 +5,7 @@ import static org.mockito.Mockito.*; import java.time.Instant; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.EnumSet; @@ -288,4 +289,43 @@ private void completeWithSynchronizationActive(CompleteActivityCommand command) TransactionSynchronizationManager.clearSynchronization(); } } + + @Test + void delete_완료된_활동을_삭제하면_일별_요약_감소와_커버_재계산을_요청한다() { + Long activityId = 30L; + LocalDate activityDate = LocalDate.of(2026, 7, 7); + Activity activity = Activity.builder() + .id(activityId) + .memberId(MEMBER_ID) + .activityStatus(ActivityStatus.ACTIVITY_COMPLETED) + .durationSec(1800) + .activityDateWithTimezone(activityDate) + .build(); + + when(activityRepository.findByIdAndMemberId(activityId, MEMBER_ID)).thenReturn(Optional.of(activity)); + stubTransactionTemplateToRunCallback(); + + activityService.delete(activityId, MEMBER_ID); + + InOrder inOrder = inOrder(activityDailySummaryService); + inOrder.verify(activityDailySummaryService).removeActivity(MEMBER_ID, activityDate, 1800); + inOrder.verify(activityDailySummaryService).refreshCoverAfterActivityChanged(MEMBER_ID, activityDate, activityId); + } + + @Test + void delete_진행중인_활동을_삭제하면_일별_요약에_영향을_주지_않는다() { + Long activityId = 31L; + Activity activity = Activity.builder() + .id(activityId) + .memberId(MEMBER_ID) + .activityStatus(ActivityStatus.ACTIVITY_IN_PROGRESS) + .build(); + + when(activityRepository.findByIdAndMemberId(activityId, MEMBER_ID)).thenReturn(Optional.of(activity)); + stubTransactionTemplateToRunCallback(); + + activityService.delete(activityId, MEMBER_ID); + + verifyNoInteractions(activityDailySummaryService); + } }