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
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public enum MissionErrorCode implements BaseErrorCode {
ALREADY_COMPLETED(HttpStatus.CONFLICT, "MISSION409_1", "오늘 이미 완료한 미션입니다."),
CUSTOM_MISSION_NOT_ALLOWED(HttpStatus.FORBIDDEN, "MISSION403_1", "나를 돌보기 트랙은 개인 미션을 추가할 수 없습니다."),
CUSTOM_MISSION_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "MISSION400_1", "개인 미션은 최대 2개까지 추가할 수 있습니다."),
CANNOT_PROCEED(HttpStatus.BAD_REQUEST, "MISSION400_2", "아직 다음 트랙으로 진행할 수 없습니다."),
;

private final HttpStatus status;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public enum MissionSuccessCode implements BaseSuccessCode {
ADD_CUSTOM_MISSION_SUCCESS(HttpStatus.OK, "MISSION200_2", "개인 미션 추가에 성공했습니다."),
GET_PROGRESS_SUCCESS(HttpStatus.OK, "MISSION200_3", "미션 진행 현황 조회에 성공했습니다."),
GET_CUSTOM_MISSIONS_SUCCESS(HttpStatus.OK, "MISSION200_4", "개인 미션 목록 조회에 성공했습니다."),
PROCEED_TRACK_SUCCESS(HttpStatus.OK, "MISSION200_5", "다음 트랙으로 진행되었습니다."),
;

private final HttpStatus status;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.team4.hackerton.domain.mission.code.MissionSuccessCode;
import com.team4.hackerton.domain.mission.dto.request.CustomMissionRequest;
import com.team4.hackerton.domain.mission.dto.response.MissionCompleteResponse;
import com.team4.hackerton.domain.mission.dto.response.MissionItemResponse;
import com.team4.hackerton.domain.mission.dto.response.MissionListResponse;
import com.team4.hackerton.domain.mission.dto.response.MissionProgressResponse;
Expand Down Expand Up @@ -50,19 +51,20 @@ public ApiResponse<MissionListResponse> getTodayMissions(

@Operation(
summary = "미션 완료",
description = "특정 미션을 오늘 완료 처리합니다. 하루에 같은 미션은 1번만 완료할 수 있습니다."
description = "특정 미션을 오늘 완료 처리합니다. 하루에 같은 미션은 1번만 완료할 수 있습니다.\n\n"
+ "- 응답의 `trackCompleted`가 `true`이면 `POST /api/missions/proceed`를 호출하여 다음 트랙으로 이동하세요."
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "미션 완료 성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "미션을 찾을 수 없음 (MISSION404_2)", content = @Content(schema = @Schema(hidden = true))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "이미 완료한 미션 (MISSION409_1)", content = @Content(schema = @Schema(hidden = true)))
})
@PostMapping("/{missionId}/complete")
public ApiResponse<Void> completeMission(
public ApiResponse<MissionCompleteResponse> completeMission(
@AuthenticationPrincipal CustomUserDetails userDetails,
@PathVariable Long missionId) {
missionService.completeMission(userDetails.getUser(), missionId);
return ApiResponse.onSuccess(MissionSuccessCode.COMPLETE_MISSION_SUCCESS);
MissionCompleteResponse response = missionService.completeMission(userDetails.getUser(), missionId);
return ApiResponse.onSuccess(MissionSuccessCode.COMPLETE_MISSION_SUCCESS, response);
}
Comment on lines +54 to 68

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

The doc instructs clients to call POST /api/missions/proceed when trackCompleted is true, but the service already advances the track inside completeMission. This is the downstream symptom of the service-layer issue flagged in MissionService.completeMission (Line 107-119); resolving it there keeps this contract consistent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/team4/hackerton/domain/mission/controller/MissionController.java`
around lines 54 - 68, Update MissionService.completeMission so track advancement
is handled consistently with the documented API contract, then revise
MissionController.completeMission’s Swagger description to match the actual
service behavior and remove the incorrect instruction to call POST
/api/missions/proceed when trackCompleted is true.


@Operation(
Expand Down Expand Up @@ -97,6 +99,22 @@ public ApiResponse<MissionListResponse> getCustomMissions(
return ApiResponse.onSuccess(MissionSuccessCode.GET_CUSTOM_MISSIONS_SUCCESS, response);
}

@Operation(
summary = "다음 트랙으로 진행",
description = "canProceed가 true일 때 현재 트랙을 완료하고 다음 트랙으로 이동합니다. 마지막 트랙이면 nextTrackName은 null입니다."
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "트랙 진행 성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "아직 조건 미달 (MISSION400_2)", content = @Content(schema = @Schema(hidden = true))),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "소속된 트랙 없음 (MISSION404_1)", content = @Content(schema = @Schema(hidden = true)))
})
@PostMapping("/proceed")
public ApiResponse<MissionCompleteResponse> proceedToNextTrack(
@AuthenticationPrincipal CustomUserDetails userDetails) {
MissionCompleteResponse response = missionService.proceedToNextTrack(userDetails.getUser());
return ApiResponse.onSuccess(MissionSuccessCode.PROCEED_TRACK_SUCCESS, response);
}

@Operation(
summary = "미션 진행 현황 조회",
description = "트랙 참여 후 14일 중 미션 완료한 날 수와 다음 단계 진행 가능 여부를 반환합니다.\n\n"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.team4.hackerton.domain.mission.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class MissionCompleteResponse {

@Schema(description = "트랙 완료 및 다음 트랙 전환 여부", example = "true")
private final boolean trackCompleted;

@Schema(description = "전환된 다음 트랙 이름 (마지막 트랙 완료 시 null)", example = "바깥으로 나가기", nullable = true)
private final String nextTrackName;
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ public interface MissionRepository extends JpaRepository<Mission, Long> {

Optional<Mission> findTopByUserOrderByCreatedAtDesc(User user);

Optional<Mission> findTopByUserAndTrackOrderByCreatedAtDesc(User user, Track track);

long countByUserAndTrackAndType(User user, Track track, MissionType type);

List<Mission> findByUserAndTrackAndType(User user, Track track, MissionType type);

List<Mission> findByTrackAndTypeOrderByIdAsc(Track track, MissionType type);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.team4.hackerton.domain.mission.code.MissionErrorCode;
import com.team4.hackerton.domain.mission.dto.request.CustomMissionRequest;
import com.team4.hackerton.domain.mission.dto.response.MissionCompleteResponse;
import com.team4.hackerton.domain.mission.dto.response.MissionItemResponse;
import com.team4.hackerton.domain.mission.dto.response.MissionListResponse;
import com.team4.hackerton.domain.mission.dto.response.MissionProgressResponse;
Expand All @@ -10,9 +11,11 @@
import com.team4.hackerton.domain.mission.repository.MissionLogRepository;
import com.team4.hackerton.domain.mission.repository.MissionRepository;
import com.team4.hackerton.domain.mission.entity.MissionType;
import com.team4.hackerton.domain.track.code.TrackErrorCode;
import com.team4.hackerton.domain.track.entity.Track;
import com.team4.hackerton.domain.track.entity.TrackType;
import com.team4.hackerton.domain.track.entity.UserTrack;
import com.team4.hackerton.domain.track.repository.TrackRepository;
import com.team4.hackerton.domain.track.repository.UserTrackRepository;
import com.team4.hackerton.domain.user.entity.User;
import com.team4.hackerton.global.apiPayload.exception.AppException;
Expand All @@ -35,6 +38,7 @@ public class MissionService {
private final MissionRepository missionRepository;
private final MissionLogRepository missionLogRepository;
private final UserTrackRepository userTrackRepository;
private final TrackRepository trackRepository;

public MissionListResponse getTodayMissions(User user) {
UserTrack userTrack = userTrackRepository.findByUserAndIsCurrentTrue(user)
Expand All @@ -51,18 +55,18 @@ public MissionListResponse getTodayMissions(User user) {
List<MissionItemResponse> result = new ArrayList<>();

if (track.getTrackType() == TrackType.SELF_CARE) {
missionRepository.findByTrackOrderByIdAsc(track).forEach(mission ->
missionRepository.findByTrackAndTypeOrderByIdAsc(track, MissionType.TRACK_DEFAULT).forEach(mission ->
result.add(new MissionItemResponse(mission, completedMissionIds.contains(mission.getId())))
);
} else {
List<Mission> commonMissions = missionRepository.findByTrackOrderByIdAsc(track);
List<Mission> commonMissions = missionRepository.findByTrackAndTypeOrderByIdAsc(track, MissionType.TRACK_DEFAULT);
if (!commonMissions.isEmpty()) {
long dayIndex = ChronoUnit.DAYS.between(userTrack.getJoinedAt(), today);
Mission todayCommon = commonMissions.get((int) (dayIndex % commonMissions.size()));
result.add(new MissionItemResponse(todayCommon, completedMissionIds.contains(todayCommon.getId())));
}

missionRepository.findTopByUserOrderByCreatedAtDesc(user).ifPresent(custom ->
missionRepository.findTopByUserAndTrackOrderByCreatedAtDesc(user, track).ifPresent(custom ->
result.add(new MissionItemResponse(custom, completedMissionIds.contains(custom.getId())))
);
}
Expand All @@ -71,7 +75,7 @@ public MissionListResponse getTodayMissions(User user) {
}

@Transactional
public void completeMission(User user, Long missionId) {
public MissionCompleteResponse completeMission(User user, Long missionId) {
UserTrack userTrack = userTrackRepository.findByUserAndIsCurrentTrue(user)
.orElseThrow(() -> new AppException(MissionErrorCode.USER_TRACK_NOT_FOUND));

Expand All @@ -86,6 +90,56 @@ public void completeMission(User user, Long missionId) {
}

missionLogRepository.save(new MissionLog(user, mission, track, today));

if (!isDayCompleted(user, track, today, userTrack.getJoinedAt())) {
return new MissionCompleteResponse(false, null);
}

List<LocalDate> allDates = missionLogRepository.findCompletedDatesByUserAndTrack(user, track);
int completedDays = (int) allDates.stream()
.filter(date -> isDayCompleted(user, track, date, userTrack.getJoinedAt()))
.count();

if (completedDays < track.getRequiredDays()) {
return new MissionCompleteResponse(false, null);
}

userTrack.complete(today);

TrackType nextType = track.getTrackType().next();
if (nextType == null) {
return new MissionCompleteResponse(true, null);
}

Track nextTrack = trackRepository.findByTrackType(nextType)
.orElseThrow(() -> new AppException(TrackErrorCode.TRACK_NOT_FOUND));
userTrackRepository.save(new UserTrack(user, nextTrack, today));

return new MissionCompleteResponse(true, nextType.getDisplayName());
}
Comment on lines +107 to +119

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the service and controller to inspect the relevant methods and docs.
ast-grep outline src/main/java/com/team4/hackerton/domain/mission/service/MissionService.java --view expanded
printf '\n---\n'
ast-grep outline src/main/java/com/team4/hackerton/domain/mission/controller/MissionController.java --view expanded
printf '\n---\n'
ast-grep outline src/main/java/com/team4/hackerton/domain/mission/dto/MissionCompleteResponse.java --view expanded
printf '\n---\n'
ast-grep outline src/main/java/com/team4/hackerton/domain/mission/dto --view expanded

Repository: 8th-COKERTHON/server-team4

Length of output: 1598


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read only the relevant slices after locating line numbers.
sed -n '1,260p' src/main/java/com/team4/hackerton/domain/mission/service/MissionService.java
printf '\n---CONTROLLER---\n'
sed -n '1,260p' src/main/java/com/team4/hackerton/domain/mission/controller/MissionController.java

Repository: 8th-COKERTHON/server-team4

Length of output: 18642


completeMission should not advance the track here. proceedToNextTrack already calls userTrack.complete(today) and creates the next UserTrack, so this path is mutating the same state twice and makes the /complete/proceed flow inconsistent. Keep completeMission to reporting trackCompleted/nextTrackName only, and leave the transition to proceedToNextTrack.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/team4/hackerton/domain/mission/service/MissionService.java`
around lines 107 - 119, Update completeMission to stop advancing the track:
remove its next-track lookup and UserTrack creation, while retaining only the
completion reporting fields (trackCompleted and nextTrackName). Keep the
transition logic exclusively in proceedToNextTrack, which already completes the
current track and creates the next UserTrack.


private boolean isDayCompleted(User user, Track track, LocalDate date, LocalDate joinedAt) {
List<MissionLog> logs = missionLogRepository.findByUserAndTrackAndPerformedDate(user, track, date);
Set<Long> completedMissionIds = logs.stream()
.map(log -> log.getMission().getId())
.collect(Collectors.toSet());

List<Mission> defaultMissions = missionRepository.findByTrackAndTypeOrderByIdAsc(track, MissionType.TRACK_DEFAULT);

if (track.getTrackType() == TrackType.SELF_CARE) {
if (defaultMissions.isEmpty()) return false;
Set<Long> defaultIds = defaultMissions.stream().map(Mission::getId).collect(Collectors.toSet());
return completedMissionIds.containsAll(defaultIds);
} else {
if (defaultMissions.isEmpty()) return false;
long dayIndex = ChronoUnit.DAYS.between(joinedAt, date);
Mission todayCommon = defaultMissions.get((int) (dayIndex % defaultMissions.size()));
if (!completedMissionIds.contains(todayCommon.getId())) return false;

Set<Long> customMissionIds = missionRepository.findByUserAndTrackAndType(user, track, MissionType.USER_CUSTOM)
.stream().map(Mission::getId).collect(Collectors.toSet());
return completedMissionIds.stream().anyMatch(customMissionIds::contains);
}
Comment on lines +133 to +142

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target file around the reported lines
sed -n '1,240p' src/main/java/com/team4/hackerton/domain/mission/service/MissionService.java

# Search for related mission type logic and custom mission limits
rg -n "SELF_CARE|USER_CUSTOM|isDayCompleted|defaultMissions|completedMissionIds|custom mission|customMissions|missionRepository.findByUserAndTrackAndType" src/main/java

Repository: 8th-COKERTHON/server-team4

Length of output: 16602


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,240p' src/main/java/com/team4/hackerton/domain/mission/service/MissionService.java

printf '\n--- SEARCH ---\n'
rg -n "SELF_CARE|USER_CUSTOM|isDayCompleted|defaultMissions|completedMissionIds|custom mission|customMissions|missionRepository.findByUserAndTrackAndType" src/main/java

Repository: 8th-COKERTHON/server-team4

Length of output: 16618


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map mission-related files and inspect the service and entity definitions.
git ls-files | rg '^src/main/java/.+Mission|^src/main/java/.+Track|^src/main/java/.+MissionType|^src/main/java/.+MissionRepository|^src/main/java/.+TrackRepository'

printf '\n--- MissionService outline ---\n'
ast-grep outline src/main/java/com/team4/hackerton/domain/mission/service/MissionService.java --view expanded || true

printf '\n--- Search for custom-mission constraints ---\n'
rg -n "0-2|2개|USER_CUSTOM|SELF_CARE|create.*custom|add.*custom|findByUserAndTrackAndType|isDayCompleted|completedMissionIds" src/main/java

Repository: 8th-COKERTHON/server-team4

Length of output: 7715


Non-SELF_CARE progression is gated on an optional custom mission.
isDayCompleted requires a completed USER_CUSTOM mission after the common mission, but custom missions are optional and capped at 2. A user with no custom mission can never count a day as complete, so track progress gets stuck.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/team4/hackerton/domain/mission/service/MissionService.java`
around lines 133 - 142, Update isDayCompleted so non-SELF_CARE progression
requires the scheduled common mission but only checks for a completed
USER_CUSTOM mission when custom missions exist; return true after the common
mission for users with no custom missions, while preserving the existing
custom-completion requirement when applicable.

}

@Transactional
Expand Down Expand Up @@ -129,14 +183,48 @@ public MissionListResponse getCustomMissions(User user) {
return new MissionListResponse(result);
}

@Transactional
public MissionCompleteResponse proceedToNextTrack(User user) {
UserTrack userTrack = userTrackRepository.findByUserAndIsCurrentTrue(user)
.orElseThrow(() -> new AppException(MissionErrorCode.USER_TRACK_NOT_FOUND));

Track track = userTrack.getTrack();
LocalDate today = LocalDate.now();

List<LocalDate> allDates = missionLogRepository.findCompletedDatesByUserAndTrack(user, track);
int completedDays = (int) allDates.stream()
.filter(date -> isDayCompleted(user, track, date, userTrack.getJoinedAt()))
.count();

if (completedDays < track.getRequiredDays()) {
throw new AppException(MissionErrorCode.CANNOT_PROCEED);
}

userTrack.complete(today);

TrackType nextType = track.getTrackType().next();
if (nextType == null) {
return new MissionCompleteResponse(true, null);
}

Track nextTrack = trackRepository.findByTrackType(nextType)
.orElseThrow(() -> new AppException(TrackErrorCode.TRACK_NOT_FOUND));
userTrackRepository.save(new UserTrack(user, nextTrack, today));

return new MissionCompleteResponse(true, nextType.getDisplayName());
}

public MissionProgressResponse getMissionProgress(User user) {
UserTrack userTrack = userTrackRepository.findByUserAndIsCurrentTrue(user)
.orElseThrow(() -> new AppException(MissionErrorCode.USER_TRACK_NOT_FOUND));

Track track = userTrack.getTrack();
int requiredDays = track.getRequiredDays();

int completedDays = missionLogRepository.findCompletedDatesByUserAndTrack(user, track).size();
List<LocalDate> allDates = missionLogRepository.findCompletedDatesByUserAndTrack(user, track);
int completedDays = (int) allDates.stream()
.filter(date -> isDayCompleted(user, track, date, userTrack.getJoinedAt()))
.count();

return new MissionProgressResponse(requiredDays, completedDays, completedDays >= requiredDays);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,12 @@ public enum TrackType {
;

private final String displayName;

public TrackType next() {
return switch (this) {
case SELF_CARE -> GO_OUTSIDE;
case GO_OUTSIDE -> CONNECT_PEOPLE;
case CONNECT_PEOPLE -> null;
};
}
}
Loading