diff --git a/src/main/java/com/project/growfit/domain/Goal/controller/GoalController.java b/src/main/java/com/project/growfit/domain/Goal/controller/GoalController.java new file mode 100644 index 0000000..eef1412 --- /dev/null +++ b/src/main/java/com/project/growfit/domain/Goal/controller/GoalController.java @@ -0,0 +1,101 @@ +package com.project.growfit.domain.Goal.controller; + +import com.project.growfit.domain.Goal.dto.request.CreateWeeklyGoalRequestDto; +import com.project.growfit.domain.Goal.dto.request.GoalCertificationRequestDto; +import com.project.growfit.domain.Goal.dto.response.CertificationResponseDto; +import com.project.growfit.domain.Goal.dto.response.GoalResponseDto; +import com.project.growfit.domain.Goal.dto.response.WeeklyGoalResponseDto; +import com.project.growfit.domain.Goal.service.GoalService; +import com.project.growfit.global.response.ResultCode; +import com.project.growfit.global.response.ResultResponse; +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.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDate; +import java.util.List; + +@Validated +@RestController +@RequestMapping("/api/goal") +@RequiredArgsConstructor +@Tag(name = "주간 목표 API", description = "주간 목표 및 일일 인증 관련 API입니다.") +public class GoalController { + + private final GoalService goalService; + + @PostMapping + @Operation(summary = "주간 목표 생성", description = "부모가 주간 목표(WeeklyGoal)를 생성합니다. 시작일, 종료일, 인증 횟수, 목표 리스트를 포함해야 합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "주간 목표 생성 성공"), + @ApiResponse(responseCode = "400", description = "요청 형식 오류 또는 중복된 주간 목표 존재"), + @ApiResponse(responseCode = "403", description = "권한 없음")}) + public ResultResponse createWeeklyGoal(@Valid @RequestBody CreateWeeklyGoalRequestDto request) { + WeeklyGoalResponseDto dto = goalService.createWeeklyGoal(request); + + return ResultResponse.of(ResultCode.WEEKLY_GOAL_CREATE_SUCCESS, dto); + } + + /* 주간 목표 단일 조회 API (날짜 기준) */ + @GetMapping + @Operation(summary = "주간 목표 단일 조회", description = "특정 날짜가 속한 주간 목표를 조회합니다. " + "인증된 부모의 목표만 조회 가능합니다.") + @Parameters({@Parameter(name = "date", description = "조회할 날짜 (yyyy-MM-dd)", required = true, example = "2025-06-24")}) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "주간 목표 조회 성공"), + @ApiResponse(responseCode = "404", description = "해당 날짜에 해당하는 주간 목표가 존재하지 않음")}) + public ResultResponse getWeeklyGoal(@RequestParam("date") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) { + WeeklyGoalResponseDto dto = goalService.getWeeklyGoal(date); + + return ResultResponse.of(ResultCode.WEEKLY_GOAL_FETCH_SUCCESS, dto); + } + + @PostMapping(value = "/{goalId}/certify", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "일일 목표 인증", description = "아이 계정으로 특정 목표에 대한 인증 이미지를 업로드합니다. " + "하루에 한 번만 인증 가능합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "목표 인증 성공"), + @ApiResponse(responseCode = "400", description = "이미 오늘 인증한 경우"), + @ApiResponse(responseCode = "403", description = "권한 없음"), + @ApiResponse(responseCode = "404", description = "해당 목표 ID가 존재하지 않음")}) + public ResultResponse certifyDailyGoal(@PathVariable Long goalId, + @RequestPart(name = "image", required = false) MultipartFile image) { + GoalResponseDto dto = goalService.certifyDailyGoal(goalId, image); + + return ResultResponse.of(ResultCode.GOAL_CERTIFICATION_SUCCESS, dto); + } + + @GetMapping("/{goalId}/certifications") + @Operation(summary = "목표 인증 내역 조회", description = "특정 목표(goalId)에 등록된 인증 내역 리스트를 조회합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "인증 내역 조회 성공"), + @ApiResponse(responseCode = "404", description = "해당 목표가 존재하지 않음")}) + public ResultResponse> getCertifications(@PathVariable Long goalId) { + List dto = goalService.getCertificationsByGoalId(goalId); + + return ResultResponse.of(ResultCode.CERTIFICATION_LIST_FETCH_SUCCESS, dto); + } + + @PutMapping("/{goalId}") + @Operation(summary = "목표 수정", description = "특정 목표(goalId) 타이틀(제목)을 수정합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "인증 내역 조회 성공"), + @ApiResponse(responseCode = "400", description = "이미 인증을 진행중인 목표는 수정 불가"), + @ApiResponse(responseCode = "404", description = "해당 목표가 존재하지 않음")}) + public ResultResponse getCertifications(@PathVariable Long goalId, + @Valid @NotBlank @RequestParam String title) { + GoalResponseDto dto = goalService.updateGoalTitle(goalId, title); + + return ResultResponse.of(ResultCode.GOAL_UPDATE_SUCCESS, dto); + } +} \ No newline at end of file diff --git a/src/main/java/com/project/growfit/domain/Goal/controller/LetterController.java b/src/main/java/com/project/growfit/domain/Goal/controller/LetterController.java new file mode 100644 index 0000000..317b06e --- /dev/null +++ b/src/main/java/com/project/growfit/domain/Goal/controller/LetterController.java @@ -0,0 +1,50 @@ +package com.project.growfit.domain.Goal.controller; + +import com.project.growfit.domain.Goal.dto.request.LetterRequestDto; +import com.project.growfit.domain.Goal.dto.response.LetterBasicResponseDto; +import com.project.growfit.domain.Goal.dto.response.LetterResponseDto; +import com.project.growfit.domain.Goal.service.LetterService; +import com.project.growfit.global.response.ResultCode; +import com.project.growfit.global.response.ResultResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/letter") +@RequiredArgsConstructor +@Tag(name = "칭찬 편지 API", description = "주간 목표가 완료되면 부모가 작성하고 아이가 조회할 수 있는 편지 관련 API입니다.") +public class LetterController { + + private final LetterService letterService; + + @PostMapping("/{weeklyGoalId}") + @Operation(summary = "편지 작성", description = "모든 목표가 COMPLETE 상태일 때 편지를 작성할 수 있습니다.") + public ResultResponse createLetter(@PathVariable Long weeklyGoalId, + @RequestBody @Valid LetterRequestDto request) { + LetterBasicResponseDto dto = letterService.createLetter(weeklyGoalId, request); + return ResultResponse.of(ResultCode.LETTER_CREATE_SUCCESS, dto); + } + + @GetMapping("/{weeklyGoalId}") + @Operation(summary = "편지 조회", description = "아이 또는 부모가 주간 목표에 작성된 편지를 조회합니다.") + public ResultResponse getLetter(@PathVariable Long weeklyGoalId) { + LetterResponseDto dto = letterService.getLetterByWeeklyGoalId(weeklyGoalId); + return ResultResponse.of(ResultCode.LETTER_FETCH_SUCCESS, dto); + } + + @GetMapping + @Operation(summary = "모든 편지 페이징 조회", description = "부모가 지금까지 작성한 모든 편지를 페이징으로 조회합니다.") + public ResultResponse > getAllLetters(@Parameter(description = "페이지 번호", example = "0") @RequestParam(defaultValue = "0") @Min(0) int page, + @Parameter(description = "페이지 크기", example = "10") @RequestParam(defaultValue = "10") @Min(1) int size){ + Page dto = letterService.getAllLetters(page, size); + return ResultResponse.of(ResultCode.LETTER_LIST_FETCH_SUCCESS, dto); + } +} \ No newline at end of file diff --git a/src/main/java/com/project/growfit/domain/Goal/dto/request/CreateWeeklyGoalRequestDto.java b/src/main/java/com/project/growfit/domain/Goal/dto/request/CreateWeeklyGoalRequestDto.java new file mode 100644 index 0000000..0d7b75b --- /dev/null +++ b/src/main/java/com/project/growfit/domain/Goal/dto/request/CreateWeeklyGoalRequestDto.java @@ -0,0 +1,40 @@ +package com.project.growfit.domain.Goal.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Column; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; + +import java.time.LocalDate; +import java.util.List; + +@Schema(description = "주간 목표 생성 요청 DTO") +public record CreateWeeklyGoalRequestDto( + @NotNull + @Schema(description = "시작 날짜", example = "2025-06-24") + LocalDate startDate, + + @NotNull + @Schema(description = "종료 날짜", example = "2025-06-30") + LocalDate endDate, + + @NotNull + @Min(value = 1, message = "인증 횟수는 최소 1회 이상이어야 합니다.") + @Max(value = 7, message = "인증 횟수는 최대 7회까지 가능합니다.") + @Schema(description = "목표 달성 설정 갯수", example = "2") + int certificationCount, + + @Valid + @Size(min = 1, max = 10, message = "목표는 최소 1개 이상, 최대 10개까지 설정 가능합니다.") + @NotEmpty(message = "목표 목록은 비어 있을 수 없습니다.") + @Schema(description = "목표 목록") + List goals +) { + public record GoalItem( + @NotNull @Schema(description = "목표 이름", example = "매일 독서 20분") + String name, + + @NotNull @Schema(description = "아이콘 ID", example = "1") + int iconId + ) {} +} \ No newline at end of file diff --git a/src/main/java/com/project/growfit/domain/Goal/dto/request/GoalCertificationRequestDto.java b/src/main/java/com/project/growfit/domain/Goal/dto/request/GoalCertificationRequestDto.java new file mode 100644 index 0000000..a256931 --- /dev/null +++ b/src/main/java/com/project/growfit/domain/Goal/dto/request/GoalCertificationRequestDto.java @@ -0,0 +1,10 @@ +package com.project.growfit.domain.Goal.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "목표 인증 요청 DTO") +public record GoalCertificationRequestDto( + @NotNull @Schema(description = "이미지 URL", example = "https://s3-url/image.jpg") + String imageUrl +) {} diff --git a/src/main/java/com/project/growfit/domain/Goal/dto/request/LetterRequestDto.java b/src/main/java/com/project/growfit/domain/Goal/dto/request/LetterRequestDto.java new file mode 100644 index 0000000..12acab6 --- /dev/null +++ b/src/main/java/com/project/growfit/domain/Goal/dto/request/LetterRequestDto.java @@ -0,0 +1,11 @@ +package com.project.growfit.domain.Goal.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "칭찬 편지 작성 요청 DTO") +public record LetterRequestDto( + + @Schema(description = "편지 내용", example = "이번 주 목표를 모두 완성했어요! 정말 자랑스럽고 대견해요 :)") + String content +) { +} diff --git a/src/main/java/com/project/growfit/domain/Goal/dto/response/CertificationResponseDto.java b/src/main/java/com/project/growfit/domain/Goal/dto/response/CertificationResponseDto.java new file mode 100644 index 0000000..9ecad81 --- /dev/null +++ b/src/main/java/com/project/growfit/domain/Goal/dto/response/CertificationResponseDto.java @@ -0,0 +1,19 @@ +package com.project.growfit.domain.Goal.dto.response; + +import com.project.growfit.domain.Goal.entity.Certification; + +import java.time.LocalDateTime; + +public record CertificationResponseDto( + Long id, + String imageUrl, + LocalDateTime certifiedAt +) { + public static CertificationResponseDto toDto(Certification certification) { + return new CertificationResponseDto( + certification.getId(), + certification.getImageUrl(), + certification.getCertifiedAt() + ); + } +} diff --git a/src/main/java/com/project/growfit/domain/Goal/dto/response/GoalResponseDto.java b/src/main/java/com/project/growfit/domain/Goal/dto/response/GoalResponseDto.java new file mode 100644 index 0000000..581466e --- /dev/null +++ b/src/main/java/com/project/growfit/domain/Goal/dto/response/GoalResponseDto.java @@ -0,0 +1,24 @@ +package com.project.growfit.domain.Goal.dto.response; + +import com.project.growfit.domain.Goal.entity.Goal; +import com.project.growfit.domain.Goal.entity.GoalStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "개별 목표 응답 DTO") +public record GoalResponseDto( + Long goalId, + String name, + int iconId, + int certifiedCount, + GoalStatus status +) { + public static GoalResponseDto toDto(Goal goal) { + return new GoalResponseDto( + goal.getId(), + goal.getName(), + goal.getIconId(), + goal.getCertificationList().size(), + goal.getStatus() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/project/growfit/domain/Goal/dto/response/LetterBasicResponseDto.java b/src/main/java/com/project/growfit/domain/Goal/dto/response/LetterBasicResponseDto.java new file mode 100644 index 0000000..bf2fca1 --- /dev/null +++ b/src/main/java/com/project/growfit/domain/Goal/dto/response/LetterBasicResponseDto.java @@ -0,0 +1,22 @@ +package com.project.growfit.domain.Goal.dto.response; + +import com.project.growfit.domain.Goal.entity.Letter; +import com.project.growfit.domain.Goal.entity.WeeklyGoal; + +import java.util.List; + +public record LetterBasicResponseDto( + Long letterId, + Long weeklyGoalId, + Long parentId, + String content +) { + public static LetterBasicResponseDto toDto(Letter letter) { + return new LetterBasicResponseDto( + letter.getId(), + letter.getWeeklyGoal().getId(), + letter.getWeeklyGoal().getParent().getId(), + letter.getContent() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/project/growfit/domain/Goal/dto/response/LetterResponseDto.java b/src/main/java/com/project/growfit/domain/Goal/dto/response/LetterResponseDto.java new file mode 100644 index 0000000..8816217 --- /dev/null +++ b/src/main/java/com/project/growfit/domain/Goal/dto/response/LetterResponseDto.java @@ -0,0 +1,34 @@ +package com.project.growfit.domain.Goal.dto.response; + +import com.project.growfit.domain.Goal.entity.Letter; + +import java.util.List; +import java.util.stream.Collectors; + +public record LetterResponseDto( + Long letterId, + String content, + List details +) { + public static LetterResponseDto toDto(Letter letter) { + List detailDtos = letter.getWeeklyGoal().getGoalList().stream() + .filter(goal -> !goal.getCertificationList().isEmpty()) + .map(goal -> new LetterDetailDto( + goal.getId(), + goal.getCertificationList().get(0).getImageUrl() + )) + .collect(Collectors.toList()); + + return new LetterResponseDto( + letter.getId(), + letter.getContent(), + detailDtos + ); + } + + public record LetterDetailDto( + Long goalId, + String imageUrl + ) { + } +} \ No newline at end of file diff --git a/src/main/java/com/project/growfit/domain/Goal/dto/response/WeeklyGoalHistoryResponseDto.java b/src/main/java/com/project/growfit/domain/Goal/dto/response/WeeklyGoalHistoryResponseDto.java new file mode 100644 index 0000000..e501eb8 --- /dev/null +++ b/src/main/java/com/project/growfit/domain/Goal/dto/response/WeeklyGoalHistoryResponseDto.java @@ -0,0 +1,22 @@ +package com.project.growfit.domain.Goal.dto.response; + +import com.project.growfit.domain.Goal.entity.GoalStatus; +import com.project.growfit.domain.Goal.entity.WeeklyGoal; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDate; +import java.util.List; + +@Schema(description = "주간 달성 이력 응답 DTO") +public record WeeklyGoalHistoryResponseDto( + LocalDate startDate, + LocalDate endDate, + List goals +) { + public record GoalHistory( + String name, + GoalStatus status, + int totalDays, + int certifiedDays + ) {} +} \ No newline at end of file diff --git a/src/main/java/com/project/growfit/domain/Goal/dto/response/WeeklyGoalResponseDto.java b/src/main/java/com/project/growfit/domain/Goal/dto/response/WeeklyGoalResponseDto.java new file mode 100644 index 0000000..41b44d1 --- /dev/null +++ b/src/main/java/com/project/growfit/domain/Goal/dto/response/WeeklyGoalResponseDto.java @@ -0,0 +1,41 @@ +package com.project.growfit.domain.Goal.dto.response; + +import com.project.growfit.domain.Goal.entity.GoalStatus; +import com.project.growfit.domain.Goal.entity.WeeklyGoal; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDate; +import java.util.List; + +@Schema(description = "주간 목표 조회 응답 DTO") +public record WeeklyGoalResponseDto( + Long weeklyGoalId, + LocalDate startDate, + LocalDate endDate, + boolean isLetterSent, + int targetCount, + List goals +) { + public static WeeklyGoalResponseDto toDto(WeeklyGoal weeklyGoal) { + List goals = + weeklyGoal.getGoalList().stream().map( + goal -> new GoalResponseDto( + goal.getId(), + goal.getName(), + goal.getIconId(), + goal.getCertificationList().size(), + goal.getStatus() + + )) + .toList(); + + return new WeeklyGoalResponseDto( + weeklyGoal.getId(), + weeklyGoal.getStartDate(), + weeklyGoal.getEndDate(), + weeklyGoal.isLetterSent(), + weeklyGoal.getCertificationCount(), + goals + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/project/growfit/domain/Goal/entity/Certification.java b/src/main/java/com/project/growfit/domain/Goal/entity/Certification.java index 8793f85..3b4bff9 100644 --- a/src/main/java/com/project/growfit/domain/Goal/entity/Certification.java +++ b/src/main/java/com/project/growfit/domain/Goal/entity/Certification.java @@ -1,5 +1,7 @@ package com.project.growfit.domain.Goal.entity; +import com.project.growfit.global.exception.BusinessException; +import com.project.growfit.global.exception.ErrorCode; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -9,7 +11,11 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; + +import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.List; + import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -34,4 +40,27 @@ public class Certification { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "goal_id", nullable = false) private Goal goal; + + public static Certification create(String imageUrl, Goal goal) { + LocalDate now = LocalDate.now(); + checkDateValidity(goal.getCertificationList(), now); + + Certification certification = new Certification(); + certification.imageUrl = imageUrl; + certification.certifiedAt = LocalDateTime.now(); + certification.goal = goal; + return certification; + } + + public void assignToGoal(Goal goal) { + + this.goal = goal; + } + + private static void checkDateValidity(List certifications, LocalDate date) { + boolean exists = certifications.stream() + .anyMatch(cert -> cert.getCertifiedAt().toLocalDate().isEqual(date)); + if (exists) throw new BusinessException(ErrorCode.ALREADY_CERTIFIED_TODAY); + } + } diff --git a/src/main/java/com/project/growfit/domain/Goal/entity/Goal.java b/src/main/java/com/project/growfit/domain/Goal/entity/Goal.java index dea1896..e836a48 100644 --- a/src/main/java/com/project/growfit/domain/Goal/entity/Goal.java +++ b/src/main/java/com/project/growfit/domain/Goal/entity/Goal.java @@ -47,4 +47,32 @@ public class Goal extends BaseEntity { @OneToMany(mappedBy = "goal", cascade = CascadeType.ALL, orphanRemoval = true) private List certificationList = new ArrayList<>(); + + public static Goal create(String name, int iconId, WeeklyGoal weeklyGoal) { + Goal goal = new Goal(); + goal.name = name; + goal.iconId = iconId; + goal.status = GoalStatus.PENDING; + goal.weeklyGoal = weeklyGoal; + return goal; + } + public void addCertification(Certification certification) { + this.certificationList.add(certification); + certification.assignToGoal(this); // 양방향 연관관계 설정 + updateStatusByCertificationCount(); + } + + private void updateStatusByCertificationCount() { + int currentCount = this.certificationList.size(); + int requiredCount = this.weeklyGoal.getCertificationCount(); + if (currentCount == requiredCount) { + this.status = GoalStatus.COMPLETE; + } else { + this.status = GoalStatus.PROGRESS; + } + } + + public void updateTitle(String title) { + this.name = title; + } } diff --git a/src/main/java/com/project/growfit/domain/Goal/entity/Letter.java b/src/main/java/com/project/growfit/domain/Goal/entity/Letter.java new file mode 100644 index 0000000..1009837 --- /dev/null +++ b/src/main/java/com/project/growfit/domain/Goal/entity/Letter.java @@ -0,0 +1,32 @@ +package com.project.growfit.domain.Goal.entity; + +import com.project.growfit.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "letter") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Letter extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 1000) + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "weekly_goal_id", nullable = false) + private WeeklyGoal weeklyGoal; + + public static Letter create(String content, WeeklyGoal weeklyGoal) { + Letter letter = new Letter(); + letter.content = content; + letter.weeklyGoal = weeklyGoal; + return letter; + } +} \ No newline at end of file diff --git a/src/main/java/com/project/growfit/domain/Goal/entity/WeeklyGoal.java b/src/main/java/com/project/growfit/domain/Goal/entity/WeeklyGoal.java index c43e9a3..2f7a139 100644 --- a/src/main/java/com/project/growfit/domain/Goal/entity/WeeklyGoal.java +++ b/src/main/java/com/project/growfit/domain/Goal/entity/WeeklyGoal.java @@ -1,5 +1,6 @@ package com.project.growfit.domain.Goal.entity; +import com.project.growfit.domain.Goal.dto.request.CreateWeeklyGoalRequestDto; import com.project.growfit.domain.User.entity.Parent; import com.project.growfit.global.entity.BaseEntity; import jakarta.persistence.CascadeType; @@ -49,4 +50,33 @@ public class WeeklyGoal extends BaseEntity { @OneToMany(mappedBy = "weeklyGoal", cascade = CascadeType.ALL, orphanRemoval = true) private List goalList = new ArrayList<>(); + + public static WeeklyGoal create(LocalDate startDate, LocalDate endDate, int certificationCount, Parent parent) { + WeeklyGoal weeklyGoal = new WeeklyGoal(); + weeklyGoal.startDate = startDate; + weeklyGoal.endDate = endDate; + weeklyGoal.certificationCount = certificationCount; + weeklyGoal.isLetterSent = false; + weeklyGoal.parent = parent; + return weeklyGoal; + } + + public static WeeklyGoal create(LocalDate startDate, LocalDate endDate, int certificationCount, Parent parent, List goalItems) { + WeeklyGoal weeklyGoal = new WeeklyGoal(); + weeklyGoal.startDate = startDate; + weeklyGoal.endDate = endDate; + weeklyGoal.certificationCount = certificationCount; + weeklyGoal.isLetterSent = false; + weeklyGoal.parent = parent; + List goals = goalItems.stream() + .map(item -> Goal.create(item.name(), item.iconId(), weeklyGoal)) + .toList(); + weeklyGoal.goalList.addAll(goals); + + return weeklyGoal; + } + + public void markLetterSent() { + this.isLetterSent = true; + } } diff --git a/src/main/java/com/project/growfit/domain/Goal/repository/CertificationRepository.java b/src/main/java/com/project/growfit/domain/Goal/repository/CertificationRepository.java new file mode 100644 index 0000000..96fa39c --- /dev/null +++ b/src/main/java/com/project/growfit/domain/Goal/repository/CertificationRepository.java @@ -0,0 +1,13 @@ +package com.project.growfit.domain.Goal.repository; + +import com.project.growfit.domain.Goal.entity.Certification; +import com.project.growfit.domain.Goal.entity.Goal; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; + +@Repository +public interface CertificationRepository extends JpaRepository { + boolean existsByGoalAndCertifiedAtBetween(Goal goal, LocalDateTime start, LocalDateTime end); +} diff --git a/src/main/java/com/project/growfit/domain/Goal/repository/GoalRepository.java b/src/main/java/com/project/growfit/domain/Goal/repository/GoalRepository.java new file mode 100644 index 0000000..051859d --- /dev/null +++ b/src/main/java/com/project/growfit/domain/Goal/repository/GoalRepository.java @@ -0,0 +1,14 @@ +package com.project.growfit.domain.Goal.repository; + +import com.project.growfit.domain.Goal.entity.Certification; +import com.project.growfit.domain.Goal.entity.Goal; +import com.project.growfit.domain.Goal.entity.WeeklyGoal; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface GoalRepository extends JpaRepository { + List findByWeeklyGoal(WeeklyGoal weeklyGoal); +} diff --git a/src/main/java/com/project/growfit/domain/Goal/repository/LetterRepository.java b/src/main/java/com/project/growfit/domain/Goal/repository/LetterRepository.java new file mode 100644 index 0000000..609054f --- /dev/null +++ b/src/main/java/com/project/growfit/domain/Goal/repository/LetterRepository.java @@ -0,0 +1,16 @@ +package com.project.growfit.domain.Goal.repository; + +import com.project.growfit.domain.Goal.entity.Letter; +import com.project.growfit.domain.Goal.entity.WeeklyGoal; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface LetterRepository extends JpaRepository { + Optional findByWeeklyGoal(WeeklyGoal weeklyGoal); + Page findAllByWeeklyGoal_Parent_Id(Long parentId, Pageable pageable); +} \ No newline at end of file diff --git a/src/main/java/com/project/growfit/domain/Goal/repository/WeeklyGoalRepository.java b/src/main/java/com/project/growfit/domain/Goal/repository/WeeklyGoalRepository.java new file mode 100644 index 0000000..275c1aa --- /dev/null +++ b/src/main/java/com/project/growfit/domain/Goal/repository/WeeklyGoalRepository.java @@ -0,0 +1,21 @@ +package com.project.growfit.domain.Goal.repository; + +import com.project.growfit.domain.Goal.entity.WeeklyGoal; +import com.project.growfit.domain.User.entity.Parent; +import io.lettuce.core.dynamic.annotation.Param; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +@Repository +public interface WeeklyGoalRepository extends JpaRepository { + // 주어진 start~end 날짜 범위와 겹치는 기존 주간 목표가 해당 parent에게 존재하는지 검사합니다. + boolean existsByParentAndStartDateLessThanEqualAndEndDateGreaterThanEqual(Parent parent, LocalDate start, LocalDate end); + // 해당 날짜(date)가 포함된 주간 목표가 parent에게 존재하는지 단일 주간 목표를 찾는 메서드입니다. + Optional findByParentAndStartDateLessThanEqualAndEndDateGreaterThanEqual(Parent parent, LocalDate startDate, LocalDate endDate); + // +} diff --git a/src/main/java/com/project/growfit/domain/Goal/service/GoalService.java b/src/main/java/com/project/growfit/domain/Goal/service/GoalService.java new file mode 100644 index 0000000..f844254 --- /dev/null +++ b/src/main/java/com/project/growfit/domain/Goal/service/GoalService.java @@ -0,0 +1,22 @@ +package com.project.growfit.domain.Goal.service; + +import com.project.growfit.domain.Goal.dto.request.CreateWeeklyGoalRequestDto; +import com.project.growfit.domain.Goal.dto.request.GoalCertificationRequestDto; +import com.project.growfit.domain.Goal.dto.response.CertificationResponseDto; +import com.project.growfit.domain.Goal.dto.response.GoalResponseDto; +import com.project.growfit.domain.Goal.dto.response.WeeklyGoalHistoryResponseDto; +import com.project.growfit.domain.Goal.dto.response.WeeklyGoalResponseDto; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDate; +import java.util.List; + +public interface GoalService { + WeeklyGoalResponseDto createWeeklyGoal(CreateWeeklyGoalRequestDto request); + WeeklyGoalResponseDto getWeeklyGoal(LocalDate date); + + GoalResponseDto certifyDailyGoal(Long goalId, MultipartFile image); + List getCertificationsByGoalId(Long goalId); + + GoalResponseDto updateGoalTitle(Long goalId, String title); +} diff --git a/src/main/java/com/project/growfit/domain/Goal/service/LetterService.java b/src/main/java/com/project/growfit/domain/Goal/service/LetterService.java new file mode 100644 index 0000000..e8f22a4 --- /dev/null +++ b/src/main/java/com/project/growfit/domain/Goal/service/LetterService.java @@ -0,0 +1,13 @@ +package com.project.growfit.domain.Goal.service; + +import com.project.growfit.domain.Goal.dto.request.LetterRequestDto; +import com.project.growfit.domain.Goal.dto.response.LetterBasicResponseDto; +import com.project.growfit.domain.Goal.dto.response.LetterResponseDto; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface LetterService { + LetterBasicResponseDto createLetter(Long weeklyGoalId, LetterRequestDto request); + LetterResponseDto getLetterByWeeklyGoalId(Long weeklyGoalId); + Page getAllLetters(int page, int size); +} \ No newline at end of file diff --git a/src/main/java/com/project/growfit/domain/Goal/service/impl/GoalServiceImpl.java b/src/main/java/com/project/growfit/domain/Goal/service/impl/GoalServiceImpl.java new file mode 100644 index 0000000..99606de --- /dev/null +++ b/src/main/java/com/project/growfit/domain/Goal/service/impl/GoalServiceImpl.java @@ -0,0 +1,123 @@ +package com.project.growfit.domain.Goal.service.impl; + +import com.project.growfit.domain.Goal.dto.request.CreateWeeklyGoalRequestDto; +import com.project.growfit.domain.Goal.dto.response.CertificationResponseDto; +import com.project.growfit.domain.Goal.dto.response.GoalResponseDto; +import com.project.growfit.domain.Goal.dto.response.WeeklyGoalResponseDto; +import com.project.growfit.domain.Goal.entity.Certification; +import com.project.growfit.domain.Goal.entity.Goal; +import com.project.growfit.domain.Goal.entity.GoalStatus; +import com.project.growfit.domain.Goal.entity.WeeklyGoal; +import com.project.growfit.domain.Goal.repository.CertificationRepository; +import com.project.growfit.domain.Goal.repository.GoalRepository; +import com.project.growfit.domain.Goal.repository.WeeklyGoalRepository; +import com.project.growfit.domain.Goal.service.GoalService; +import com.project.growfit.domain.User.entity.Child; +import com.project.growfit.domain.User.entity.Parent; +import com.project.growfit.global.auth.service.AuthenticatedUserProvider; +import com.project.growfit.global.exception.BusinessException; +import com.project.growfit.global.exception.ErrorCode; +import com.project.growfit.global.s3.service.S3UploadService; +import org.springframework.transaction.annotation.*; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class GoalServiceImpl implements GoalService { + + private final WeeklyGoalRepository weeklyGoalRepository; + private final GoalRepository goalRepository; + private final CertificationRepository certificationRepository; + private final AuthenticatedUserProvider authenticatedUserProvider; + private final S3UploadService s3UploadService; + private String imageUploadPath = "goal/"; + + @Override + public WeeklyGoalResponseDto createWeeklyGoal(CreateWeeklyGoalRequestDto request) { + Parent parent = authenticatedUserProvider.getAuthenticatedParent(); + + validateDuplicateWeeklyGoal(request, parent); + WeeklyGoal weeklyGoal = WeeklyGoal.create( + request.startDate(), + request.endDate(), + request.certificationCount(), + parent, + request.goals()); + weeklyGoalRepository.save(weeklyGoal); + + return WeeklyGoalResponseDto.toDto(weeklyGoal); + } + + @Override + @Transactional(readOnly = true) + public WeeklyGoalResponseDto getWeeklyGoal(LocalDate date) { + Parent parent = authenticatedUserProvider.getAuthenticatedParent(); + WeeklyGoal weeklyGoal = findWeeklyGoalByDate(date, parent); + + return WeeklyGoalResponseDto.toDto(weeklyGoal); + } + + @Override + @Transactional + public GoalResponseDto certifyDailyGoal(Long goalId, MultipartFile image) { + Child child = authenticatedUserProvider.getAuthenticatedChild(); + Goal goal = findGoalOrThrow(goalId); + WeeklyGoal weeklyGoal = goal.getWeeklyGoal(); + LocalDate today = LocalDate.now(); + + if (!weeklyGoal.getParent().getChildren().contains(child)) throw new BusinessException(ErrorCode.FORBIDDEN_ACCESS); + if (image == null || image.isEmpty()) throw new BusinessException(ErrorCode.EMPTY_IMAGE_FILE); + if (today.isBefore(weeklyGoal.getStartDate()) || today.isAfter(weeklyGoal.getEndDate())) throw new BusinessException(ErrorCode.CERTIFICATION_NOT_ALLOWED_DATE); + + String imageUrl = s3UploadService.saveFile(image, imageUploadPath); + Certification certification = Certification.create(imageUrl, goal); + + goal.addCertification(certification); + certificationRepository.save(certification); + + return GoalResponseDto.toDto(goal); + } + + @Override + @Transactional(readOnly = true) + public List getCertificationsByGoalId(Long goalId) { + Goal goal = goalRepository.findById(goalId) + .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND_GOAL)); + + return goal.getCertificationList().stream() + .map(CertificationResponseDto::toDto) + .toList(); + } + + @Override + @Transactional + public GoalResponseDto updateGoalTitle(Long goalId, String title) { + Goal goal = findGoalOrThrow(goalId); + if (goal.getStatus() != GoalStatus.PENDING) throw new BusinessException(ErrorCode.CANNOT_UPDATE_CERTIFIED_GOAL); + goal.updateTitle(title); + + return GoalResponseDto.toDto(goal); + } + + private Goal findGoalOrThrow (Long goalId) { + return goalRepository.findById(goalId) + .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND_GOAL)); + } + + private WeeklyGoal findWeeklyGoalByDate(LocalDate date, Parent parent) { + return weeklyGoalRepository.findByParentAndStartDateLessThanEqualAndEndDateGreaterThanEqual(parent, date, date) + .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND_WEEKLY_GOAL)); + } + + private void validateDuplicateWeeklyGoal(CreateWeeklyGoalRequestDto request, Parent parent) { + boolean exists = weeklyGoalRepository.existsByParentAndStartDateLessThanEqualAndEndDateGreaterThanEqual( + parent, request.endDate(), request.startDate()); + if (exists) throw new BusinessException(ErrorCode.DUPLICATE_WEEKLY_GOAL); + } +} diff --git a/src/main/java/com/project/growfit/domain/Goal/service/impl/LetterServiceImpl.java b/src/main/java/com/project/growfit/domain/Goal/service/impl/LetterServiceImpl.java new file mode 100644 index 0000000..3496512 --- /dev/null +++ b/src/main/java/com/project/growfit/domain/Goal/service/impl/LetterServiceImpl.java @@ -0,0 +1,85 @@ +package com.project.growfit.domain.Goal.service.impl; + +import com.project.growfit.domain.Goal.dto.request.LetterRequestDto; +import com.project.growfit.domain.Goal.dto.response.LetterBasicResponseDto; +import com.project.growfit.domain.Goal.dto.response.LetterResponseDto; +import com.project.growfit.domain.Goal.entity.GoalStatus; +import com.project.growfit.domain.Goal.entity.Letter; +import com.project.growfit.domain.Goal.entity.WeeklyGoal; +import com.project.growfit.domain.Goal.repository.LetterRepository; +import com.project.growfit.domain.Goal.repository.WeeklyGoalRepository; +import com.project.growfit.domain.Goal.service.LetterService; +import com.project.growfit.domain.User.entity.Parent; +import com.project.growfit.global.auth.service.AuthenticatedUserProvider; +import com.project.growfit.global.exception.BusinessException; +import com.project.growfit.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class LetterServiceImpl implements LetterService { + + private final LetterRepository letterRepository; + private final WeeklyGoalRepository weeklyGoalRepository; + private final AuthenticatedUserProvider auth; + + @Override + @Transactional + public LetterBasicResponseDto createLetter(Long weeklyGoalId, LetterRequestDto request) { + Parent parent = auth.getAuthenticatedParent(); + WeeklyGoal weeklyGoal = getWeeklyGoalOrThrow(weeklyGoalId); + + validateParentAccess(weeklyGoal, parent); + validateAllGoalsComplete(weeklyGoal); + validateLetterNotSent(weeklyGoal); + + Letter letter = Letter.create(request.content(), weeklyGoal); + weeklyGoal.markLetterSent(); + letterRepository.save(letter); + return LetterBasicResponseDto.toDto(letter); + } + + @Override + @Transactional(readOnly = true) + public LetterResponseDto getLetterByWeeklyGoalId(Long weeklyGoalId) { + WeeklyGoal weeklyGoal = getWeeklyGoalOrThrow(weeklyGoalId); + + Letter letter = letterRepository.findByWeeklyGoal(weeklyGoal) + .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND_LETTER)); + + return LetterResponseDto.toDto(letter); + } + + @Override + @Transactional(readOnly = true) + public Page getAllLetters(int page, int size) { + Long parentId = auth.getAuthenticatedParent().getId(); + Pageable pageable = PageRequest.of(page, size); + return letterRepository.findAllByWeeklyGoal_Parent_Id(parentId, pageable) + .map(LetterBasicResponseDto::toDto); + } + + private WeeklyGoal getWeeklyGoalOrThrow(Long weeklyGoalId) { + return weeklyGoalRepository.findById(weeklyGoalId) + .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND_WEEKLY_GOAL)); + } + + private static void validateParentAccess(WeeklyGoal weeklyGoal, Parent parent) { + if (!weeklyGoal.getParent().equals(parent)) throw new BusinessException(ErrorCode.FORBIDDEN_ACCESS); + } + + private static void validateAllGoalsComplete(WeeklyGoal weeklyGoal) { + boolean allComplete = weeklyGoal.getGoalList().stream() + .allMatch(goal -> goal.getStatus() == GoalStatus.COMPLETE); + if (!allComplete) throw new BusinessException(ErrorCode.NOT_COMPLETED_ALL_GOALS); + } + + private static void validateLetterNotSent(WeeklyGoal weeklyGoal) { + if (weeklyGoal.isLetterSent()) throw new BusinessException(ErrorCode.LETTER_ALREADY_SENT); + } +} \ No newline at end of file diff --git a/src/main/java/com/project/growfit/global/exception/ErrorCode.java b/src/main/java/com/project/growfit/global/exception/ErrorCode.java index ae2a482..3d603ff 100644 --- a/src/main/java/com/project/growfit/global/exception/ErrorCode.java +++ b/src/main/java/com/project/growfit/global/exception/ErrorCode.java @@ -74,7 +74,23 @@ public enum ErrorCode { EMPTY_IMAGE_FILE(HttpStatus.BAD_REQUEST, "이미지를 첨부해주세요."), NO_IMAGE_TO_DELETE(HttpStatus.BAD_REQUEST, "삭제할 이미지가 존재하지 않습니다."), - NO_FOOD_FOR_STICKER(HttpStatus.BAD_REQUEST, "음식이 등록되어야 스티커를 남길 수 있습니다."); + NO_FOOD_FOR_STICKER(HttpStatus.BAD_REQUEST, "음식이 등록되어야 스티커를 남길 수 있습니다."), + + // Goal + + //Letter + NOT_COMPLETED_ALL_GOALS(HttpStatus.BAD_REQUEST, "아직 완료되지 않은 목표가 있습니다."), + LETTER_ALREADY_SENT(HttpStatus.BAD_REQUEST, "해당 주간 목표에는 이미 편지가 작성되었습니다."), + NOT_FOUND_LETTER(HttpStatus.NOT_FOUND, "편지를 찾을 수 없습니다."), + + // 주간 목표 관련 + DUPLICATE_WEEKLY_GOAL(HttpStatus.BAD_REQUEST, "해당 기간에 이미 주간 목표가 존재합니다."), + NOT_FOUND_WEEKLY_GOAL(HttpStatus.NOT_FOUND, "해당 날짜에 해당하는 주간 목표가 존재하지 않습니다."), + NOT_FOUND_GOAL(HttpStatus.NOT_FOUND, "해당 목표를 찾을 수 없습니다."), + CERTIFICATION_NOT_ALLOWED_DATE(HttpStatus.BAD_REQUEST, "해당 날짜에는 인증이 불가능합니다."), + ALREADY_CERTIFIED_TODAY(HttpStatus.BAD_REQUEST, "오늘은 이미 목표를 인증했습니다."), + CANNOT_UPDATE_CERTIFIED_GOAL(HttpStatus.BAD_REQUEST, "이미 인증이 시작된 목표는 수정할 수 없습니다."); + private final HttpStatus status; private final String message; } diff --git a/src/main/java/com/project/growfit/global/response/ResultCode.java b/src/main/java/com/project/growfit/global/response/ResultCode.java index b79683d..bb108f6 100644 --- a/src/main/java/com/project/growfit/global/response/ResultCode.java +++ b/src/main/java/com/project/growfit/global/response/ResultCode.java @@ -52,6 +52,17 @@ public enum ResultCode { DIET_DATE_EMPTY(HttpStatus.OK, "해당 날짜에 기록된 식단이 없습니다."), CALENDAR_OVERVIEW_SUCCESS(HttpStatus.OK, "식단 캘린더 조회에 성공했습니다."), + // Goal + WEEKLY_GOAL_CREATE_SUCCESS(HttpStatus.CREATED, "주간 목표가 성공적으로 생성되었습니다."), + GOAL_UPDATE_SUCCESS(HttpStatus.OK, "목표가 성공적으로 수정되었습니다."), + WEEKLY_GOAL_FETCH_SUCCESS(HttpStatus.OK, "주간 목표 조회에 성공했습니다."), + GOAL_CERTIFICATION_SUCCESS(HttpStatus.CREATED, "목표 인증이 성공적으로 완료되었습니다."), + CERTIFICATION_LIST_FETCH_SUCCESS(HttpStatus.OK, "인증샷 목록 조회에 성공했습니다."), + + // Letter + LETTER_CREATE_SUCCESS(HttpStatus.CREATED, "편지가 성공적으로 작성되었습니다."), + LETTER_FETCH_SUCCESS(HttpStatus.OK, "편지를 성공적으로 조회했습니다."), + LETTER_LIST_FETCH_SUCCESS(HttpStatus.OK, "모든 편지를 성공적으로 조회했습니다."), // Community CREATE_POST_SUCCESS(HttpStatus.OK, "글 등록 성공."),