diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubRegistrationRequestApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubRegistrationRequestApi.java index 2aab33338..02d940ea8 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubRegistrationRequestApi.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubRegistrationRequestApi.java @@ -1,7 +1,9 @@ package gg.agit.konect.domain.club.controller; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import gg.agit.konect.domain.club.dto.ClubInformationUpdateRequestDto; import gg.agit.konect.domain.club.dto.ClubRegistrationRequestDto; import io.swagger.v3.oas.annotations.parameters.RequestBody; @@ -10,4 +12,9 @@ public interface ClubRegistrationRequestApi { ResponseEntity registerClub( @RequestBody ClubRegistrationRequestDto request ); + + ResponseEntity requestClubInformationUpdate( + @PathVariable(name = "clubId") Integer clubId, + @RequestBody ClubInformationUpdateRequestDto request + ); } diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubRegistrationRequestController.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubRegistrationRequestController.java index fdc00b45c..91bf98b10 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubRegistrationRequestController.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubRegistrationRequestController.java @@ -2,11 +2,13 @@ import org.springframework.http.HttpStatus; 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 org.springframework.web.bind.annotation.RestController; +import gg.agit.konect.domain.club.dto.ClubInformationUpdateRequestDto; import gg.agit.konect.domain.club.dto.ClubRegistrationRequestDto; import gg.agit.konect.domain.club.service.ClubRegistrationRequestService; import gg.agit.konect.global.auth.annotation.PublicApi; @@ -24,7 +26,10 @@ public class ClubRegistrationRequestController implements ClubRegistrationReques private final ClubRegistrationRequestService clubRegistrationRequestService; @Override - @Operation(summary = "동아리 등록 요청", description = "비로그인 사용자가 새 동아리 등록을 요청합니다.") + @Operation( + summary = "동아리 등록 요청", + description = "비로그인 사용자가 새 동아리 등록을 요청합니다." + ) @PostMapping("/registration-requests") @PublicApi public ResponseEntity registerClub( @@ -33,4 +38,19 @@ public ResponseEntity registerClub( clubRegistrationRequestService.register(request); return ResponseEntity.status(HttpStatus.CREATED).build(); } + + @Override + @Operation( + summary = "동아리 정보 수정 요청", + description = "비로그인 사용자가 기존 동아리 정보 수정을 요청합니다." + ) + @PostMapping("/{clubId}/information-update-requests") + @PublicApi + public ResponseEntity requestClubInformationUpdate( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody ClubInformationUpdateRequestDto request + ) { + clubRegistrationRequestService.requestInformationUpdate(clubId, request); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } } diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubInformationUpdateRequestDto.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubInformationUpdateRequestDto.java new file mode 100644 index 000000000..b6b33fecb --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubInformationUpdateRequestDto.java @@ -0,0 +1,67 @@ +package gg.agit.konect.domain.club.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import gg.agit.konect.domain.club.enums.ClubCategory; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +@Schema(name = "ClubInformationUpdateRequest", description = "동아리 정보 수정 요청") +public record ClubInformationUpdateRequestDto( + + @Schema(description = "대학교 명", example = "한국기술교육대학교", requiredMode = REQUIRED) + @NotBlank(message = "대학교 명은 필수입니다.") + String universityName, + + @Schema(description = "동아리 명", example = "BCSD Lab", requiredMode = REQUIRED) + @NotBlank(message = "동아리 명은 필수입니다.") + @Size(max = 50, message = "동아리 명은 최대 50자입니다.") + String clubName, + + @Schema(description = "동아리 분과", example = "ACADEMIC", requiredMode = REQUIRED) + @NotNull(message = "동아리 분과는 필수입니다.") + ClubCategory clubCategory, + + @Schema(description = "동아리 주제", example = "코딩", requiredMode = REQUIRED) + @NotBlank(message = "동아리 주제는 필수입니다.") + @Size(max = 20, message = "동아리 주제는 최대 20자입니다.") + String clubTopic, + + @Schema(description = "동아리 이모지", example = "💻", requiredMode = REQUIRED) + @NotBlank(message = "동아리 이모지는 필수입니다.") + @Size(max = 10, message = "동아리 이모지는 최대 10자입니다.") + String clubEmoji, + + @Schema( + description = "한 줄 소개 (최대 30자)", + example = "코딩 동아리입니다.", + requiredMode = REQUIRED + ) + @NotBlank(message = "한 줄 소개는 필수입니다.") + @Size(max = 30, message = "한 줄 소개는 최대 30자입니다.") + String shortDescription, + + @Schema( + description = "동아리 소개 (최대 2000자)", + example = "상세한 동아리 소개 내용...", + requiredMode = REQUIRED + ) + @NotBlank(message = "동아리 소개는 필수입니다.") + @Size(max = 2000, message = "동아리 소개는 최대 2000자입니다.") + String fullIntroduction, + + @Schema( + description = "사진 및 영상 URL 목록 (최대 5개)", + example = "[\"https://example.com/image1.jpg\"]" + ) + @Size(max = 5, message = "사진 및 영상은 최대 5개까지 업로드 가능합니다.") + List< + @NotBlank(message = "이미지 URL은 필수입니다.") + @Size(max = 500, message = "이미지 URL은 최대 500자입니다.") + String> imageUrls +) { +} diff --git a/src/main/java/gg/agit/konect/domain/club/event/ClubInformationUpdateRequestedEvent.java b/src/main/java/gg/agit/konect/domain/club/event/ClubInformationUpdateRequestedEvent.java new file mode 100644 index 000000000..b59f596e4 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/event/ClubInformationUpdateRequestedEvent.java @@ -0,0 +1,50 @@ +package gg.agit.konect.domain.club.event; + +import java.util.List; + +import gg.agit.konect.domain.club.model.ClubInformationUpdateRequest; + +public record ClubInformationUpdateRequestedEvent( + Integer requestId, + Integer clubId, + String currentUniversityName, + String requestedUniversityName, + String currentClubName, + String requestedClubName, + String currentCategory, + String requestedCategory, + String currentTopic, + String requestedTopic, + String requestedEmoji, + String currentDescription, + String requestedDescription, + String currentFullIntroduction, + String requestedFullIntroduction, + String currentImageUrl, + List requestedImageUrls +) { + + public static ClubInformationUpdateRequestedEvent from(ClubInformationUpdateRequest request) { + return new ClubInformationUpdateRequestedEvent( + request.getId(), + request.getClub().getId(), + request.getClub().getUniversity().getKoreanName(), + request.getUniversityName(), + request.getClub().getName(), + request.getClubName(), + request.getClub().getClubCategory().getDescription(), + request.getClubCategory().getDescription(), + request.getClub().getTopic(), + request.getClubTopic(), + request.getClubEmoji(), + request.getClub().getDescription(), + request.getShortDescription(), + request.getClub().getIntroduce(), + request.getFullIntroduction(), + request.getClub().getImageUrl(), + request.getImages().stream() + .map(image -> image.getImageUrl()) + .toList() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/model/ClubInformationUpdateRequest.java b/src/main/java/gg/agit/konect/domain/club/model/ClubInformationUpdateRequest.java new file mode 100644 index 000000000..2f33401e9 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/model/ClubInformationUpdateRequest.java @@ -0,0 +1,126 @@ +package gg.agit.konect.domain.club.model; + +import static jakarta.persistence.CascadeType.ALL; +import static jakarta.persistence.EnumType.STRING; +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import java.util.ArrayList; +import java.util.List; + +import gg.agit.konect.domain.club.enums.ClubCategory; +import gg.agit.konect.domain.website.model.WebClub; +import gg.agit.konect.global.model.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OrderBy; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Getter +@ToString +@Entity +@Table(name = "club_information_update_request") +@NoArgsConstructor(access = PROTECTED) +public class ClubInformationUpdateRequest extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "id", nullable = false, updatable = false, unique = true) + private Integer id; + + @NotNull + @ToString.Exclude + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "web_club_id", nullable = false) + private WebClub club; + + @NotNull + @Column(name = "university_name", nullable = false) + private String universityName; + + @NotNull + @Column(name = "club_name", length = 50, nullable = false) + private String clubName; + + @NotNull + @Enumerated(value = STRING) + @Column(name = "club_category", nullable = false) + private ClubCategory clubCategory; + + @NotNull + @Column(name = "club_topic", length = 20, nullable = false) + private String clubTopic; + + @NotNull + @Column(name = "club_emoji", length = 10, nullable = false) + private String clubEmoji; + + @NotNull + @Column(name = "short_description", length = 30, nullable = false) + private String shortDescription; + + @NotNull + @Column(name = "full_introduction", columnDefinition = "TEXT", nullable = false) + private String fullIntroduction; + + @OneToMany(mappedBy = "request", cascade = ALL, orphanRemoval = true) + @OrderBy("displayOrder ASC") + private List images = new ArrayList<>(); + + @NotNull + @Enumerated(value = STRING) + @Column(name = "status", length = 20, nullable = false) + private UpdateRequestStatus status; + + @Builder + private ClubInformationUpdateRequest( + Integer id, + WebClub club, + String universityName, + String clubName, + ClubCategory clubCategory, + String clubTopic, + String clubEmoji, + String shortDescription, + String fullIntroduction, + UpdateRequestStatus status + ) { + this.id = id; + this.club = club; + this.universityName = universityName; + this.clubName = clubName; + this.clubCategory = clubCategory; + this.clubTopic = clubTopic; + this.clubEmoji = clubEmoji; + this.shortDescription = shortDescription; + this.fullIntroduction = fullIntroduction; + this.status = status != null ? status : UpdateRequestStatus.PENDING; + } + + public void addImages(List imageUrls) { + for (int i = 0; i < imageUrls.size(); i++) { + ClubInformationUpdateRequestImage image = ClubInformationUpdateRequestImage.builder() + .request(this) + .imageUrl(imageUrls.get(i)) + .displayOrder(i) + .build(); + this.images.add(image); + } + } + + public enum UpdateRequestStatus { + PENDING, APPROVED, REJECTED + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/model/ClubInformationUpdateRequestImage.java b/src/main/java/gg/agit/konect/domain/club/model/ClubInformationUpdateRequestImage.java new file mode 100644 index 000000000..84549f1dc --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/model/ClubInformationUpdateRequestImage.java @@ -0,0 +1,68 @@ +package gg.agit.konect.domain.club.model; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import gg.agit.konect.global.model.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "club_information_update_request_image") +@NoArgsConstructor(access = PROTECTED) +public class ClubInformationUpdateRequestImage extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "id", nullable = false, updatable = false, unique = true) + private Integer id; + + @NotNull + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "request_id", nullable = false) + private ClubInformationUpdateRequest request; + + @NotNull + @Column(name = "image_url", length = 500, nullable = false) + private String imageUrl; + + @NotNull + @Column(name = "display_order", nullable = false) + private Integer displayOrder; + + @Builder + private ClubInformationUpdateRequestImage( + Integer id, + ClubInformationUpdateRequest request, + String imageUrl, + Integer displayOrder + ) { + this.id = id; + this.request = request; + this.imageUrl = imageUrl; + this.displayOrder = displayOrder; + } + + public static class ClubInformationUpdateRequestImageBuilder { + + @Override + public String toString() { + return "ClubInformationUpdateRequestImage.ClubInformationUpdateRequestImageBuilder(" + + "id=" + id + + ", imageUrl=" + imageUrl + + ", displayOrder=" + displayOrder + + ")"; + } + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubInformationUpdateRequestRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubInformationUpdateRequestRepository.java new file mode 100644 index 000000000..55ca66907 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubInformationUpdateRequestRepository.java @@ -0,0 +1,9 @@ +package gg.agit.konect.domain.club.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import gg.agit.konect.domain.club.model.ClubInformationUpdateRequest; + +public interface ClubInformationUpdateRequestRepository + extends JpaRepository { +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubRegistrationRequestService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubRegistrationRequestService.java index aa812da2b..2b2a25203 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubRegistrationRequestService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubRegistrationRequestService.java @@ -4,10 +4,18 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import gg.agit.konect.domain.club.dto.ClubInformationUpdateRequestDto; import gg.agit.konect.domain.club.dto.ClubRegistrationRequestDto; +import gg.agit.konect.domain.club.event.ClubInformationUpdateRequestedEvent; import gg.agit.konect.domain.club.event.ClubRegistrationRequestedEvent; +import gg.agit.konect.domain.club.model.ClubInformationUpdateRequest; import gg.agit.konect.domain.club.model.ClubRegistrationRequest; +import gg.agit.konect.domain.club.repository.ClubInformationUpdateRequestRepository; import gg.agit.konect.domain.club.repository.ClubRegistrationRequestRepository; +import gg.agit.konect.domain.website.model.WebClub; +import gg.agit.konect.domain.website.repository.WebsiteQueryRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; import lombok.RequiredArgsConstructor; @Service @@ -16,6 +24,8 @@ public class ClubRegistrationRequestService { private final ClubRegistrationRequestRepository clubRegistrationRequestRepository; + private final ClubInformationUpdateRequestRepository clubInformationUpdateRequestRepository; + private final WebsiteQueryRepository websiteQueryRepository; private final ApplicationEventPublisher applicationEventPublisher; public void register(ClubRegistrationRequestDto request) { @@ -38,4 +48,27 @@ public void register(ClubRegistrationRequestDto request) { ClubRegistrationRequest saved = clubRegistrationRequestRepository.save(entity); applicationEventPublisher.publishEvent(ClubRegistrationRequestedEvent.from(saved)); } + + public void requestInformationUpdate(Integer clubId, ClubInformationUpdateRequestDto request) { + WebClub club = websiteQueryRepository.findClub(clubId) + .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_CLUB)); + ClubInformationUpdateRequest entity = ClubInformationUpdateRequest.builder() + .club(club) + .universityName(request.universityName()) + .clubName(request.clubName()) + .clubCategory(request.clubCategory()) + .clubTopic(request.clubTopic()) + .clubEmoji(request.clubEmoji()) + .shortDescription(request.shortDescription()) + .fullIntroduction(request.fullIntroduction()) + .status(ClubInformationUpdateRequest.UpdateRequestStatus.PENDING) + .build(); + + if (request.imageUrls() != null && !request.imageUrls().isEmpty()) { + entity.addImages(request.imageUrls()); + } + + ClubInformationUpdateRequest saved = clubInformationUpdateRequestRepository.save(entity); + applicationEventPublisher.publishEvent(ClubInformationUpdateRequestedEvent.from(saved)); + } } diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/enums/SlackMessageTemplate.java b/src/main/java/gg/agit/konect/infrastructure/slack/enums/SlackMessageTemplate.java index f57f65109..1a3db1024 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/enums/SlackMessageTemplate.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/enums/SlackMessageTemplate.java @@ -58,6 +58,28 @@ public enum SlackMessageTemplate { ```%s``` """ ), + CLUB_INFORMATION_UPDATE_REQUEST( + """ + :pencil2: *동아리 정보 수정 요청이 도착했어요* + + :receipt: *요청 ID* : *`%s`* + :id: *동아리 ID* : *`%s`* + :school: *대학교* : %s + :bookmark: *동아리명* : %s + :label: *분과* : %s + :dart: *주제* : %s + :art: *요청 이모지* : *`%s`* + + :memo: *한 줄 소개* + %s + + :page_facing_up: *상세 소개* + %s + + :paperclip: *이미지* + %s + """ + ), ; private final String template; diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/listener/ClubRegistrationRequestSlackListener.java b/src/main/java/gg/agit/konect/infrastructure/slack/listener/ClubRegistrationRequestSlackListener.java index 6a15dfdb0..5f853b9c7 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/listener/ClubRegistrationRequestSlackListener.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/listener/ClubRegistrationRequestSlackListener.java @@ -6,6 +6,7 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionalEventListener; +import gg.agit.konect.domain.club.event.ClubInformationUpdateRequestedEvent; import gg.agit.konect.domain.club.event.ClubRegistrationRequestedEvent; import gg.agit.konect.infrastructure.slack.service.SlackNotificationService; import lombok.RequiredArgsConstructor; @@ -37,4 +38,36 @@ public void handleClubRegistrationRequested(ClubRegistrationRequestedEvent event log.warn("Failed to send club registration request Slack notification. requestId={}", event.requestId(), e); } } + + @Async("slackTaskExecutor") + @TransactionalEventListener(phase = AFTER_COMMIT) + public void handleClubInformationUpdateRequested(ClubInformationUpdateRequestedEvent event) { + try { + slackNotificationService.notifyClubInformationUpdateRequest( + event.requestId(), + event.clubId(), + event.currentUniversityName(), + event.requestedUniversityName(), + event.currentClubName(), + event.requestedClubName(), + event.currentCategory(), + event.requestedCategory(), + event.currentTopic(), + event.requestedTopic(), + event.requestedEmoji(), + event.currentDescription(), + event.requestedDescription(), + event.currentFullIntroduction(), + event.requestedFullIntroduction(), + event.currentImageUrl(), + event.requestedImageUrls() + ); + } catch (RuntimeException e) { + log.warn( + "Failed to send club information update request Slack notification. requestId={}", + event.requestId(), + e + ); + } + } } diff --git a/src/main/java/gg/agit/konect/infrastructure/slack/service/SlackNotificationService.java b/src/main/java/gg/agit/konect/infrastructure/slack/service/SlackNotificationService.java index c2342ec15..1c435bf54 100644 --- a/src/main/java/gg/agit/konect/infrastructure/slack/service/SlackNotificationService.java +++ b/src/main/java/gg/agit/konect/infrastructure/slack/service/SlackNotificationService.java @@ -1,6 +1,7 @@ package gg.agit.konect.infrastructure.slack.service; import static gg.agit.konect.infrastructure.slack.enums.SlackMessageTemplate.ADMIN_CHAT_RECEIVED; +import static gg.agit.konect.infrastructure.slack.enums.SlackMessageTemplate.CLUB_INFORMATION_UPDATE_REQUEST; import static gg.agit.konect.infrastructure.slack.enums.SlackMessageTemplate.CLUB_REGISTRATION_REQUEST; import static gg.agit.konect.infrastructure.slack.enums.SlackMessageTemplate.INQUIRY; import static gg.agit.konect.infrastructure.slack.enums.SlackMessageTemplate.SHEET_SYNC_FAILED; @@ -8,6 +9,7 @@ import static gg.agit.konect.infrastructure.slack.enums.SlackMessageTemplate.USER_WITHDRAWAL; import java.util.List; +import java.util.Objects; import org.springframework.stereotype.Service; @@ -79,6 +81,66 @@ public void notifyClubRegistrationRequest( slackClient.sendMessage(message, slackProperties.webhooks().event()); } + public void notifyClubInformationUpdateRequest( + Integer requestId, + Integer clubId, + String currentUniversityName, + String requestedUniversityName, + String currentClubName, + String requestedClubName, + String currentCategory, + String requestedCategory, + String currentTopic, + String requestedTopic, + String requestedEmoji, + String currentDescription, + String requestedDescription, + String currentFullIntroduction, + String requestedFullIntroduction, + String currentImageUrl, + List requestedImageUrls + ) { + String message = CLUB_INFORMATION_UPDATE_REQUEST.format( + requestId, + clubId, + formatInlineChange(currentUniversityName, requestedUniversityName), + formatInlineChange(currentClubName, requestedClubName), + formatInlineChange(currentCategory, requestedCategory), + formatInlineChange(currentTopic, requestedTopic), + requestedEmoji, + formatBlockChange(currentDescription, requestedDescription), + formatBlockChange(currentFullIntroduction, requestedFullIntroduction), + formatBlockChange(currentImageUrl, formatImageUrls(requestedImageUrls)) + ); + slackClient.sendMessage(message, slackProperties.webhooks().event()); + } + + private String formatInlineChange(String currentValue, String requestedValue) { + if (Objects.equals(currentValue, requestedValue)) { + return wrapInline(currentValue); + } + return wrapInline(currentValue) + " → " + wrapInline(requestedValue); + } + + private String wrapInline(String value) { + return "*`" + value + "`*"; + } + + private String formatBlockChange(String currentValue, String requestedValue) { + if (Objects.equals(currentValue, requestedValue)) { + return wrapBlock(currentValue); + } + return wrapBlock(currentValue) + + System.lineSeparator() + + "→" + + System.lineSeparator() + + wrapBlock(requestedValue); + } + + private String wrapBlock(String value) { + return "```" + value + "```"; + } + private String formatImageUrls(List imageUrls) { if (imageUrls.isEmpty()) { return "없음"; diff --git a/src/main/resources/db/migration/V81__create_club_information_update_request.sql b/src/main/resources/db/migration/V81__create_club_information_update_request.sql new file mode 100644 index 000000000..a06d1936a --- /dev/null +++ b/src/main/resources/db/migration/V81__create_club_information_update_request.sql @@ -0,0 +1,31 @@ +CREATE TABLE IF NOT EXISTS club_information_update_request +( + id INT AUTO_INCREMENT PRIMARY KEY, + web_club_id INT NOT NULL COMMENT '수정 요청 대상 웹 동아리 ID', + university_name VARCHAR(255) NOT NULL COMMENT '요청 대학교 명', + club_name VARCHAR(50) NOT NULL COMMENT '요청 동아리 명', + club_category VARCHAR(255) NOT NULL COMMENT '요청 동아리 분과', + club_topic VARCHAR(20) NOT NULL COMMENT '요청 동아리 주제', + club_emoji VARCHAR(10) NOT NULL COMMENT '요청 동아리 이모지', + short_description VARCHAR(30) NOT NULL COMMENT '요청 한 줄 소개', + full_introduction TEXT NOT NULL COMMENT '요청 동아리 소개', + status VARCHAR(20) DEFAULT 'PENDING' NOT NULL COMMENT '요청 상태 (PENDING, APPROVED, REJECTED)', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL, + + CONSTRAINT chk_club_information_update_request_status CHECK (status IN ('PENDING', 'APPROVED', 'REJECTED')), + FOREIGN KEY (web_club_id) REFERENCES web_club (id) +) COMMENT '동아리 정보 수정 요청'; + +CREATE TABLE IF NOT EXISTS club_information_update_request_image +( + id INT AUTO_INCREMENT PRIMARY KEY, + request_id INT NOT NULL COMMENT '동아리 정보 수정 요청 ID', + image_url VARCHAR(500) NOT NULL COMMENT '이미지 URL', + display_order INT DEFAULT 0 NOT NULL COMMENT '표시 순서', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL, + + CONSTRAINT uq_club_information_update_request_image_order UNIQUE (request_id, display_order), + FOREIGN KEY (request_id) REFERENCES club_information_update_request (id) ON DELETE CASCADE +) COMMENT '동아리 정보 수정 요청 이미지'; diff --git a/src/test/java/gg/agit/konect/integration/domain/club/ClubRegistrationRequestApiTest.java b/src/test/java/gg/agit/konect/integration/domain/club/ClubRegistrationRequestApiTest.java index 808bfd630..6110b3d4b 100644 --- a/src/test/java/gg/agit/konect/integration/domain/club/ClubRegistrationRequestApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/club/ClubRegistrationRequestApiTest.java @@ -7,9 +7,14 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import gg.agit.konect.domain.club.dto.ClubInformationUpdateRequestDto; import gg.agit.konect.domain.club.dto.ClubRegistrationRequestDto; import gg.agit.konect.domain.club.enums.ClubCategory; +import gg.agit.konect.domain.website.model.WebClub; +import gg.agit.konect.domain.website.model.WebUniversity; import gg.agit.konect.support.IntegrationTestSupport; +import gg.agit.konect.support.fixture.WebClubFixture; +import gg.agit.konect.support.fixture.WebUniversityFixture; class ClubRegistrationRequestApiTest extends IntegrationTestSupport { @@ -120,4 +125,63 @@ void registerClubWithLongIntroduction() throws Exception { performPost("/clubs/registration-requests", request) .andExpect(status().isBadRequest()); } + + @Test + @DisplayName("비로그인 사용자도 기존 동아리 정보 수정 요청을 보낼 수 있다") + void requestClubInformationUpdateWithoutLogin() throws Exception { + // given + WebUniversity university = persist(WebUniversityFixture.create()); + WebClub club = persist(WebClubFixture.create(university)); + ClubInformationUpdateRequestDto request = createInformationUpdateRequest(); + + // when & then + performPost("/clubs/" + club.getId() + "/information-update-requests", request) + .andExpect(status().isCreated()); + } + + @Test + @DisplayName("존재하지 않는 동아리에 대한 정보 수정 요청은 404를 반환한다") + void requestClubInformationUpdateWithUnknownClub() throws Exception { + // given + ClubInformationUpdateRequestDto request = createInformationUpdateRequest(); + + // when & then + performPost("/clubs/" + Integer.MAX_VALUE + "/information-update-requests", request) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("동아리 정보 수정 요청 필수값이 없으면 400을 반환한다") + void requestClubInformationUpdateWithMissingFields() throws Exception { + // given + WebUniversity university = persist(WebUniversityFixture.create()); + WebClub club = persist(WebClubFixture.create(university)); + ClubInformationUpdateRequestDto request = new ClubInformationUpdateRequestDto( + "", + "BCSD Lab", + ClubCategory.ACADEMIC, + "코딩", + "💻", + "수정 소개", + "수정 상세 소개입니다.", + List.of() + ); + + // when & then + performPost("/clubs/" + club.getId() + "/information-update-requests", request) + .andExpect(status().isBadRequest()); + } + + private ClubInformationUpdateRequestDto createInformationUpdateRequest() { + return new ClubInformationUpdateRequestDto( + "한국기술교육대학교", + "BCSD Lab", + ClubCategory.ACADEMIC, + "코딩", + "💻", + "수정 소개", + "수정 상세 소개입니다.", + List.of("https://example.com/image1.jpg") + ); + } } diff --git a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubRegistrationRequestServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubRegistrationRequestServiceTest.java index 8d441fac4..1c33a978a 100644 --- a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubRegistrationRequestServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubRegistrationRequestServiceTest.java @@ -6,6 +6,7 @@ import static org.mockito.Mockito.verify; import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -14,19 +15,33 @@ import org.mockito.Mock; import org.springframework.context.ApplicationEventPublisher; +import gg.agit.konect.domain.club.dto.ClubInformationUpdateRequestDto; import gg.agit.konect.domain.club.dto.ClubRegistrationRequestDto; import gg.agit.konect.domain.club.enums.ClubCategory; +import gg.agit.konect.domain.club.event.ClubInformationUpdateRequestedEvent; import gg.agit.konect.domain.club.event.ClubRegistrationRequestedEvent; +import gg.agit.konect.domain.club.model.ClubInformationUpdateRequest; import gg.agit.konect.domain.club.model.ClubRegistrationRequest; +import gg.agit.konect.domain.club.repository.ClubInformationUpdateRequestRepository; import gg.agit.konect.domain.club.repository.ClubRegistrationRequestRepository; import gg.agit.konect.domain.club.service.ClubRegistrationRequestService; +import gg.agit.konect.domain.website.model.WebClub; +import gg.agit.konect.domain.website.repository.WebsiteQueryRepository; import gg.agit.konect.support.ServiceTestSupport; +import gg.agit.konect.support.fixture.WebClubFixture; +import gg.agit.konect.support.fixture.WebUniversityFixture; class ClubRegistrationRequestServiceTest extends ServiceTestSupport { @Mock private ClubRegistrationRequestRepository clubRegistrationRequestRepository; + @Mock + private ClubInformationUpdateRequestRepository clubInformationUpdateRequestRepository; + + @Mock + private WebsiteQueryRepository websiteQueryRepository; + @Mock private ApplicationEventPublisher applicationEventPublisher; @@ -81,4 +96,68 @@ void registerPublishesClubRegistrationRequestedEvent() { assertThat(event.imageUrls()).containsExactlyElementsOf(request.imageUrls()); assertThat(event.imageCount()).isEqualTo(1); } + + @Test + @DisplayName("동아리 정보 수정 요청 저장 후 Slack 알림 이벤트를 발행한다") + void requestInformationUpdatePublishesClubInformationUpdateRequestedEvent() { + // given + WebClub club = WebClubFixture.createWithId( + 1, + WebUniversityFixture.createWithId(1), + "현재 동아리명", + ClubCategory.HOBBY + ); + ClubInformationUpdateRequestDto request = new ClubInformationUpdateRequestDto( + "한국기술교육대학교", + "요청 동아리명", + ClubCategory.ACADEMIC, + "AI", + "🤖", + "수정 소개", + "수정 상세 소개입니다.", + List.of("https://example.com/image1.jpg") + ); + ClubInformationUpdateRequest saved = ClubInformationUpdateRequest.builder() + .id(10) + .club(club) + .universityName(request.universityName()) + .clubName(request.clubName()) + .clubCategory(request.clubCategory()) + .clubTopic(request.clubTopic()) + .clubEmoji(request.clubEmoji()) + .shortDescription(request.shortDescription()) + .fullIntroduction(request.fullIntroduction()) + .build(); + saved.addImages(request.imageUrls()); + given(websiteQueryRepository.findClub(club.getId())).willReturn(Optional.of(club)); + given(clubInformationUpdateRequestRepository.save(any(ClubInformationUpdateRequest.class))).willReturn(saved); + + // when + clubRegistrationRequestService.requestInformationUpdate(club.getId(), request); + + // then + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass( + ClubInformationUpdateRequestedEvent.class + ); + verify(applicationEventPublisher).publishEvent(eventCaptor.capture()); + + ClubInformationUpdateRequestedEvent event = eventCaptor.getValue(); + assertThat(event.requestId()).isEqualTo(saved.getId()); + assertThat(event.clubId()).isEqualTo(club.getId()); + assertThat(event.currentUniversityName()).isEqualTo(club.getUniversity().getKoreanName()); + assertThat(event.requestedUniversityName()).isEqualTo(request.universityName()); + assertThat(event.currentClubName()).isEqualTo(club.getName()); + assertThat(event.requestedClubName()).isEqualTo(request.clubName()); + assertThat(event.currentCategory()).isEqualTo(club.getClubCategory().getDescription()); + assertThat(event.requestedCategory()).isEqualTo(request.clubCategory().getDescription()); + assertThat(event.currentTopic()).isEqualTo(club.getTopic()); + assertThat(event.requestedTopic()).isEqualTo(request.clubTopic()); + assertThat(event.requestedEmoji()).isEqualTo(request.clubEmoji()); + assertThat(event.currentDescription()).isEqualTo(club.getDescription()); + assertThat(event.requestedDescription()).isEqualTo(request.shortDescription()); + assertThat(event.currentFullIntroduction()).isEqualTo(club.getIntroduce()); + assertThat(event.requestedFullIntroduction()).isEqualTo(request.fullIntroduction()); + assertThat(event.currentImageUrl()).isEqualTo(club.getImageUrl()); + assertThat(event.requestedImageUrls()).containsExactlyElementsOf(request.imageUrls()); + } } diff --git a/src/test/java/gg/agit/konect/unit/infrastructure/slack/listener/ClubRegistrationRequestSlackListenerTest.java b/src/test/java/gg/agit/konect/unit/infrastructure/slack/listener/ClubRegistrationRequestSlackListenerTest.java index 8335e18d6..1ed28914f 100644 --- a/src/test/java/gg/agit/konect/unit/infrastructure/slack/listener/ClubRegistrationRequestSlackListenerTest.java +++ b/src/test/java/gg/agit/konect/unit/infrastructure/slack/listener/ClubRegistrationRequestSlackListenerTest.java @@ -11,6 +11,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; +import gg.agit.konect.domain.club.event.ClubInformationUpdateRequestedEvent; import gg.agit.konect.domain.club.event.ClubRegistrationRequestedEvent; import gg.agit.konect.infrastructure.slack.listener.ClubRegistrationRequestSlackListener; import gg.agit.konect.infrastructure.slack.service.SlackNotificationService; @@ -71,6 +72,69 @@ void handleClubRegistrationRequestedSwallowsExceptions() { .doesNotThrowAnyException(); } + @Test + @DisplayName("동아리 정보 수정 요청 이벤트를 Slack 알림 서비스에 위임한다") + void handleClubInformationUpdateRequestedDelegatesToSlackService() { + // given + ClubInformationUpdateRequestedEvent event = createInformationUpdateEvent(); + + // when + clubRegistrationRequestSlackListener.handleClubInformationUpdateRequested(event); + + // then + verify(slackNotificationService).notifyClubInformationUpdateRequest( + event.requestId(), + event.clubId(), + event.currentUniversityName(), + event.requestedUniversityName(), + event.currentClubName(), + event.requestedClubName(), + event.currentCategory(), + event.requestedCategory(), + event.currentTopic(), + event.requestedTopic(), + event.requestedEmoji(), + event.currentDescription(), + event.requestedDescription(), + event.currentFullIntroduction(), + event.requestedFullIntroduction(), + event.currentImageUrl(), + event.requestedImageUrls() + ); + } + + @Test + @DisplayName("동아리 정보 수정 요청 Slack 알림 실패를 삼킨다") + void handleClubInformationUpdateRequestedSwallowsExceptions() { + // given + ClubInformationUpdateRequestedEvent event = createInformationUpdateEvent(); + doThrow(new RuntimeException("slack error")) + .when(slackNotificationService) + .notifyClubInformationUpdateRequest( + event.requestId(), + event.clubId(), + event.currentUniversityName(), + event.requestedUniversityName(), + event.currentClubName(), + event.requestedClubName(), + event.currentCategory(), + event.requestedCategory(), + event.currentTopic(), + event.requestedTopic(), + event.requestedEmoji(), + event.currentDescription(), + event.requestedDescription(), + event.currentFullIntroduction(), + event.requestedFullIntroduction(), + event.currentImageUrl(), + event.requestedImageUrls() + ); + + // when & then + assertThatCode(() -> clubRegistrationRequestSlackListener.handleClubInformationUpdateRequested(event)) + .doesNotThrowAnyException(); + } + private ClubRegistrationRequestedEvent createEvent() { return new ClubRegistrationRequestedEvent( 1, @@ -84,4 +148,26 @@ private ClubRegistrationRequestedEvent createEvent() { List.of("https://example.com/image1.jpg") ); } + + private ClubInformationUpdateRequestedEvent createInformationUpdateEvent() { + return new ClubInformationUpdateRequestedEvent( + 1, + 2, + "한국기술교육대학교", + "한국기술교육대학교", + "현재 동아리명", + "요청 동아리명", + "문화", + "학술", + "코딩", + "AI", + "🤖", + "현재 소개", + "수정 소개", + "현재 상세 소개 내용입니다.", + "수정 상세 소개 내용입니다.", + "https://example.com/current-logo.png", + List.of("https://example.com/image1.jpg") + ); + } } diff --git a/src/test/java/gg/agit/konect/unit/infrastructure/slack/service/SlackNotificationServiceTest.java b/src/test/java/gg/agit/konect/unit/infrastructure/slack/service/SlackNotificationServiceTest.java index d0d5efcb3..26727677c 100644 --- a/src/test/java/gg/agit/konect/unit/infrastructure/slack/service/SlackNotificationServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/infrastructure/slack/service/SlackNotificationServiceTest.java @@ -79,4 +79,60 @@ void notifyClubRegistrationRequestFormatsSlackMessageWithMarkdownAndEmoji() { ); } + @Test + @DisplayName("동아리 정보 수정 요청 Slack 메시지를 마크다운과 이모지로 구성한다") + void notifyClubInformationUpdateRequestFormatsSlackMessageWithMarkdownAndEmoji() { + // when + slackNotificationService.notifyClubInformationUpdateRequest( + 1, + 2, + "한국기술교육대학교", + "한국기술교육대학교", + "현재 동아리명", + "요청 동아리명", + "문화", + "학술", + "코딩", + "AI", + "🤖", + "현재 소개", + "수정 소개", + "현재 상세 소개 내용입니다.", + "수정 상세 소개 내용입니다.", + "https://example.com/current-logo.png", + List.of("https://example.com/image1.jpg") + ); + + // then + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(String.class); + verify(slackClient).sendMessage(messageCaptor.capture(), eq(EVENT_WEBHOOK_URL)); + assertThat(messageCaptor.getValue()).isEqualTo( + """ + :pencil2: *동아리 정보 수정 요청이 도착했어요* + + :receipt: *요청 ID* : *`1`* + :id: *동아리 ID* : *`2`* + :school: *대학교* : *`한국기술교육대학교`* + :bookmark: *동아리명* : *`현재 동아리명`* → *`요청 동아리명`* + :label: *분과* : *`문화`* → *`학술`* + :dart: *주제* : *`코딩`* → *`AI`* + :art: *요청 이모지* : *`🤖`* + + :memo: *한 줄 소개* + ```현재 소개``` + → + ```수정 소개``` + + :page_facing_up: *상세 소개* + ```현재 상세 소개 내용입니다.``` + → + ```수정 상세 소개 내용입니다.``` + + :paperclip: *이미지* + ```https://example.com/current-logo.png``` + → + ```https://example.com/image1.jpg``` + """ + ); + } }