diff --git a/build.gradle b/build.gradle index d854c5a0..6d8b818f 100644 --- a/build.gradle +++ b/build.gradle @@ -75,6 +75,13 @@ dependencies { // notification implementation 'com.google.firebase:firebase-admin:9.2.0' + // Google Sheets API + implementation 'com.google.apis:google-api-services-sheets:v4-rev20251110-2.0.0' + implementation 'com.google.auth:google-auth-library-oauth2-http:1.23.0' + + // Google Drive API + implementation 'com.google.apis:google-api-services-drive:v3-rev20250723-2.0.0' + // Gemini AI - using REST API directly (no SDK dependency) // test 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 new file mode 100644 index 00000000..29ebaf24 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java @@ -0,0 +1,51 @@ +package gg.agit.konect.domain.club.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.domain.club.dto.ClubSheetIdUpdateRequest; +import gg.agit.konect.domain.club.enums.ClubSheetSortKey; +import gg.agit.konect.global.auth.annotation.UserId; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "(Normal) Club - Sheet") +@RequestMapping("/clubs") +public interface ClubMemberSheetApi { + + @Operation( + summary = "구글 스프레드시트 ID 등록 / 수정", + description = "동아리에서 사용 중인 구글 스프레드시트 ID를 등록하거나 수정합니다. " + + "등록 시 AI(Claude Haiku)가 시트 상단 10행을 자동으로 분석하여 " + + "이름·학번·연락처 등 컬럼 위치를 파악하고, 이후 동기화 시 해당 컬럼에만 값을 채웁니다. " + + "시트 양식이 변경된 경우 이 API를 다시 호출하면 AI가 재분석합니다." + ) + @PutMapping("/{clubId}/sheet") + ResponseEntity updateSheetId( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody ClubSheetIdUpdateRequest request, + @UserId Integer requesterId + ); + + @Operation( + summary = "동아리 인명부 스프레드시트 동기화", + description = "등록된 구글 스프레드시트에 동아리 회원 인명부를 동기화합니다. " + + "sortKey로 정렬 기준(NAME, STUDENT_ID, POSITION, JOINED_AT)을 지정할 수 있으며, " + + "ascending으로 오름차순/내림차순을 설정합니다. " + + "가입 승인·탈퇴 시에도 자동으로 동기화됩니다." + ) + @PostMapping("/{clubId}/members/sheet-sync") + ResponseEntity syncMembersToSheet( + @PathVariable(name = "clubId") Integer clubId, + @RequestParam(name = "sortKey", defaultValue = "POSITION") ClubSheetSortKey sortKey, + @RequestParam(name = "ascending", defaultValue = "true") boolean ascending, + @UserId Integer requesterId + ); +} diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetController.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetController.java new file mode 100644 index 00000000..ad374cc8 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetController.java @@ -0,0 +1,46 @@ +package gg.agit.konect.domain.club.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.domain.club.dto.ClubSheetIdUpdateRequest; +import gg.agit.konect.domain.club.enums.ClubSheetSortKey; +import gg.agit.konect.domain.club.service.ClubMemberSheetService; +import gg.agit.konect.global.auth.annotation.UserId; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/clubs") +public class ClubMemberSheetController implements ClubMemberSheetApi { + + private final ClubMemberSheetService clubMemberSheetService; + + @Override + public ResponseEntity updateSheetId( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody ClubSheetIdUpdateRequest request, + @UserId Integer requesterId + ) { + clubMemberSheetService.updateSheetId(clubId, requesterId, request); + return ResponseEntity.ok().build(); + } + + @Override + public ResponseEntity syncMembersToSheet( + @PathVariable(name = "clubId") Integer clubId, + @RequestParam(name = "sortKey", defaultValue = "POSITION") ClubSheetSortKey sortKey, + @RequestParam(name = "ascending", defaultValue = "true") boolean ascending, + @UserId Integer requesterId + ) { + ClubMemberSheetSyncResponse response = + clubMemberSheetService.syncMembersToSheet(clubId, requesterId, sortKey, ascending); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java new file mode 100644 index 00000000..e8aaaf2f --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java @@ -0,0 +1,48 @@ +package gg.agit.konect.domain.club.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.domain.club.dto.SheetImportRequest; +import gg.agit.konect.domain.club.dto.SheetImportResponse; +import gg.agit.konect.domain.club.dto.SheetMigrateRequest; +import gg.agit.konect.global.auth.annotation.UserId; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "(Normal) Club - Sheet") +@RequestMapping("/clubs") +public interface ClubSheetMigrationApi { + + @Operation( + summary = "기존 스프레드시트 → 팀 양식으로 이관", + description = "동아리가 기존에 사용하던 스프레드시트 URL을 제출하면, " + + "AI가 데이터를 분석하여 KONECT 팀이 마련한 표준 양식 파일로 복사합니다. " + + "새 파일은 기존 URL과 동일한 Google Drive 폴더에 생성됩니다. " + + "이후 동기화는 새로 생성된 파일 기준으로 진행됩니다." + ) + @PostMapping("/{clubId}/sheet/migrate") + ResponseEntity migrateSheet( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody SheetMigrateRequest request, + @UserId Integer requesterId + ); + + @Operation( + summary = "기존 스프레드시트에서 사전 회원 가져오기", + description = "동아리가 기존에 관리하던 스프레드시트의 인명부를 읽어 " + + "DB에 사전 회원(ClubPreMember)으로 등록합니다. " + + "AI가 헤더를 자동 분석하며, 이미 등록된 회원(이름+학번 중복)은 건너뜁니다." + ) + @PostMapping("/{clubId}/sheet/import") + ResponseEntity importPreMembers( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody SheetImportRequest request, + @UserId Integer requesterId + ); +} diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java new file mode 100644 index 00000000..ffde170c --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java @@ -0,0 +1,50 @@ +package gg.agit.konect.domain.club.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.domain.club.dto.SheetImportRequest; +import gg.agit.konect.domain.club.dto.SheetImportResponse; +import gg.agit.konect.domain.club.dto.SheetMigrateRequest; +import gg.agit.konect.domain.club.service.SheetImportService; +import gg.agit.konect.domain.club.service.SheetMigrationService; +import gg.agit.konect.global.auth.annotation.UserId; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/clubs") +public class ClubSheetMigrationController implements ClubSheetMigrationApi { + + private final SheetMigrationService sheetMigrationService; + private final SheetImportService sheetImportService; + + @Override + public ResponseEntity migrateSheet( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody SheetMigrateRequest request, + @UserId Integer requesterId + ) { + String newSpreadsheetId = sheetMigrationService.migrateToTemplate( + clubId, requesterId, request.sourceSpreadsheetUrl() + ); + return ResponseEntity.ok(ClubMemberSheetSyncResponse.of(0, newSpreadsheetId)); + } + + @Override + public ResponseEntity importPreMembers( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody SheetImportRequest request, + @UserId Integer requesterId + ) { + int count = sheetImportService.importPreMembersFromSheet( + clubId, requesterId, request.spreadsheetId() + ); + return ResponseEntity.ok(SheetImportResponse.of(count)); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncRequest.java new file mode 100644 index 00000000..800f4791 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncRequest.java @@ -0,0 +1,14 @@ +package gg.agit.konect.domain.club.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record ClubMemberSheetSyncRequest( + @NotBlank(message = "스프레드시트 ID는 필수 입력입니다.") + @Schema( + description = "동기화 대상 구글 스프레드시트 ID (URL의 /d/{spreadsheetId}/ 부분)", + example = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms" + ) + String spreadsheetId +) { +} 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 new file mode 100644 index 00000000..886892b6 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncResponse.java @@ -0,0 +1,19 @@ +package gg.agit.konect.domain.club.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record ClubMemberSheetSyncResponse( + @Schema(description = "동기화된 회원 수", example = "42") + int syncedMemberCount, + + @Schema( + description = "동기화된 스프레드시트 URL", + example = "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms/edit" + ) + String sheetUrl +) { + public static ClubMemberSheetSyncResponse of(int syncedMemberCount, String spreadsheetId) { + String sheetUrl = "https://docs.google.com/spreadsheets/d/" + spreadsheetId + "/edit"; + return new ClubMemberSheetSyncResponse(syncedMemberCount, sheetUrl); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubSheetIdUpdateRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubSheetIdUpdateRequest.java new file mode 100644 index 00000000..51a34e92 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubSheetIdUpdateRequest.java @@ -0,0 +1,19 @@ +package gg.agit.konect.domain.club.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record ClubSheetIdUpdateRequest( + @NotBlank(message = "스프레드시트 ID는 필수 입력입니다.") + @Pattern( + regexp = "^[A-Za-z0-9_-]+$", + message = "스프레드시트 ID는 영문자, 숫자, 하이픈(-), 언더스코어(_)만 허용합니다." + ) + @Schema( + description = "등록할 구글 스프레드시트 ID (URL의 /d/{spreadsheetId}/ 부분)", + example = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms" + ) + String spreadsheetId +) { +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/SheetImportRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/SheetImportRequest.java new file mode 100644 index 00000000..bc87df24 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/SheetImportRequest.java @@ -0,0 +1,14 @@ +package gg.agit.konect.domain.club.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record SheetImportRequest( + @NotBlank + @Schema( + description = "인명부가 담긴 구글 스프레드시트 ID 또는 URL", + example = "1BxiMVs0XRA5..." + ) + String spreadsheetId +) { +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/SheetImportResponse.java b/src/main/java/gg/agit/konect/domain/club/dto/SheetImportResponse.java new file mode 100644 index 00000000..91225711 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/SheetImportResponse.java @@ -0,0 +1,9 @@ +package gg.agit.konect.domain.club.dto; + +public record SheetImportResponse( + int importedCount +) { + public static SheetImportResponse of(int importedCount) { + return new SheetImportResponse(importedCount); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/SheetMigrateRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/SheetMigrateRequest.java new file mode 100644 index 00000000..41d99ad7 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/SheetMigrateRequest.java @@ -0,0 +1,14 @@ +package gg.agit.konect.domain.club.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record SheetMigrateRequest( + @NotBlank + @Schema( + description = "동아리가 기존에 사용하던 구글 스프레드시트 URL", + example = "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5.../edit" + ) + String sourceSpreadsheetUrl +) { +} diff --git a/src/main/java/gg/agit/konect/domain/club/enums/ClubSheetSortKey.java b/src/main/java/gg/agit/konect/domain/club/enums/ClubSheetSortKey.java new file mode 100644 index 00000000..06f9c43e --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/enums/ClubSheetSortKey.java @@ -0,0 +1,8 @@ +package gg.agit.konect.domain.club.enums; + +public enum ClubSheetSortKey { + NAME, + STUDENT_ID, + POSITION, + JOINED_AT +} diff --git a/src/main/java/gg/agit/konect/domain/club/event/ClubMemberChangedEvent.java b/src/main/java/gg/agit/konect/domain/club/event/ClubMemberChangedEvent.java new file mode 100644 index 00000000..2afa0b67 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/event/ClubMemberChangedEvent.java @@ -0,0 +1,9 @@ +package gg.agit.konect.domain.club.event; + +public record ClubMemberChangedEvent( + Integer clubId +) { + public static ClubMemberChangedEvent of(Integer clubId) { + return new ClubMemberChangedEvent(clubId); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/model/Club.java b/src/main/java/gg/agit/konect/domain/club/model/Club.java index 3e946392..114c1cc8 100644 --- a/src/main/java/gg/agit/konect/domain/club/model/Club.java +++ b/src/main/java/gg/agit/konect/domain/club/model/Club.java @@ -84,6 +84,18 @@ public class Club extends BaseEntity { @Column(name = "is_application_enabled") private Boolean isApplicationEnabled; + @Column(name = "google_sheet_id", length = 255) + private String googleSheetId; + + @Column(name = "sheet_column_mapping", columnDefinition = "JSON") + private String sheetColumnMapping; + + @Column(name = "drive_folder_id", length = 255) + private String driveFolderId; + + @Column(name = "template_spreadsheet_id", length = 255) + private String templateSpreadsheetId; + @OneToOne(mappedBy = "club", fetch = LAZY, cascade = ALL, orphanRemoval = true) private ClubRecruitment clubRecruitment; @@ -224,4 +236,16 @@ private void clearFeeInfo() { this.feeAccountNumber = null; this.feeAccountHolder = null; } + + public void updateGoogleSheetId(String googleSheetId) { + this.googleSheetId = googleSheetId; + } + + public void updateSheetColumnMapping(String sheetColumnMapping) { + this.sheetColumnMapping = sheetColumnMapping; + } + + public void updateDriveFolderId(String driveFolderId) { + this.driveFolderId = driveFolderId; + } } diff --git a/src/main/java/gg/agit/konect/domain/club/model/SheetColumnMapping.java b/src/main/java/gg/agit/konect/domain/club/model/SheetColumnMapping.java new file mode 100644 index 00000000..227a4de2 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/model/SheetColumnMapping.java @@ -0,0 +1,63 @@ +package gg.agit.konect.domain.club.model; + +import java.util.HashMap; +import java.util.Map; + +public class SheetColumnMapping { + + public static final String NAME = "name"; + public static final String STUDENT_ID = "studentId"; + public static final String EMAIL = "email"; + public static final String PHONE = "phone"; + public static final String POSITION = "position"; + public static final String JOINED_AT = "joinedAt"; + + private static final int COL_NAME = 0; + private static final int COL_STUDENT_ID = 1; + private static final int COL_EMAIL = 2; + private static final int COL_PHONE = 3; + private static final int COL_POSITION = 4; + private static final int COL_JOINED_AT = 5; + private static final int DEFAULT_DATA_START_ROW = 2; + + private final Map fieldToColumn; + private final int dataStartRow; + + public SheetColumnMapping(Map fieldToColumn, int dataStartRow) { + this.fieldToColumn = new HashMap<>(fieldToColumn); + this.dataStartRow = dataStartRow; + } + + public SheetColumnMapping(Map fieldToColumn) { + this(fieldToColumn, DEFAULT_DATA_START_ROW); + } + + public static SheetColumnMapping defaultMapping() { + Map mapping = new HashMap<>(); + mapping.put(NAME, COL_NAME); + mapping.put(STUDENT_ID, COL_STUDENT_ID); + mapping.put(EMAIL, COL_EMAIL); + mapping.put(PHONE, COL_PHONE); + mapping.put(POSITION, COL_POSITION); + mapping.put(JOINED_AT, COL_JOINED_AT); + return new SheetColumnMapping(mapping, DEFAULT_DATA_START_ROW); + } + + public boolean hasColumn(String field) { + return fieldToColumn.containsKey(field); + } + + public int getColumnIndex(String field) { + return fieldToColumn.getOrDefault(field, -1); + } + + public int getDataStartRow() { + return dataStartRow; + } + + public Map toMap() { + Map result = new HashMap<>(fieldToColumn); + result.put("dataStartRow", dataStartRow); + return result; + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java index 076d7c69..9cc2a9d3 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java @@ -31,6 +31,7 @@ import gg.agit.konect.domain.club.dto.ClubFeeInfoResponse; import gg.agit.konect.domain.club.event.ClubApplicationApprovedEvent; import gg.agit.konect.domain.club.event.ClubApplicationSubmittedEvent; +import gg.agit.konect.domain.club.event.ClubMemberChangedEvent; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubApply; import gg.agit.konect.domain.club.model.ClubApplyAnswer; @@ -251,6 +252,7 @@ public void approveClubApplication(Integer clubId, Integer applicationId, Intege clubId, club.getName() )); + applicationEventPublisher.publishEvent(ClubMemberChangedEvent.of(clubId)); } @Transactional diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java index 5a83be74..7c973d13 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.Optional; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,6 +19,7 @@ import gg.agit.konect.domain.club.dto.PresidentTransferRequest; import gg.agit.konect.domain.club.dto.VicePresidentChangeRequest; import gg.agit.konect.domain.club.enums.ClubPosition; +import gg.agit.konect.domain.club.event.ClubMemberChangedEvent; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubMember; import gg.agit.konect.domain.club.model.ClubPreMember; @@ -42,6 +44,7 @@ public class ClubMemberManagementService { private final ClubPermissionValidator clubPermissionValidator; private final UserRepository userRepository; private final ChatRoomMembershipService chatRoomMembershipService; + private final ApplicationEventPublisher applicationEventPublisher; @Transactional public ClubMember changeMemberPosition( @@ -72,6 +75,7 @@ public ClubMember changeMemberPosition( validatePositionLimit(clubId, newPosition, target); target.changePosition(newPosition); + applicationEventPublisher.publishEvent(ClubMemberChangedEvent.of(clubId)); return target; } @@ -130,6 +134,7 @@ private ClubPreMemberAddResponse addDirectMember(Club club, User user, ClubPosit ClubMember savedMember = clubMemberRepository.save(clubMember); chatRoomMembershipService.addClubMember(savedMember); + applicationEventPublisher.publishEvent(ClubMemberChangedEvent.of(club.getId())); return ClubPreMemberAddResponse.from(savedMember); } @@ -194,6 +199,7 @@ public List transferPresident( currentPresident.changePosition(MEMBER); newPresident.changePosition(PRESIDENT); + applicationEventPublisher.publishEvent(ClubMemberChangedEvent.of(clubId)); return List.of(currentPresident, newPresident); } @@ -223,6 +229,7 @@ public List changeVicePresident( ClubMember currentVicePresident = currentVicePresidentOpt.get(); currentVicePresident.changePosition(MEMBER); changedMembers.add(currentVicePresident); + applicationEventPublisher.publishEvent(ClubMemberChangedEvent.of(clubId)); } return changedMembers; } @@ -241,6 +248,7 @@ public List changeVicePresident( newVicePresident.changePosition(VICE_PRESIDENT); changedMembers.add(newVicePresident); + applicationEventPublisher.publishEvent(ClubMemberChangedEvent.of(clubId)); return changedMembers; } @@ -273,6 +281,7 @@ public void removeMember(Integer clubId, Integer targetUserId, Integer requester clubMemberRepository.delete(target); chatRoomMembershipService.removeClubMember(clubId, targetUserId); + applicationEventPublisher.publishEvent(ClubMemberChangedEvent.of(clubId)); } private void validateNotSelf(Integer userId1, Integer userId2, ApiResponseCode errorCode) { 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 new file mode 100644 index 00000000..6485f8ae --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java @@ -0,0 +1,85 @@ +package gg.agit.konect.domain.club.service; + +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CLUB_SHEET_ID; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.domain.club.dto.ClubSheetIdUpdateRequest; +import gg.agit.konect.domain.club.enums.ClubSheetSortKey; +import gg.agit.konect.domain.club.event.ClubMemberChangedEvent; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.global.exception.CustomException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ClubMemberSheetService { + + private final ClubRepository clubRepository; + private final ClubMemberRepository clubMemberRepository; + private final ClubPermissionValidator clubPermissionValidator; + private final SheetSyncDebouncer sheetSyncDebouncer; + private final SheetSyncExecutor sheetSyncExecutor; + private final SheetHeaderMapper sheetHeaderMapper; + private final ObjectMapper objectMapper; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onClubMemberChanged(ClubMemberChangedEvent event) { + sheetSyncDebouncer.debounce(event.clubId()); + } + + @Transactional + public void updateSheetId( + Integer clubId, + Integer requesterId, + ClubSheetIdUpdateRequest request + ) { + SheetHeaderMapper.SheetAnalysisResult result = + sheetHeaderMapper.analyzeAllSheets(request.spreadsheetId()); + String mappingJson = null; + try { + mappingJson = objectMapper.writeValueAsString(result.memberListMapping().toMap()); + } catch (JsonProcessingException e) { + log.warn("Failed to serialize mapping, skipping. cause={}", e.getMessage()); + } + + Club club = clubRepository.getById(clubId); + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + club.updateGoogleSheetId(request.spreadsheetId()); + if (mappingJson != null) { + club.updateSheetColumnMapping(mappingJson); + } + } + + @Transactional(readOnly = true) + public ClubMemberSheetSyncResponse syncMembersToSheet( + Integer clubId, + Integer requesterId, + ClubSheetSortKey sortKey, + boolean ascending + ) { + Club club = clubRepository.getById(clubId); + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + + String spreadsheetId = club.getGoogleSheetId(); + if (spreadsheetId == null || spreadsheetId.isBlank()) { + throw CustomException.of(NOT_FOUND_CLUB_SHEET_ID); + } + + long memberCount = clubMemberRepository.countByClubId(clubId); + sheetSyncExecutor.executeWithSort(clubId, sortKey, ascending); + + return ClubMemberSheetSyncResponse.of((int)memberCount, spreadsheetId); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java b/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java new file mode 100644 index 00000000..e210f613 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java @@ -0,0 +1,276 @@ +package gg.agit.konect.domain.club.service; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.model.Sheet; +import com.google.api.services.sheets.v4.model.Spreadsheet; +import com.google.api.services.sheets.v4.model.ValueRange; + +import gg.agit.konect.domain.club.model.SheetColumnMapping; +import gg.agit.konect.infrastructure.claude.config.ClaudeProperties; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class SheetHeaderMapper { + + private static final String API_URL = "https://api.anthropic.com/v1/messages"; + private static final String ANTHROPIC_VERSION = "2023-06-01"; + // 헤더 분석용으로 haiku 모델을 고정 사용 (ClaudeProperties.model()과 의도적으로 분리) + // 운영 모델과 다르게 비용/속도 최적화를 위해 저비용 모델을 선택 + private static final String MAPPING_MODEL = "claude-haiku-4-5-20251001"; + private static final int MAX_TOKENS = 1024; + private static final int SCAN_ROWS = 10; + + private final Sheets googleSheetsService; + private final ClaudeProperties claudeProperties; + private final ObjectMapper objectMapper; + private final RestClient restClient; + + public SheetHeaderMapper( + Sheets googleSheetsService, + ClaudeProperties claudeProperties, + ObjectMapper objectMapper, + RestClient.Builder restClientBuilder + ) { + this.googleSheetsService = googleSheetsService; + this.claudeProperties = claudeProperties; + this.objectMapper = objectMapper; + this.restClient = restClientBuilder.build(); + } + + public record SheetAnalysisResult( + SheetColumnMapping memberListMapping, + Integer feeSheetId, + SheetColumnMapping feeLedgerMapping + ) {} + + public SheetAnalysisResult analyzeAllSheets(String spreadsheetId) { + List sheets = readAllSheets(spreadsheetId); + if (sheets.isEmpty()) { + log.warn("No sheets found. Using default mapping."); + return new SheetAnalysisResult(SheetColumnMapping.defaultMapping(), null, null); + } + + try { + return inferAllMappings(spreadsheetId, sheets); + } catch (Exception e) { + log.warn("Sheet analysis failed, using default. cause={}", e.getMessage()); + return new SheetAnalysisResult(SheetColumnMapping.defaultMapping(), null, null); + } + } + + private record SheetInfo(Integer sheetId, String title) {} + + private List readAllSheets(String spreadsheetId) { + try { + Spreadsheet spreadsheet = googleSheetsService.spreadsheets() + .get(spreadsheetId) + .execute(); + + List result = new ArrayList<>(); + for (Sheet sheet : spreadsheet.getSheets()) { + result.add(new SheetInfo( + sheet.getProperties().getSheetId(), + sheet.getProperties().getTitle() + )); + } + return result; + + } catch (IOException e) { + log.error("Failed to read spreadsheet info. spreadsheetId={}", spreadsheetId, e); + return List.of(); + } + } + + private List> readSheetRows(String spreadsheetId, String sheetTitle) { + try { + String range = "'" + sheetTitle + "'!A1:Z10"; + ValueRange response = googleSheetsService.spreadsheets().values() + .get(spreadsheetId, range) + .execute(); + + List> values = response.getValues(); + if (values == null || values.isEmpty()) { + return List.of(); + } + + List> rows = new ArrayList<>(); + int limit = Math.min(values.size(), SCAN_ROWS); + for (int i = 0; i < limit; i++) { + List row = values.get(i).stream() + .map(Object::toString) + .toList(); + rows.add(row); + } + return rows; + + } catch (IOException e) { + log.warn("Failed to read rows from sheet '{}'. cause={}", sheetTitle, e.getMessage()); + return List.of(); + } + } + + private SheetAnalysisResult inferAllMappings( + String spreadsheetId, + List sheets + ) throws Exception { + StringBuilder sheetsDescription = new StringBuilder(); + Map>> sheetRowsMap = new HashMap<>(); + + for (SheetInfo sheet : sheets) { + List> rows = readSheetRows(spreadsheetId, sheet.title()); + sheetRowsMap.put(sheet.title(), rows); + + sheetsDescription.append(String.format("=== Sheet: \"%s\" (sheetId: %d) ===%n", + sheet.title(), sheet.sheetId())); + if (rows.isEmpty()) { + sheetsDescription.append("(empty)\n"); + } else { + for (int i = 0; i < rows.size(); i++) { + sheetsDescription.append(String.format("Row %d: %s%n", i + 1, rows.get(i))); + } + } + sheetsDescription.append("\n"); + } + + String prompt = buildPrompt(sheetsDescription.toString(), sheets); + String rawJson = callClaude(prompt); + return parseAllMappings(rawJson, sheets); + } + + private String buildPrompt(String sheetsDescription, List sheets) { + List sheetNames = sheets.stream().map(SheetInfo::title).toList(); + return String.format(""" + A Korean university club uses a Google Spreadsheet with these sheets: + %s + + %s + + Analyze the sheets and respond ONLY with a JSON object in this format: + { + "memberList": { + "sheetTitle": "sheet name containing member list", + "headerRow": 1, + "mapping": {"name": 0, "studentId": 1, "email": 2} + } + } + + Field definitions: + - memberList fields: name(이름/성명), studentId(학번), email(이메일), + phone(전화번호/연락처), position(직책), joinedAt(가입일) + + Rules: + - "memberList.sheetTitle" must be one of: %s + - "headerRow" is 1-indexed + - "mapping" uses 0-indexed column positions + - Only include fields you are confident about + - Do not include explanation + """, + sheetNames, sheetsDescription, sheetNames + ); + } + + private String callClaude(String prompt) { + Map request = Map.of( + "model", MAPPING_MODEL, + "max_tokens", MAX_TOKENS, + "messages", List.of(Map.of("role", "user", "content", prompt)) + ); + + try { + String response = restClient.post() + .uri(API_URL) + .header("x-api-key", claudeProperties.apiKey()) + .header("anthropic-version", ANTHROPIC_VERSION) + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .body(String.class); + + JsonNode root = objectMapper.readTree(response); + JsonNode content = root.path("content"); + if (!content.isArray() || content.isEmpty()) { + throw new RuntimeException("Claude API returned empty content. response=" + response); + } + return content.get(0).path("text").asText(); + + } catch (RestClientException | IOException e) { + throw new RuntimeException("Claude API call failed", e); + } + } + + private SheetAnalysisResult parseAllMappings( + String rawJson, + List sheets + ) { + try { + String cleaned = rawJson.trim(); + int start = cleaned.indexOf('{'); + int end = cleaned.lastIndexOf('}'); + if (start < 0 || end < 0) { + throw new IllegalArgumentException("No JSON object found"); + } + cleaned = cleaned.substring(start, end + 1); + + JsonNode root = objectMapper.readTree(cleaned); + + SheetColumnMapping memberListMapping = parseSingleMapping(root.path("memberList")); + SheetColumnMapping feeLedgerMapping = null; + Integer feeSheetId = null; + + JsonNode feeLedgerNode = root.path("feeLedger"); + if (!feeLedgerNode.isMissingNode() && !feeLedgerNode.isNull()) { + String feeLedgerTitle = feeLedgerNode.path("sheetTitle").asText(null); + if (feeLedgerTitle != null && !"null".equals(feeLedgerTitle)) { + feeLedgerMapping = parseSingleMapping(feeLedgerNode); + feeSheetId = sheets.stream() + .filter(s -> s.title().equals(feeLedgerTitle)) + .map(SheetInfo::sheetId) + .findFirst() + .orElse(null); + } + } + + log.info( + "Sheet analysis done. memberList={}, feeSheetId={}, feeLedger={}", + memberListMapping.toMap(), feeSheetId, + feeLedgerMapping != null ? feeLedgerMapping.toMap() : "none" + ); + + return new SheetAnalysisResult(memberListMapping, feeSheetId, feeLedgerMapping); + + } catch (Exception e) { + log.warn("Failed to parse all mappings: {}. Using default.", rawJson); + return new SheetAnalysisResult(SheetColumnMapping.defaultMapping(), null, null); + } + } + + private SheetColumnMapping parseSingleMapping(JsonNode node) { + int headerRow = node.path("headerRow").asInt(1); + int dataStartRow = headerRow + 1; + + JsonNode mappingNode = node.path("mapping"); + Map mapping = new HashMap<>(); + mappingNode.fields().forEachRemaining(entry -> { + int colIndex = entry.getValue().asInt(-1); + if (colIndex >= 0) { + mapping.put(entry.getKey(), colIndex); + } + }); + + return new SheetColumnMapping(mapping, dataStartRow); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java b/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java new file mode 100644 index 00000000..7ab32742 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java @@ -0,0 +1,121 @@ +package gg.agit.konect.domain.club.service; + +import java.io.IOException; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.model.ValueRange; + +import gg.agit.konect.domain.club.enums.ClubPosition; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubPreMember; +import gg.agit.konect.domain.club.model.SheetColumnMapping; +import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SheetImportService { + + private final Sheets googleSheetsService; + private final SheetHeaderMapper sheetHeaderMapper; + private final ClubRepository clubRepository; + private final ClubPreMemberRepository clubPreMemberRepository; + private final ClubPermissionValidator clubPermissionValidator; + + @Transactional + public int importPreMembersFromSheet( + Integer clubId, + Integer requesterId, + String spreadsheetId + ) { + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + Club club = clubRepository.getById(clubId); + + SheetHeaderMapper.SheetAnalysisResult analysis = + sheetHeaderMapper.analyzeAllSheets(spreadsheetId); + SheetColumnMapping mapping = analysis.memberListMapping(); + + List> rows = readDataRows(spreadsheetId, mapping); + int imported = 0; + + for (List row : rows) { + String name = getCell(row, mapping, SheetColumnMapping.NAME); + String studentNumber = getCell(row, mapping, SheetColumnMapping.STUDENT_ID); + + if (name.isBlank() || studentNumber.isBlank()) { + continue; + } + + if (clubPreMemberRepository.existsByClubIdAndStudentNumberAndName( + clubId, studentNumber, name + )) { + continue; + } + + String positionStr = getCell(row, mapping, SheetColumnMapping.POSITION); + ClubPosition position = resolvePosition(positionStr); + + ClubPreMember preMember = ClubPreMember.builder() + .club(club) + .studentNumber(studentNumber) + .name(name) + .clubPosition(position) + .build(); + + clubPreMemberRepository.save(preMember); + imported++; + } + + log.info( + "Sheet import done. clubId={}, spreadsheetId={}, imported={}", + clubId, spreadsheetId, imported + ); + return imported; + } + + private List> readDataRows(String spreadsheetId, SheetColumnMapping mapping) { + try { + int dataStartRow = mapping.getDataStartRow(); + String range = "A" + dataStartRow + ":Z"; + ValueRange response = googleSheetsService.spreadsheets().values() + .get(spreadsheetId, range) + .execute(); + + List> values = response.getValues(); + return values != null ? values : List.of(); + + } catch (IOException e) { + log.error("Failed to read sheet data. spreadsheetId={}", spreadsheetId, e); + return List.of(); + } + } + + private String getCell(List row, SheetColumnMapping mapping, String field) { + int col = mapping.getColumnIndex(field); + if (col < 0 || col >= row.size()) { + return ""; + } + String value = row.get(col).toString().trim(); + if (value.startsWith("'")) { + return value.substring(1); + } + return value; + } + + private ClubPosition resolvePosition(String positionStr) { + for (ClubPosition pos : ClubPosition.values()) { + if (pos.getDescription().equals(positionStr) + || pos.name().equalsIgnoreCase(positionStr)) { + return pos; + } + } + return ClubPosition.MEMBER; + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java b/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java new file mode 100644 index 00000000..338e69ef --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java @@ -0,0 +1,281 @@ +package gg.agit.konect.domain.club.service; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.google.api.services.drive.Drive; +import com.google.api.services.drive.model.File; +import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.model.ValueRange; + +import com.google.api.services.drive.model.Permission; + +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.SheetColumnMapping; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.global.code.ApiResponseCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SheetMigrationService { + + private static final Pattern FOLDER_ID_PATTERN = + Pattern.compile("(?:folders/|id=)([a-zA-Z0-9_-]{20,})"); + private static final Pattern SPREADSHEET_ID_PATTERN = + Pattern.compile("/spreadsheets/d/([a-zA-Z0-9_-]+)"); + private static final String MIME_TYPE_SPREADSHEET = + "application/vnd.google-apps.spreadsheet"; + private static final String NEW_SHEET_TITLE_PREFIX = "KONECT_인명부_"; + + @Value("${google.sheets.template-spreadsheet-id:}") + private String defaultTemplateSpreadsheetId; + + private final Drive googleDriveService; + private final Sheets googleSheetsService; + private final SheetHeaderMapper sheetHeaderMapper; + private final ClubRepository clubRepository; + private final UserRepository userRepository; + + @Transactional + public String migrateToTemplate( + Integer clubId, + Integer requesterId, + String sourceSpreadsheetUrl + ) { + Club club = clubRepository.getById(clubId); + User requester = userRepository.getById(requesterId); + String templateId = defaultTemplateSpreadsheetId; + + if (templateId == null || templateId.isBlank()) { + throw CustomException.of(ApiResponseCode.NOT_FOUND_CLUB_SHEET_ID); + } + + String sourceSpreadsheetId = extractSpreadsheetId(sourceSpreadsheetUrl); + String folderId = resolveFolderId(sourceSpreadsheetUrl, sourceSpreadsheetId); + + String newSpreadsheetId = copyTemplate(templateId, club.getName(), folderId, requester.getEmail()); + + SheetHeaderMapper.SheetAnalysisResult sourceAnalysis = + sheetHeaderMapper.analyzeAllSheets(sourceSpreadsheetId); + + List> sourceData = readAllData( + sourceSpreadsheetId, + sourceAnalysis.memberListMapping() + ); + + writeToTemplate(newSpreadsheetId, sourceData, sourceAnalysis.memberListMapping()); + + club.updateGoogleSheetId(newSpreadsheetId); + if (folderId != null) { + club.updateDriveFolderId(folderId); + } + + SheetHeaderMapper.SheetAnalysisResult newAnalysis = + sheetHeaderMapper.analyzeAllSheets(newSpreadsheetId); + try { + com.fasterxml.jackson.databind.ObjectMapper om = + new com.fasterxml.jackson.databind.ObjectMapper(); + club.updateSheetColumnMapping( + om.writeValueAsString(newAnalysis.memberListMapping().toMap()) + ); + } catch (Exception e) { + log.warn("Failed to serialize new mapping. cause={}", e.getMessage()); + } + + log.info( + "Sheet migration done. clubId={}, sourceId={}, newId={}, folderId={}", + clubId, sourceSpreadsheetId, newSpreadsheetId, folderId + ); + + return newSpreadsheetId; + } + + private String extractSpreadsheetId(String url) { + Matcher m = SPREADSHEET_ID_PATTERN.matcher(url); + if (m.find()) { + return m.group(1); + } + return url; + } + + private String resolveFolderId(String url, String spreadsheetId) { + Matcher m = FOLDER_ID_PATTERN.matcher(url); + if (m.find()) { + return m.group(1); + } + try { + File file = googleDriveService.files().get(spreadsheetId) + .setFields("parents") + .execute(); + List parents = file.getParents(); + if (parents != null && !parents.isEmpty()) { + return parents.get(0); + } + } catch (IOException e) { + log.warn("Failed to get parent folder of spreadsheet. cause={}", e.getMessage()); + } + return null; + } + + private String copyTemplate(String templateId, String clubName, String targetFolderId, String ownerEmail) { + try { + String title = NEW_SHEET_TITLE_PREFIX + clubName; + File copyMetadata = new File().setName(title); + + if (targetFolderId != null) { + copyMetadata.setParents(Collections.singletonList(targetFolderId)); + } + + File copied = googleDriveService.files().copy(templateId, copyMetadata) + .setFields("id") + .execute(); + + log.info("Template copied. newId={}, folderId={}", copied.getId(), targetFolderId); + + transferOwnership(copied.getId(), ownerEmail); + + return copied.getId(); + + } catch (IOException e) { + log.error("Failed to copy template. cause={}", e.getMessage(), e); + throw new RuntimeException("Failed to copy template spreadsheet", e); + } + } + + private void transferOwnership(String fileId, String ownerEmail) { + try { + Permission permission = new Permission() + .setType("user") + .setRole("owner") + .setEmailAddress(ownerEmail); + + googleDriveService.permissions().create(fileId, permission) + .setTransferOwnership(true) + .execute(); + + log.info("Ownership transferred. fileId={}, ownerEmail={}", fileId, ownerEmail); + } catch (IOException e) { + log.warn("Failed to transfer ownership. fileId={}, cause={}", fileId, e.getMessage()); + } + } + + private List> readAllData( + String spreadsheetId, + SheetColumnMapping mapping + ) { + try { + int dataStartRow = mapping.getDataStartRow(); + String range = "A" + dataStartRow + ":Z"; + ValueRange response = googleSheetsService.spreadsheets().values() + .get(spreadsheetId, range) + .execute(); + + List> values = response.getValues(); + return values != null ? values : List.of(); + + } catch (IOException e) { + log.error("Failed to read source data. cause={}", e.getMessage(), e); + return List.of(); + } + } + + private void writeToTemplate( + String newSpreadsheetId, + List> sourceData, + SheetColumnMapping sourceMapping + ) { + if (sourceData.isEmpty()) { + return; + } + + try { + SheetHeaderMapper.SheetAnalysisResult templateAnalysis = + sheetHeaderMapper.analyzeAllSheets(newSpreadsheetId); + SheetColumnMapping targetMapping = templateAnalysis.memberListMapping(); + int targetDataStartRow = targetMapping.getDataStartRow(); + + Map sourceFieldToCol = buildReverseMapping(sourceMapping); + List> targetRows = new ArrayList<>(); + + for (List sourceRow : sourceData) { + List targetRow = buildTargetRow( + sourceRow, sourceFieldToCol, targetMapping + ); + targetRows.add(targetRow); + } + + String range = "A" + targetDataStartRow; + ValueRange body = new ValueRange().setValues(targetRows); + googleSheetsService.spreadsheets().values() + .update(newSpreadsheetId, range, body) + .setValueInputOption("USER_ENTERED") + .execute(); + + log.info( + "Data written to template. rows={}, targetStartRow={}", + targetRows.size(), targetDataStartRow + ); + + } catch (IOException e) { + log.error("Failed to write data to template. cause={}", e.getMessage(), e); + } + } + + private Map buildReverseMapping(SheetColumnMapping mapping) { + Map result = new java.util.HashMap<>(); + for (String field : List.of( + SheetColumnMapping.NAME, SheetColumnMapping.STUDENT_ID, + SheetColumnMapping.EMAIL, SheetColumnMapping.PHONE, + SheetColumnMapping.POSITION, SheetColumnMapping.JOINED_AT + )) { + int colIndex = mapping.getColumnIndex(field); + if (colIndex >= 0) { + result.put(field, colIndex); + } + } + return result; + } + + private List buildTargetRow( + List sourceRow, + Map sourceFieldToCol, + SheetColumnMapping targetMapping + ) { + int maxCol = targetMapping.toMap().values().stream() + .filter(v -> v instanceof Integer) + .mapToInt(v -> (Integer)v) + .max() + .orElse(0); + + List row = new ArrayList<>( + Collections.nCopies(maxCol + 1, "") + ); + + for (Map.Entry entry : sourceFieldToCol.entrySet()) { + String field = entry.getKey(); + int sourceCol = entry.getValue(); + int targetCol = targetMapping.getColumnIndex(field); + + if (targetCol >= 0 && sourceCol < sourceRow.size()) { + row.set(targetCol, sourceRow.get(sourceCol)); + } + } + + return row; + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncDebouncer.java b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncDebouncer.java new file mode 100644 index 00000000..9f84b3d0 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncDebouncer.java @@ -0,0 +1,41 @@ +package gg.agit.konect.domain.club.service; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import org.springframework.stereotype.Component; + +import gg.agit.konect.domain.club.enums.ClubSheetSortKey; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SheetSyncDebouncer { + + private static final long DEBOUNCE_DELAY_SECONDS = 3; + + private final ConcurrentHashMap> pendingTasks = + new ConcurrentHashMap<>(); + private final ScheduledExecutorService scheduler = + Executors.newSingleThreadScheduledExecutor(); + + private final SheetSyncExecutor sheetSyncExecutor; + + public void debounce(Integer clubId) { + pendingTasks.compute(clubId, (id, existing) -> { + if (existing != null && !existing.isDone()) { + existing.cancel(false); + log.debug("Sheet sync debounced. clubId={}", id); + } + return scheduler.schedule(() -> { + pendingTasks.remove(id); + sheetSyncExecutor.executeWithSort(id, ClubSheetSortKey.POSITION, true); + }, DEBOUNCE_DELAY_SECONDS, TimeUnit.SECONDS); + }); + } +} 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 new file mode 100644 index 00000000..a6eea1fc --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java @@ -0,0 +1,288 @@ +package gg.agit.konect.domain.club.service; + +import java.io.IOException; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.model.BasicFilter; +import com.google.api.services.sheets.v4.model.BatchClearValuesRequest; +import com.google.api.services.sheets.v4.model.BatchUpdateSpreadsheetRequest; +import com.google.api.services.sheets.v4.model.BatchUpdateValuesRequest; +import com.google.api.services.sheets.v4.model.ClearValuesRequest; +import com.google.api.services.sheets.v4.model.GridProperties; +import com.google.api.services.sheets.v4.model.GridRange; +import com.google.api.services.sheets.v4.model.Request; +import com.google.api.services.sheets.v4.model.SetBasicFilterRequest; +import com.google.api.services.sheets.v4.model.SheetProperties; +import com.google.api.services.sheets.v4.model.UpdateSheetPropertiesRequest; +import com.google.api.services.sheets.v4.model.ValueRange; + +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.SheetColumnMapping; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SheetSyncExecutor { + + private static final String SHEET_RANGE = "A1"; + private static final int ALPHABET_SIZE = 26; + private static final DateTimeFormatter DATE_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + private static final List HEADER_ROW = List.of( + "Name", "StudentId", "Email", "Phone", "Position", "JoinedAt" + ); + + private final Sheets googleSheetsService; + private final ClubRepository clubRepository; + private final ClubMemberRepository clubMemberRepository; + private final ObjectMapper objectMapper; + + @Async("sheetSyncTaskExecutor") + @Transactional(readOnly = true) + public void executeWithSort(Integer clubId, ClubSheetSortKey sortKey, boolean ascending) { + Club club = clubRepository.getById(clubId); + String spreadsheetId = club.getGoogleSheetId(); + if (spreadsheetId == null || spreadsheetId.isBlank()) { + return; + } + + SheetColumnMapping mapping = resolveMapping(club); + List members = clubMemberRepository.findAllByClubId(clubId); + List sorted = sort(members, sortKey, ascending); + + try { + if (club.getSheetColumnMapping() != null) { + updateMappedColumns(spreadsheetId, sorted, mapping); + } else { + clearAndWriteAll(spreadsheetId, sorted); + applyFormat(spreadsheetId); + } + log.info("Sheet sync done. clubId={}, members={}", clubId, members.size()); + } catch (IOException e) { + log.error( + "Sheet sync failed. clubId={}, spreadsheetId={}, cause={}", + clubId, spreadsheetId, e.getMessage(), e + ); + } + } + + private SheetColumnMapping resolveRawMapping(String mappingJson) { + try { + Map raw = objectMapper.readValue( + mappingJson, new TypeReference<>() {} + ); + int dataStartRow = raw.containsKey("dataStartRow") + ? ((Number)raw.get("dataStartRow")).intValue() : 2; + Map fieldMap = new HashMap<>(); + raw.forEach((key, value) -> { + if (!"dataStartRow".equals(key) && value instanceof Number num) { + fieldMap.put(key, num.intValue()); + } + }); + return new SheetColumnMapping(fieldMap, dataStartRow); + } catch (Exception e) { + log.warn("Failed to parse raw mapping, using default. cause={}", e.getMessage()); + return SheetColumnMapping.defaultMapping(); + } + } + + private SheetColumnMapping resolveMapping(Club club) { + String mappingJson = club.getSheetColumnMapping(); + if (mappingJson == null || mappingJson.isBlank()) { + return SheetColumnMapping.defaultMapping(); + } + return resolveRawMapping(mappingJson); + } + + private void updateMappedColumns( + String spreadsheetId, + List members, + SheetColumnMapping mapping + ) throws IOException { + int dataStartRow = mapping.getDataStartRow(); + clearMappedColumns(spreadsheetId, mapping, dataStartRow); + Map> columnData = buildColumnData(members, mapping); + + List data = new ArrayList<>(); + for (Map.Entry> entry : columnData.entrySet()) { + int colIndex = entry.getKey(); + String colLetter = columnLetter(colIndex); + String range = colLetter + dataStartRow + ":" + colLetter; + List> wrapped = + entry.getValue().stream().map(v -> List.of((Object)v)).toList(); + data.add(new ValueRange().setRange(range).setValues(wrapped)); + } + + if (!data.isEmpty()) { + googleSheetsService.spreadsheets().values() + .batchUpdate(spreadsheetId, + new BatchUpdateValuesRequest() + .setValueInputOption("USER_ENTERED") + .setData(data)) + .execute(); + } + } + + private void clearMappedColumns( + String spreadsheetId, + SheetColumnMapping mapping, + int dataStartRow + ) throws IOException { + List clearRanges = new ArrayList<>(); + for (String field : List.of( + SheetColumnMapping.NAME, SheetColumnMapping.STUDENT_ID, SheetColumnMapping.EMAIL, + SheetColumnMapping.PHONE, SheetColumnMapping.POSITION, SheetColumnMapping.JOINED_AT + )) { + int colIndex = mapping.getColumnIndex(field); + if (colIndex >= 0) { + String colLetter = columnLetter(colIndex); + clearRanges.add(colLetter + dataStartRow + ":" + colLetter); + } + } + if (!clearRanges.isEmpty()) { + googleSheetsService.spreadsheets().values() + .batchClear(spreadsheetId, new BatchClearValuesRequest().setRanges(clearRanges)) + .execute(); + } + } + + private Map> buildColumnData( + List members, + SheetColumnMapping mapping + ) { + Map> columns = new HashMap<>(); + + for (ClubMember member : members) { + putValue(columns, mapping, SheetColumnMapping.NAME, + member.getUser().getName()); + putValue(columns, mapping, SheetColumnMapping.STUDENT_ID, + member.getUser().getStudentNumber()); + putValue(columns, mapping, SheetColumnMapping.EMAIL, + member.getUser().getEmail()); + putValue(columns, mapping, SheetColumnMapping.PHONE, + member.getUser().getPhoneNumber() != null + ? "'" + member.getUser().getPhoneNumber() : ""); + putValue(columns, mapping, SheetColumnMapping.POSITION, + member.getClubPosition().getDescription()); + putValue(columns, mapping, SheetColumnMapping.JOINED_AT, + member.getCreatedAt().format(DATE_FORMATTER)); + } + + return columns; + } + + private void putValue( + Map> columns, + SheetColumnMapping mapping, + String field, + Object value + ) { + int colIndex = mapping.getColumnIndex(field); + if (colIndex >= 0) { + columns.computeIfAbsent(colIndex, k -> new ArrayList<>()).add(value); + } + } + + private void clearAndWriteAll( + String spreadsheetId, + List members + ) throws IOException { + String clearRange = "A:F"; + googleSheetsService.spreadsheets().values() + .clear(spreadsheetId, clearRange, new ClearValuesRequest()) + .execute(); + + List> rows = new ArrayList<>(); + rows.add(HEADER_ROW); + + for (ClubMember member : members) { + String phone = member.getUser().getPhoneNumber() != null + ? "'" + member.getUser().getPhoneNumber() : ""; + rows.add(List.of( + member.getUser().getName(), + member.getUser().getStudentNumber(), + member.getUser().getEmail(), + phone, + member.getClubPosition().getDescription(), + member.getCreatedAt().format(DATE_FORMATTER) + )); + } + + ValueRange body = new ValueRange().setValues(rows); + googleSheetsService.spreadsheets().values() + .update(spreadsheetId, SHEET_RANGE, body) + .setValueInputOption("USER_ENTERED") + .execute(); + } + + private void applyFormat(String spreadsheetId) throws IOException { + List requests = new ArrayList<>(); + + requests.add(new Request().setUpdateSheetProperties( + new UpdateSheetPropertiesRequest() + .setProperties(new SheetProperties() + .setGridProperties(new GridProperties().setFrozenRowCount(1))) + .setFields("gridProperties.frozenRowCount") + )); + + requests.add(new Request().setSetBasicFilter( + new SetBasicFilterRequest() + .setFilter(new BasicFilter() + .setRange(new GridRange().setSheetId(0))) + )); + + googleSheetsService.spreadsheets() + .batchUpdate(spreadsheetId, new BatchUpdateSpreadsheetRequest().setRequests(requests)) + .execute(); + } + + 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); + + }; + + if (!ascending) { + comparator = comparator.reversed(); + } + + return members.stream().sorted(comparator).toList(); + } + + private String columnLetter(int index) { + StringBuilder sb = new StringBuilder(); + index++; + while (index > 0) { + index--; + sb.insert(0, (char)('A' + index % ALPHABET_SIZE)); + index /= ALPHABET_SIZE; + } + return sb.toString(); + } +} diff --git a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java index ed6b7ab4..682a9749 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -91,6 +91,8 @@ public enum ApiResponseCode { NOT_FOUND_BANK(HttpStatus.NOT_FOUND, "해당하는 은행을 찾을 수 없습니다."), NOT_FOUND_VERSION(HttpStatus.NOT_FOUND, "버전을 찾을 수 없습니다."), NOT_FOUND_NOTIFICATION_TOKEN(HttpStatus.NOT_FOUND, "알림 토큰을 찾을 수 없습니다."), + NOT_FOUND_ADVERTISEMENT(HttpStatus.NOT_FOUND, "광고를 찾을 수 없습니다."), + NOT_FOUND_CLUB_SHEET_ID(HttpStatus.NOT_FOUND, "등록된 스프레드시트 ID가 없습니다. 먼저 스프레드시트 ID를 등록해 주세요."), // 405 Method Not Allowed (지원하지 않는 HTTP 메소드) METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "지원하지 않는 HTTP 메소드 입니다."), @@ -115,6 +117,7 @@ public enum ApiResponseCode { // 500 Internal Server Error (서버 오류) CLIENT_ABORTED(HttpStatus.INTERNAL_SERVER_ERROR, "클라이언트에 의해 연결이 중단되었습니다."), FAILED_UPLOAD_FILE(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다."), + FAILED_SYNC_GOOGLE_SHEET(HttpStatus.INTERNAL_SERVER_ERROR, "구글 스프레드시트 동기화에 실패했습니다."), FAILED_SEND_NOTIFICATION(HttpStatus.INTERNAL_SERVER_ERROR, "알림 전송에 실패했습니다."), UNEXPECTED_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버에 예기치 못한 에러가 발생했습니다."); diff --git a/src/main/java/gg/agit/konect/global/config/AsyncConfig.java b/src/main/java/gg/agit/konect/global/config/AsyncConfig.java new file mode 100644 index 00000000..bfc63895 --- /dev/null +++ b/src/main/java/gg/agit/konect/global/config/AsyncConfig.java @@ -0,0 +1,31 @@ +package gg.agit.konect.global.config; + +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +public class AsyncConfig { + + private static final int SHEET_SYNC_CORE_POOL_SIZE = 2; + private static final int SHEET_SYNC_MAX_POOL_SIZE = 4; + private static final int SHEET_SYNC_QUEUE_CAPACITY = 50; + private static final int SHEET_SYNC_AWAIT_TERMINATION_SECONDS = 30; + + @Bean(name = "sheetSyncTaskExecutor") + public Executor sheetSyncTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(SHEET_SYNC_CORE_POOL_SIZE); + executor.setMaxPoolSize(SHEET_SYNC_MAX_POOL_SIZE); + executor.setQueueCapacity(SHEET_SYNC_QUEUE_CAPACITY); + executor.setThreadNamePrefix("sheet-sync-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(SHEET_SYNC_AWAIT_TERMINATION_SECONDS); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java new file mode 100644 index 00000000..8467c3c9 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java @@ -0,0 +1,63 @@ +package gg.agit.konect.infrastructure.googlesheets; + +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.GeneralSecurityException; +import java.util.Arrays; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import com.google.api.services.drive.Drive; +import com.google.api.services.drive.DriveScopes; +import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.SheetsScopes; +import com.google.auth.http.HttpCredentialsAdapter; +import com.google.auth.oauth2.GoogleCredentials; + +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class GoogleSheetsConfig { + + private final GoogleSheetsProperties googleSheetsProperties; + + @Bean + public GoogleCredentials googleCredentials() throws IOException { + try (InputStream in = new FileInputStream(googleSheetsProperties.credentialsPath())) { + return GoogleCredentials.fromStream(in) + .createScoped(Arrays.asList( + SheetsScopes.SPREADSHEETS, + DriveScopes.DRIVE + )); + } + } + + @Bean + public Sheets googleSheetsService( + GoogleCredentials googleCredentials + ) throws IOException, GeneralSecurityException { + return new Sheets.Builder( + GoogleNetHttpTransport.newTrustedTransport(), + GsonFactory.getDefaultInstance(), + new HttpCredentialsAdapter(googleCredentials)) + .setApplicationName(googleSheetsProperties.applicationName()) + .build(); + } + + @Bean + public Drive googleDriveService( + GoogleCredentials googleCredentials + ) throws IOException, GeneralSecurityException { + return new Drive.Builder( + GoogleNetHttpTransport.newTrustedTransport(), + GsonFactory.getDefaultInstance(), + new HttpCredentialsAdapter(googleCredentials)) + .setApplicationName(googleSheetsProperties.applicationName()) + .build(); + } +} diff --git a/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsProperties.java b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsProperties.java new file mode 100644 index 00000000..b8cd5882 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsProperties.java @@ -0,0 +1,10 @@ +package gg.agit.konect.infrastructure.googlesheets; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "google.sheets") +public record GoogleSheetsProperties( + String credentialsPath, + String applicationName +) { +} diff --git a/src/main/resources/application-infrastructure.yml b/src/main/resources/application-infrastructure.yml index b44991e1..a92a092a 100644 --- a/src/main/resources/application-infrastructure.yml +++ b/src/main/resources/application-infrastructure.yml @@ -20,3 +20,9 @@ claude: mcp: url: ${MCP_BRIDGE_URL:http://localhost:3100} + +google: + sheets: + credentials-path: ${GOOGLE_SHEETS_CREDENTIALS_PATH} + application-name: ${GOOGLE_SHEETS_APP_NAME:KONECT} + template-spreadsheet-id: ${GOOGLE_SHEETS_TEMPLATE_ID:} diff --git a/src/main/resources/db/migration/V50__add_google_sheet_id_to_club.sql b/src/main/resources/db/migration/V50__add_google_sheet_id_to_club.sql new file mode 100644 index 00000000..722c7178 --- /dev/null +++ b/src/main/resources/db/migration/V50__add_google_sheet_id_to_club.sql @@ -0,0 +1,2 @@ +ALTER TABLE club + ADD COLUMN google_sheet_id VARCHAR(255) NULL; diff --git a/src/main/resources/db/migration/V51__add_club_fee_payment_table.sql b/src/main/resources/db/migration/V51__add_club_fee_payment_table.sql new file mode 100644 index 00000000..08420496 --- /dev/null +++ b/src/main/resources/db/migration/V51__add_club_fee_payment_table.sql @@ -0,0 +1,16 @@ +CREATE TABLE club_fee_payment ( + id INT NOT NULL AUTO_INCREMENT, + club_id INT NOT NULL, + user_id INT NOT NULL, + is_paid TINYINT(1) NOT NULL DEFAULT 0, + payment_image_url VARCHAR(255), + approved_at TIMESTAMP NULL, + approved_by INT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + CONSTRAINT fk_fee_payment_club FOREIGN KEY (club_id) REFERENCES club (id), + CONSTRAINT fk_fee_payment_user FOREIGN KEY (user_id) REFERENCES users (id), + CONSTRAINT fk_fee_payment_approved_by FOREIGN KEY (approved_by) REFERENCES users (id), + UNIQUE KEY uq_fee_payment_club_user (club_id, user_id) +); diff --git a/src/main/resources/db/migration/V52__add_sheet_column_mapping_to_club.sql b/src/main/resources/db/migration/V52__add_sheet_column_mapping_to_club.sql new file mode 100644 index 00000000..7e9a1be3 --- /dev/null +++ b/src/main/resources/db/migration/V52__add_sheet_column_mapping_to_club.sql @@ -0,0 +1,2 @@ +ALTER TABLE club + ADD COLUMN sheet_column_mapping JSON NULL; diff --git a/src/main/resources/db/migration/V53__add_fee_sheet_columns_to_club.sql b/src/main/resources/db/migration/V53__add_fee_sheet_columns_to_club.sql new file mode 100644 index 00000000..8ff2b6e0 --- /dev/null +++ b/src/main/resources/db/migration/V53__add_fee_sheet_columns_to_club.sql @@ -0,0 +1,3 @@ +ALTER TABLE club + ADD COLUMN fee_sheet_id INT NULL, + ADD COLUMN fee_sheet_column_mapping JSON NULL; diff --git a/src/main/resources/db/migration/V54__add_drive_and_template_columns_to_club.sql b/src/main/resources/db/migration/V54__add_drive_and_template_columns_to_club.sql new file mode 100644 index 00000000..4bdaf225 --- /dev/null +++ b/src/main/resources/db/migration/V54__add_drive_and_template_columns_to_club.sql @@ -0,0 +1,3 @@ +ALTER TABLE club + ADD COLUMN drive_folder_id VARCHAR(255) NULL, + ADD COLUMN template_spreadsheet_id VARCHAR(255) NULL; diff --git a/src/main/resources/db/migration/V55__rollback_fee_payment_feature.sql b/src/main/resources/db/migration/V55__rollback_fee_payment_feature.sql new file mode 100644 index 00000000..f7e84684 --- /dev/null +++ b/src/main/resources/db/migration/V55__rollback_fee_payment_feature.sql @@ -0,0 +1,9 @@ +-- V51 rollback: drop club_fee_payment table +DROP TABLE IF EXISTS club_fee_payment; + +-- V53 rollback: drop fee_sheet columns from club +ALTER TABLE club + DROP COLUMN fee_sheet_id; + +ALTER TABLE club + DROP COLUMN fee_sheet_column_mapping;