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 @@ -36,7 +36,7 @@ ResponseEntity<Void> updateSheetId(

@Operation(
summary = "동아리 인명부 스프레드시트 동기화",
description = "등록된 구글 스프레드시트에 동아리 회원 인명부를 동기화합니다. "
description = "등록된 구글 스프레드시트에 동아리 회원과 사전 회원 인명부를 동기화합니다. "
+ "sortKey로 정렬 기준(NAME, STUDENT_ID, POSITION, JOINED_AT)을 지정할 수 있으며, "
+ "ascending으로 오름차순/내림차순을 설정합니다. "
+ "가입 승인·탈퇴 시에도 자동으로 동기화됩니다."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,6 @@ void deleteByClubIdAndStudentNumberIn(
ClubPreMember save(ClubPreMember preMember);

List<ClubPreMember> saveAll(Iterable<ClubPreMember> preMembers);

long countByClubId(Integer clubId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -71,7 +74,8 @@ public void executeWithSort(Integer clubId, ClubSheetSortKey sortKey, boolean as

SheetColumnMapping mapping = resolveMapping(club);
List<ClubMember> members = clubMemberRepository.findAllByClubId(clubId);
List<ClubMember> sorted = sort(members, sortKey, ascending);
List<ClubPreMember> preMembers = clubPreMemberRepository.findAllByClubId(clubId);
List<SheetSyncRow> sorted = sort(toSheetSyncRows(members, preMembers), sortKey, ascending);

try {
if (club.getSheetColumnMapping() != null) {
Expand All @@ -80,7 +84,12 @@ public void executeWithSort(Integer clubId, ClubSheetSortKey sortKey, boolean as
clearAndWriteAll(spreadsheetId, sorted);
applyFormat(spreadsheetId);
}
Comment on lines 80 to 86

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

현재 추가된 테스트는 club.getSheetColumnMapping()이 null인 경우(전체 clear 후 values.update로 덮어쓰기)만 검증합니다. 실제 운영에서는 시트 등록 시 sheetColumnMapping이 저장되어 updateMappedColumns(values.batchUpdate 경로)로 동작하는 경우가 많으니, 해당 경로에서도 사전 회원이 함께 반영되는지(예: BatchUpdateValuesRequest에 preMember row가 포함되는지) 테스트를 추가하는 것이 좋겠습니다.

Copilot uses AI. Check for mistakes.
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(
Expand Down Expand Up @@ -134,7 +143,7 @@ private SheetColumnMapping resolveMapping(Club club) {

private void updateMappedColumns(
String spreadsheetId,
List<ClubMember> members,
List<SheetSyncRow> members,
SheetColumnMapping mapping
) throws IOException {
int dataStartRow = mapping.getDataStartRow();
Expand Down Expand Up @@ -185,24 +194,24 @@ private void clearMappedColumns(
}

private Map<Integer, List<Object>> buildColumnData(
List<ClubMember> members,
List<SheetSyncRow> members,
SheetColumnMapping mapping
) {
Map<Integer, List<Object>> 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;
Expand All @@ -222,7 +231,7 @@ private void putValue(

private void clearAndWriteAll(
String spreadsheetId,
List<ClubMember> members
List<SheetSyncRow> members
) throws IOException {
String clearRange = "A:F";
googleSheetsService.spreadsheets().values()
Expand All @@ -232,15 +241,14 @@ private void clearAndWriteAll(
List<List<Object>> 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()
));
}

Expand Down Expand Up @@ -272,16 +280,16 @@ private void applyFormat(String spreadsheetId) throws IOException {
.execute();
}

private List<ClubMember> sort(
List<ClubMember> members,
private List<SheetSyncRow> sort(
List<SheetSyncRow> members,
ClubSheetSortKey sortKey,
boolean ascending
) {
Comparator<ClubMember> 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<SheetSyncRow> 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);

};

Expand All @@ -292,6 +300,20 @@ private List<ClubMember> sort(
return members.stream().sorted(comparator).toList();
}

private List<SheetSyncRow> toSheetSyncRows(
List<ClubMember> members,
List<ClubPreMember> preMembers
) {
List<SheetSyncRow> 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++;
Expand All @@ -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()
);
}
}
}
Original file line number Diff line number Diff line change
@@ -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");
}
}
Loading
Loading