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
@@ -1,21 +1,27 @@
package com.atmd.backend.domain.group.controller;

import com.atmd.backend.domain.group.dto.request.GroupCreateRequestDTO;
import com.atmd.backend.domain.group.dto.response.GroupDailyWorkoutBarResponseDTO;
import com.atmd.backend.domain.group.dto.response.GroupInviteLinkResponseDTO;
import com.atmd.backend.domain.group.dto.response.GroupJoinResponseDTO;
import com.atmd.backend.domain.group.dto.response.GroupMemberResponseDTO;
import com.atmd.backend.domain.group.dto.response.GroupMonthlyWorkoutBarResponseDTO;
import com.atmd.backend.domain.group.dto.response.GroupResponseDTO;
import com.atmd.backend.domain.group.service.GroupService;
import com.atmd.backend.domain.group.service.GroupWorkoutRecordService;
import com.atmd.backend.global.auth.util.SecurityUtil;
import com.atmd.backend.global.common.response.ApiResponse;
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 lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.time.LocalDate;
import java.time.YearMonth;
import java.util.List;

@Tag(name = "Group", description = "그룹 API")
Expand All @@ -25,6 +31,7 @@
public class GroupController {

private final GroupService groupService;
private final GroupWorkoutRecordService groupWorkoutRecordService;

@Operation(
summary = "그룹 생성",
Expand Down Expand Up @@ -108,4 +115,40 @@ public ResponseEntity<ApiResponse<Void>> leaveGroup(@PathVariable Long groupId)
groupService.leaveGroup(groupId, SecurityUtil.getCurrentUserId());
return ResponseEntity.ok(ApiResponse.success(null));
}

@Operation(
summary = "그룹 일별 운동량 조회 (바 차트용)",
description = """
특정 날짜(기본값: 오늘, Asia/Seoul) 기준으로 그룹에 소속된 모든 멤버의
운동별(CHAIR_STAND / PUSH_UP / SIT_UP / PLANK) 실행량을 반환합니다.
운동 기록이 없는 멤버는 0으로 채워져 함께 반환됩니다.
"""
)
@GetMapping("/{groupId}/workout-records/daily")
public ResponseEntity<ApiResponse<GroupDailyWorkoutBarResponseDTO>> getDailyWorkoutBar(
@PathVariable Long groupId,
@Parameter(description = "조회 날짜 (yyyy-MM-dd). 미지정 시 오늘 기준.")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
return ResponseEntity.ok(ApiResponse.success(
groupWorkoutRecordService.getDailyBar(groupId, SecurityUtil.getCurrentUserId(), date)));
}

@Operation(
summary = "그룹 월별 운동량 조회 (바 차트용)",
description = """
특정 월(기본값: 이번 달, Asia/Seoul) 기준으로 그룹에 소속된 모든 멤버의
운동별(CHAIR_STAND / PUSH_UP / SIT_UP / PLANK) 누적 실행량을 반환합니다.
운동 기록이 없는 멤버는 0으로 채워져 함께 반환됩니다.
"""
)
@GetMapping("/{groupId}/workout-records/monthly")
public ResponseEntity<ApiResponse<GroupMonthlyWorkoutBarResponseDTO>> getMonthlyWorkoutBar(
@PathVariable Long groupId,
@Parameter(description = "조회 연월 (yyyy-MM). 미지정 시 이번 달 기준.")
@RequestParam(required = false)
@DateTimeFormat(pattern = "yyyy-MM") YearMonth yearMonth) {
return ResponseEntity.ok(ApiResponse.success(
groupWorkoutRecordService.getMonthlyBar(groupId, SecurityUtil.getCurrentUserId(), yearMonth)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.atmd.backend.domain.group.dto.response;

import lombok.Builder;
import lombok.Getter;

import java.time.LocalDate;
import java.util.List;

@Getter
@Builder
public class GroupDailyWorkoutBarResponseDTO {

private Long groupId;
private String groupName;
private LocalDate date;
private List<GroupMemberWorkoutBarResponseDTO> members;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.atmd.backend.domain.group.dto.response;

import com.atmd.backend.domain.group.repository.GroupMemberWorkoutAggregateProjection;
import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
public class GroupMemberWorkoutBarResponseDTO {

private Long userId;
private String nickname;
private boolean isOwner;
private long chairStandCount;
private long pushUpCount;
private long sitUpCount;
private long plankDurationMs;

public static GroupMemberWorkoutBarResponseDTO from(GroupMemberWorkoutAggregateProjection p) {
return GroupMemberWorkoutBarResponseDTO.builder()
.userId(p.getUserId())
.nickname(p.getNickname())
.isOwner(Boolean.TRUE.equals(p.getIsOwner()))
.chairStandCount(p.getChairStandCount() == null ? 0L : p.getChairStandCount())
.pushUpCount(p.getPushUpCount() == null ? 0L : p.getPushUpCount())
.sitUpCount(p.getSitUpCount() == null ? 0L : p.getSitUpCount())
.plankDurationMs(p.getPlankDurationMs() == null ? 0L : p.getPlankDurationMs())
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.atmd.backend.domain.group.dto.response;

import lombok.Builder;
import lombok.Getter;

import java.time.YearMonth;
import java.util.List;

@Getter
@Builder
public class GroupMonthlyWorkoutBarResponseDTO {

private Long groupId;
private String groupName;
private YearMonth yearMonth;
private List<GroupMemberWorkoutBarResponseDTO> members;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,61 @@
import org.springframework.data.repository.query.Param;

import java.time.LocalDate;
import java.util.List;

public interface GroupDailyWorkoutRecordRepository extends JpaRepository<GroupDailyWorkoutRecord, Long> {

@Query(value = """
SELECT
u.id AS userId,
u.nickname AS nickname,
(u.id = g.owner_id) AS isOwner,
COALESCE(r.chair_stand_count, 0) AS chairStandCount,
COALESCE(r.push_up_count, 0) AS pushUpCount,
COALESCE(r.sit_up_count, 0) AS sitUpCount,
COALESCE(r.plank_duration_ms, 0) AS plankDurationMs
FROM group_users gu
JOIN users u ON u.id = gu.user_id
JOIN groups g ON g.id = gu.group_id
LEFT JOIN group_daily_workout_records r
ON r.group_id = gu.group_id
AND r.user_id = gu.user_id
AND r.workout_date = :workoutDate
WHERE gu.group_id = :groupId
ORDER BY u.id
""", nativeQuery = true)
List<GroupMemberWorkoutAggregateProjection> aggregateByGroupAndDate(
@Param("groupId") Long groupId,
@Param("workoutDate") LocalDate workoutDate
);

@Query(value = """
SELECT
u.id AS userId,
u.nickname AS nickname,
(u.id = g.owner_id) AS isOwner,
COALESCE(SUM(r.chair_stand_count), 0) AS chairStandCount,
COALESCE(SUM(r.push_up_count), 0) AS pushUpCount,
COALESCE(SUM(r.sit_up_count), 0) AS sitUpCount,
COALESCE(SUM(r.plank_duration_ms), 0) AS plankDurationMs
FROM group_users gu
JOIN users u ON u.id = gu.user_id
JOIN groups g ON g.id = gu.group_id
LEFT JOIN group_daily_workout_records r
ON r.group_id = gu.group_id
AND r.user_id = gu.user_id
AND r.workout_date >= :startDate
AND r.workout_date < :endDateExclusive
WHERE gu.group_id = :groupId
GROUP BY u.id, u.nickname, g.owner_id
ORDER BY u.id
""", nativeQuery = true)
List<GroupMemberWorkoutAggregateProjection> aggregateByGroupAndDateRange(
@Param("groupId") Long groupId,
@Param("startDate") LocalDate startDate,
@Param("endDateExclusive") LocalDate endDateExclusive
);

@Modifying
@Query(value = """
INSERT INTO group_daily_workout_records (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.atmd.backend.domain.group.repository;

public interface GroupMemberWorkoutAggregateProjection {
Long getUserId();
String getNickname();
Boolean getIsOwner();
Long getChairStandCount();
Long getPushUpCount();
Long getSitUpCount();
Long getPlankDurationMs();
}
Original file line number Diff line number Diff line change
@@ -1,22 +1,33 @@
package com.atmd.backend.domain.group.service;

import com.atmd.backend.domain.fitness.enums.ExerciseType;
import com.atmd.backend.domain.group.dto.response.GroupDailyWorkoutBarResponseDTO;
import com.atmd.backend.domain.group.dto.response.GroupMemberWorkoutBarResponseDTO;
import com.atmd.backend.domain.group.dto.response.GroupMonthlyWorkoutBarResponseDTO;
import com.atmd.backend.domain.group.entity.Group;
import com.atmd.backend.domain.group.entity.GroupUser;
import com.atmd.backend.domain.group.exception.GroupErrorCode;
import com.atmd.backend.domain.group.repository.GroupDailyWorkoutRecordRepository;
import com.atmd.backend.domain.group.repository.GroupRepository;
import com.atmd.backend.domain.group.repository.GroupUserRepository;
import com.atmd.backend.global.common.exception.GeneralException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.time.ZoneId;
import java.util.List;

@Service
@RequiredArgsConstructor
public class GroupWorkoutRecordService {
private static final ZoneId SERVER_ZONE = ZoneId.of("Asia/Seoul");

private final GroupRepository groupRepository;
private final GroupUserRepository groupUserRepository;
private final GroupDailyWorkoutRecordRepository groupDailyWorkoutRecordRepository;

Expand Down Expand Up @@ -49,4 +60,49 @@ public void accumulateCompletedWorkout(
);
}
}

@Transactional(readOnly = true)
public GroupDailyWorkoutBarResponseDTO getDailyBar(Long groupId, Long viewerId, LocalDate date) {
Group group = findGroupAndCheckMembership(groupId, viewerId);
LocalDate targetDate = date != null ? date : LocalDate.now(SERVER_ZONE);
List<GroupMemberWorkoutBarResponseDTO> members = groupDailyWorkoutRecordRepository
.aggregateByGroupAndDate(groupId, targetDate)
.stream()
.map(GroupMemberWorkoutBarResponseDTO::from)
.toList();
return GroupDailyWorkoutBarResponseDTO.builder()
.groupId(group.getId())
.groupName(group.getName())
.date(targetDate)
.members(members)
.build();
}

@Transactional(readOnly = true)
public GroupMonthlyWorkoutBarResponseDTO getMonthlyBar(Long groupId, Long viewerId, YearMonth yearMonth) {
Group group = findGroupAndCheckMembership(groupId, viewerId);
YearMonth targetMonth = yearMonth != null ? yearMonth : YearMonth.now(SERVER_ZONE);
LocalDate startDate = targetMonth.atDay(1);
LocalDate endDateExclusive = targetMonth.plusMonths(1).atDay(1);
List<GroupMemberWorkoutBarResponseDTO> members = groupDailyWorkoutRecordRepository
.aggregateByGroupAndDateRange(groupId, startDate, endDateExclusive)
.stream()
.map(GroupMemberWorkoutBarResponseDTO::from)
.toList();
return GroupMonthlyWorkoutBarResponseDTO.builder()
.groupId(group.getId())
.groupName(group.getName())
.yearMonth(targetMonth)
.members(members)
.build();
}

private Group findGroupAndCheckMembership(Long groupId, Long userId) {
Group group = groupRepository.findByIdAndIsDeletedFalse(groupId)
.orElseThrow(() -> new GeneralException(GroupErrorCode.GROUP_NOT_FOUND));
if (!groupUserRepository.existsByGroupIdAndUserId(groupId, userId)) {
throw new GeneralException(GroupErrorCode.ACCESS_DENIED);
}
return group;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.atmd.backend.domain.group.entity.Group;
import com.atmd.backend.domain.group.entity.GroupUser;
import com.atmd.backend.domain.group.repository.GroupDailyWorkoutRecordRepository;
import com.atmd.backend.domain.group.repository.GroupRepository;
import com.atmd.backend.domain.group.repository.GroupUserRepository;
import org.junit.jupiter.api.Test;

Expand All @@ -18,11 +19,12 @@
import static org.mockito.Mockito.when;

class GroupWorkoutRecordServiceTest {
private final GroupRepository groupRepository = mock(GroupRepository.class);
private final GroupUserRepository groupUserRepository = mock(GroupUserRepository.class);
private final GroupDailyWorkoutRecordRepository recordRepository =
mock(GroupDailyWorkoutRecordRepository.class);
private final GroupWorkoutRecordService service =
new GroupWorkoutRecordService(groupUserRepository, recordRepository);
new GroupWorkoutRecordService(groupRepository, groupUserRepository, recordRepository);

@Test
void accumulatesSitUpCountForMembershipCreatedBeforeWorkout() {
Expand Down
Loading