diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java index 29ebaf24b..47821587e 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java @@ -36,7 +36,7 @@ ResponseEntity updateSheetId( @Operation( summary = "동아리 인명부 스프레드시트 동기화", - description = "등록된 구글 스프레드시트에 동아리 회원 인명부를 동기화합니다. " + description = "등록된 구글 스프레드시트에 동아리 회원과 사전 회원 인명부를 동기화합니다. " + "sortKey로 정렬 기준(NAME, STUDENT_ID, POSITION, JOINED_AT)을 지정할 수 있으며, " + "ascending으로 오름차순/내림차순을 설정합니다. " + "가입 승인·탈퇴 시에도 자동으로 동기화됩니다." diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncResponse.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncResponse.java index 886892b67..9daec1d03 100644 --- a/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncResponse.java +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncResponse.java @@ -3,7 +3,7 @@ import io.swagger.v3.oas.annotations.media.Schema; public record ClubMemberSheetSyncResponse( - @Schema(description = "동기화된 회원 수", example = "42") + @Schema(description = "동기화 요청된 회원 및 사전 회원 수", example = "42") int syncedMemberCount, @Schema( diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubPreMemberRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubPreMemberRepository.java index eec13a77e..55d7a6d95 100644 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubPreMemberRepository.java +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubPreMemberRepository.java @@ -95,4 +95,6 @@ void deleteByClubIdAndStudentNumberIn( ClubPreMember save(ClubPreMember preMember); List saveAll(Iterable preMembers); + + long countByClubId(Integer clubId); } diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java index b5ccb7412..f92c9b22d 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java @@ -13,6 +13,7 @@ import gg.agit.konect.domain.club.enums.ClubSheetSortKey; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; import gg.agit.konect.domain.club.repository.ClubRepository; import gg.agit.konect.global.exception.CustomException; import lombok.RequiredArgsConstructor; @@ -25,6 +26,7 @@ public class ClubMemberSheetService { private final ClubRepository clubRepository; private final ClubMemberRepository clubMemberRepository; + private final ClubPreMemberRepository clubPreMemberRepository; private final ClubPermissionValidator clubPermissionValidator; private final SheetSyncExecutor sheetSyncExecutor; private final SheetHeaderMapper sheetHeaderMapper; @@ -92,8 +94,9 @@ public ClubMemberSheetSyncResponse syncMembersToSheet( } long memberCount = clubMemberRepository.countByClubId(clubId); + long preMemberCount = clubPreMemberRepository.countByClubId(clubId); sheetSyncExecutor.executeWithSort(clubId, sortKey, ascending); - return ClubMemberSheetSyncResponse.of((int)memberCount, spreadsheetId); + return ClubMemberSheetSyncResponse.of(Math.toIntExact(memberCount + preMemberCount), spreadsheetId); } } diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java index e36759cdb..31309e4e9 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java @@ -33,8 +33,10 @@ import gg.agit.konect.domain.club.enums.ClubSheetSortKey; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.model.ClubPreMember; import gg.agit.konect.domain.club.model.SheetColumnMapping; import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; import gg.agit.konect.domain.club.repository.ClubRepository; import gg.agit.konect.global.util.PhoneNumberNormalizer; import lombok.RequiredArgsConstructor; @@ -57,6 +59,7 @@ public class SheetSyncExecutor { private final Sheets googleSheetsService; private final ClubRepository clubRepository; private final ClubMemberRepository clubMemberRepository; + private final ClubPreMemberRepository clubPreMemberRepository; private final ObjectMapper objectMapper; private final ApplicationEventPublisher applicationEventPublisher; @@ -71,7 +74,8 @@ public void executeWithSort(Integer clubId, ClubSheetSortKey sortKey, boolean as SheetColumnMapping mapping = resolveMapping(club); List members = clubMemberRepository.findAllByClubId(clubId); - List sorted = sort(members, sortKey, ascending); + List preMembers = clubPreMemberRepository.findAllByClubId(clubId); + List sorted = sort(toSheetSyncRows(members, preMembers), sortKey, ascending); try { if (club.getSheetColumnMapping() != null) { @@ -80,7 +84,12 @@ public void executeWithSort(Integer clubId, ClubSheetSortKey sortKey, boolean as clearAndWriteAll(spreadsheetId, sorted); applyFormat(spreadsheetId); } - log.info("Sheet sync done. clubId={}, members={}", clubId, members.size()); + log.info( + "Sheet sync done. clubId={}, members={}, preMembers={}", + clubId, + members.size(), + preMembers.size() + ); } catch (IOException e) { if (GoogleSheetApiExceptionHelper.isAccessDenied(e)) { log.warn( @@ -134,7 +143,7 @@ private SheetColumnMapping resolveMapping(Club club) { private void updateMappedColumns( String spreadsheetId, - List members, + List members, SheetColumnMapping mapping ) throws IOException { int dataStartRow = mapping.getDataStartRow(); @@ -185,24 +194,24 @@ private void clearMappedColumns( } private Map> buildColumnData( - List members, + List members, SheetColumnMapping mapping ) { Map> columns = new HashMap<>(); - for (ClubMember member : members) { + for (SheetSyncRow member : members) { putValue(columns, mapping, SheetColumnMapping.NAME, - member.getUser().getName()); + member.name()); putValue(columns, mapping, SheetColumnMapping.STUDENT_ID, - member.getUser().getStudentNumber()); + member.studentNumber()); putValue(columns, mapping, SheetColumnMapping.EMAIL, - member.getUser().getEmail()); + member.email()); putValue(columns, mapping, SheetColumnMapping.PHONE, - PhoneNumberNormalizer.format(member.getUser().getPhoneNumber())); + member.phone()); putValue(columns, mapping, SheetColumnMapping.POSITION, - member.getClubPosition().getDescription()); + member.positionDescription()); putValue(columns, mapping, SheetColumnMapping.JOINED_AT, - member.getCreatedAt().format(DATE_FORMATTER)); + member.joinedAt()); } return columns; @@ -222,7 +231,7 @@ private void putValue( private void clearAndWriteAll( String spreadsheetId, - List members + List members ) throws IOException { String clearRange = "A:F"; googleSheetsService.spreadsheets().values() @@ -232,15 +241,14 @@ private void clearAndWriteAll( List> rows = new ArrayList<>(); rows.add(HEADER_ROW); - for (ClubMember member : members) { - String phone = PhoneNumberNormalizer.format(member.getUser().getPhoneNumber()); + for (SheetSyncRow member : members) { rows.add(List.of( - member.getUser().getName(), - member.getUser().getStudentNumber(), - member.getUser().getEmail(), - phone != null ? phone : "", - member.getClubPosition().getDescription(), - member.getCreatedAt().format(DATE_FORMATTER) + member.name(), + member.studentNumber(), + member.email(), + member.phone(), + member.positionDescription(), + member.joinedAt() )); } @@ -272,16 +280,16 @@ private void applyFormat(String spreadsheetId) throws IOException { .execute(); } - private List sort( - List members, + private List sort( + List members, ClubSheetSortKey sortKey, boolean ascending ) { - Comparator comparator = switch (sortKey) { - case NAME -> Comparator.comparing(m -> m.getUser().getName()); - case STUDENT_ID -> Comparator.comparing(m -> m.getUser().getStudentNumber()); - case POSITION -> Comparator.comparingInt(m -> m.getClubPosition().getPriority()); - case JOINED_AT -> Comparator.comparing(ClubMember::getCreatedAt); + Comparator comparator = switch (sortKey) { + case NAME -> Comparator.comparing(SheetSyncRow::name); + case STUDENT_ID -> Comparator.comparing(SheetSyncRow::studentNumber); + case POSITION -> Comparator.comparingInt(SheetSyncRow::positionPriority); + case JOINED_AT -> Comparator.comparing(SheetSyncRow::joinedAtRaw); }; @@ -292,6 +300,20 @@ private List sort( return members.stream().sorted(comparator).toList(); } + private List toSheetSyncRows( + List members, + List preMembers + ) { + List rows = new ArrayList<>(members.size() + preMembers.size()); + for (ClubMember member : members) { + rows.add(SheetSyncRow.from(member)); + } + for (ClubPreMember preMember : preMembers) { + rows.add(SheetSyncRow.from(preMember)); + } + return rows; + } + private String columnLetter(int index) { StringBuilder sb = new StringBuilder(); index++; @@ -302,4 +324,42 @@ private String columnLetter(int index) { } return sb.toString(); } + + private record SheetSyncRow( + String name, + String studentNumber, + String email, + String phone, + String positionDescription, + int positionPriority, + String joinedAt, + java.time.LocalDateTime joinedAtRaw + ) { + private static SheetSyncRow from(ClubMember member) { + String phone = PhoneNumberNormalizer.format(member.getUser().getPhoneNumber()); + return new SheetSyncRow( + member.getUser().getName(), + member.getUser().getStudentNumber(), + member.getUser().getEmail(), + phone != null ? phone : "", + member.getClubPosition().getDescription(), + member.getClubPosition().getPriority(), + member.getCreatedAt().format(DATE_FORMATTER), + member.getCreatedAt() + ); + } + + private static SheetSyncRow from(ClubPreMember preMember) { + return new SheetSyncRow( + preMember.getName(), + preMember.getStudentNumber(), + "", + "", + preMember.getClubPosition().getDescription(), + preMember.getClubPosition().getPriority(), + preMember.getCreatedAt().format(DATE_FORMATTER), + preMember.getCreatedAt() + ); + } + } } diff --git a/src/test/java/gg/agit/konect/domain/club/service/ClubMemberSheetServiceTest.java b/src/test/java/gg/agit/konect/domain/club/service/ClubMemberSheetServiceTest.java new file mode 100644 index 000000000..7db589d73 --- /dev/null +++ b/src/test/java/gg/agit/konect/domain/club/service/ClubMemberSheetServiceTest.java @@ -0,0 +1,79 @@ +package gg.agit.konect.domain.club.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.domain.club.enums.ClubSheetSortKey; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.UniversityFixture; + +class ClubMemberSheetServiceTest extends ServiceTestSupport { + + @Mock + private ClubRepository clubRepository; + + @Mock + private ClubMemberRepository clubMemberRepository; + + @Mock + private ClubPreMemberRepository clubPreMemberRepository; + + @Mock + private ClubPermissionValidator clubPermissionValidator; + + @Mock + private SheetSyncExecutor sheetSyncExecutor; + + @Mock + private SheetHeaderMapper sheetHeaderMapper; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private ClubMemberSheetService clubMemberSheetService; + + @Test + @DisplayName("시트 동기화 수에 사전 회원도 포함한다") + void syncMembersToSheetIncludesPreMembersInCount() { + // given + Integer clubId = 1; + Integer requesterId = 2; + String spreadsheetId = "spreadsheet-id"; + Club club = ClubFixture.create(UniversityFixture.create()); + club.updateGoogleSheetId(spreadsheetId); + + given(clubRepository.getById(clubId)).willReturn(club); + given(clubMemberRepository.countByClubId(clubId)).willReturn(2L); + given(clubPreMemberRepository.countByClubId(clubId)).willReturn(3L); + + // when + ClubMemberSheetSyncResponse response = clubMemberSheetService.syncMembersToSheet( + clubId, + requesterId, + ClubSheetSortKey.POSITION, + true + ); + + // then + verify(clubPermissionValidator).validateManagerAccess(clubId, requesterId); + verify(sheetSyncExecutor).executeWithSort(clubId, ClubSheetSortKey.POSITION, true); + assertThat(response.syncedMemberCount()).isEqualTo(5); + assertThat(response.sheetUrl()) + .isEqualTo("https://docs.google.com/spreadsheets/d/" + spreadsheetId + "/edit"); + } +} diff --git a/src/test/java/gg/agit/konect/domain/club/service/SheetSyncExecutorTest.java b/src/test/java/gg/agit/konect/domain/club/service/SheetSyncExecutorTest.java index 92aea1bf6..7aa5115f6 100644 --- a/src/test/java/gg/agit/konect/domain/club/service/SheetSyncExecutorTest.java +++ b/src/test/java/gg/agit/konect/domain/club/service/SheetSyncExecutorTest.java @@ -7,6 +7,8 @@ import static org.mockito.Mockito.verify; import static gg.agit.konect.domain.club.service.GoogleApiTestUtils.googleException; +import java.lang.reflect.Field; +import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -17,16 +19,26 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.model.BatchUpdateSpreadsheetResponse; import com.google.api.services.sheets.v4.model.ClearValuesRequest; +import com.google.api.services.sheets.v4.model.UpdateValuesResponse; +import com.google.api.services.sheets.v4.model.ValueRange; import gg.agit.konect.domain.club.enums.ClubSheetSortKey; +import gg.agit.konect.domain.club.enums.ClubPosition; import gg.agit.konect.domain.club.event.SheetSyncFailedEvent; import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.model.ClubPreMember; import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.domain.user.model.User; import gg.agit.konect.support.ServiceTestSupport; import gg.agit.konect.support.fixture.ClubFixture; +import gg.agit.konect.support.fixture.ClubMemberFixture; import gg.agit.konect.support.fixture.UniversityFixture; +import gg.agit.konect.support.fixture.UserFixture; class SheetSyncExecutorTest extends ServiceTestSupport { @@ -42,12 +54,21 @@ class SheetSyncExecutorTest extends ServiceTestSupport { @Mock private Sheets.Spreadsheets.Values.Clear clearRequest; + @Mock + private Sheets.Spreadsheets.Values.Update updateRequest; + + @Mock + private Sheets.Spreadsheets.BatchUpdate batchUpdateRequest; + @Mock private ClubRepository clubRepository; @Mock private ClubMemberRepository clubMemberRepository; + @Mock + private ClubPreMemberRepository clubPreMemberRepository; + @Mock private ObjectMapper objectMapper; @@ -68,6 +89,7 @@ void executeWithSortPublishesFailureEventWhenAccessDenied() throws Exception { given(clubRepository.getById(clubId)).willReturn(club); given(clubMemberRepository.findAllByClubId(clubId)).willReturn(List.of()); + given(clubPreMemberRepository.findAllByClubId(clubId)).willReturn(List.of()); given(googleSheetsService.spreadsheets()).willReturn(spreadsheets); given(spreadsheets.values()).willReturn(values); given(values.clear(eq(spreadsheetId), eq("A:F"), any(ClearValuesRequest.class))) @@ -85,4 +107,74 @@ void executeWithSortPublishesFailureEventWhenAccessDenied() throws Exception { && sheetSyncFailedEvent.accessDenied() )); } + + @Test + @DisplayName("시트 동기화 시 사전 회원도 함께 덮어쓴다") + void executeWithSortWritesClubMembersAndPreMembers() throws Exception { + // given + Integer clubId = 1; + String spreadsheetId = "spreadsheet-id"; + Club club = ClubFixture.create(UniversityFixture.create()); + club.updateGoogleSheetId(spreadsheetId); + + User memberUser = UserFixture.createUser(club.getUniversity(), "김회원", "2021000001"); + ClubMember member = ClubMemberFixture.createMember(club, memberUser); + setCreatedAt(member, LocalDateTime.of(2024, 3, 1, 10, 0)); + + ClubPreMember preMember = ClubPreMember.builder() + .club(club) + .studentNumber("2024000001") + .name("박사전") + .clubPosition(ClubPosition.MEMBER) + .build(); + setCreatedAt(preMember, LocalDateTime.of(2024, 3, 2, 10, 0)); + + given(clubRepository.getById(clubId)).willReturn(club); + given(clubMemberRepository.findAllByClubId(clubId)).willReturn(List.of(member)); + given(clubPreMemberRepository.findAllByClubId(clubId)).willReturn(List.of(preMember)); + given(googleSheetsService.spreadsheets()).willReturn(spreadsheets); + given(spreadsheets.values()).willReturn(values); + given(values.clear(eq(spreadsheetId), eq("A:F"), any(ClearValuesRequest.class))) + .willReturn(clearRequest); + given(values.update(eq(spreadsheetId), eq("A1"), any(ValueRange.class))) + .willReturn(updateRequest); + given(updateRequest.setValueInputOption("USER_ENTERED")).willReturn(updateRequest); + given(updateRequest.execute()).willReturn(new UpdateValuesResponse()); + given(spreadsheets.batchUpdate(eq(spreadsheetId), any())).willReturn(batchUpdateRequest); + given(batchUpdateRequest.execute()).willReturn(new BatchUpdateSpreadsheetResponse()); + + // when + sheetSyncExecutor.executeWithSort(clubId, ClubSheetSortKey.NAME, true); + + // then + verify(values).update(eq(spreadsheetId), eq("A1"), argThat((ValueRange body) -> + body.getValues().equals(List.of( + List.of("Name", "StudentId", "Email", "Phone", "Position", "JoinedAt"), + List.of("김회원", "2021000001", "2021000001@koreatech.ac.kr", "", "일반회원", "2024-03-01"), + List.of("박사전", "2024000001", "", "", "일반회원", "2024-03-02") + )) + )); + } + + private void setCreatedAt(Object target, LocalDateTime createdAt) throws Exception { + Field createdAtField = findField(target.getClass(), "createdAt"); + createdAtField.setAccessible(true); + createdAtField.set(target, createdAt); + } + + private Field findField(Class type, String fieldName) throws NoSuchFieldException { + Class current = type; + + while (current != null) { + try { + return current.getDeclaredField(fieldName); + } catch (NoSuchFieldException e) { + current = current.getSuperclass(); + } + } + + throw new NoSuchFieldException( + "Field '%s' not found in class hierarchy of %s".formatted(fieldName, type.getName()) + ); + } }