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 new file mode 100644 index 000000000..2aab33338 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubRegistrationRequestApi.java @@ -0,0 +1,13 @@ +package gg.agit.konect.domain.club.controller; + +import org.springframework.http.ResponseEntity; + +import gg.agit.konect.domain.club.dto.ClubRegistrationRequestDto; +import io.swagger.v3.oas.annotations.parameters.RequestBody; + +public interface ClubRegistrationRequestApi { + + ResponseEntity registerClub( + @RequestBody ClubRegistrationRequestDto 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 new file mode 100644 index 000000000..fdc00b45c --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubRegistrationRequestController.java @@ -0,0 +1,36 @@ +package gg.agit.konect.domain.club.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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.ClubRegistrationRequestDto; +import gg.agit.konect.domain.club.service.ClubRegistrationRequestService; +import gg.agit.konect.global.auth.annotation.PublicApi; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Club Registration", description = "동아리 등록 요청 API") +@RestController +@RequestMapping("/clubs") +@RequiredArgsConstructor +public class ClubRegistrationRequestController implements ClubRegistrationRequestApi { + + private final ClubRegistrationRequestService clubRegistrationRequestService; + + @Override + @Operation(summary = "동아리 등록 요청", description = "비로그인 사용자가 새 동아리 등록을 요청합니다.") + @PostMapping("/registration-requests") + @PublicApi + public ResponseEntity registerClub( + @Valid @RequestBody ClubRegistrationRequestDto request + ) { + clubRegistrationRequestService.register(request); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubRegistrationRequestDto.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubRegistrationRequestDto.java new file mode 100644 index 000000000..d3f662747 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubRegistrationRequestDto.java @@ -0,0 +1,58 @@ +package gg.agit.konect.domain.club.dto; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +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 = "ClubRegistrationRequest", description = "동아리 등록 요청") +public record ClubRegistrationRequestDto( + + @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\"]", + requiredMode = NOT_REQUIRED + ) + @Size(max = 5, message = "사진 및 영상은 최대 5개까지 업로드 가능합니다.") + List imageUrls +) { +} diff --git a/src/main/java/gg/agit/konect/domain/club/event/ClubRegistrationRequestedEvent.java b/src/main/java/gg/agit/konect/domain/club/event/ClubRegistrationRequestedEvent.java new file mode 100644 index 000000000..e40bb6b47 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/event/ClubRegistrationRequestedEvent.java @@ -0,0 +1,28 @@ +package gg.agit.konect.domain.club.event; + +import gg.agit.konect.domain.club.model.ClubRegistrationRequest; + +public record ClubRegistrationRequestedEvent( + Integer requestId, + String universityName, + String clubName, + String category, + String topic, + String emoji, + String description, + int imageCount +) { + + public static ClubRegistrationRequestedEvent from(ClubRegistrationRequest request) { + return new ClubRegistrationRequestedEvent( + request.getId(), + request.getUniversityName(), + request.getClubName(), + request.getClubCategory().getDescription(), + request.getClubTopic(), + request.getClubEmoji(), + request.getShortDescription(), + request.getImages().size() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/model/ClubRegistrationRequest.java b/src/main/java/gg/agit/konect/domain/club/model/ClubRegistrationRequest.java new file mode 100644 index 000000000..154480ebb --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/model/ClubRegistrationRequest.java @@ -0,0 +1,112 @@ +package gg.agit.konect.domain.club.model; + +import static jakarta.persistence.EnumType.STRING; +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.global.model.BaseEntity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +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; + +@Getter +@Entity +@Table(name = "club_registration_request") +@NoArgsConstructor(access = PROTECTED) +public class ClubRegistrationRequest extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "id", nullable = false, updatable = false, unique = true) + private Integer id; + + @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 = CascadeType.ALL, orphanRemoval = true) + @OrderBy("displayOrder ASC") + private List images = new ArrayList<>(); + + @NotNull + @Enumerated(value = STRING) + @Column(name = "status", length = 20, nullable = false) + private RegistrationStatus status; + + @Builder + private ClubRegistrationRequest( + Integer id, + String universityName, + String clubName, + ClubCategory clubCategory, + String clubTopic, + String clubEmoji, + String shortDescription, + String fullIntroduction, + RegistrationStatus status + ) { + this.id = id; + 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 : RegistrationStatus.PENDING; + } + + public void addImages(List imageUrls) { + for (int i = 0; i < imageUrls.size(); i++) { + ClubRegistrationRequestImage image = ClubRegistrationRequestImage.builder() + .request(this) + .imageUrl(imageUrls.get(i)) + .displayOrder(i) + .build(); + this.images.add(image); + } + } + + public enum RegistrationStatus { + PENDING, APPROVED, REJECTED + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/model/ClubRegistrationRequestImage.java b/src/main/java/gg/agit/konect/domain/club/model/ClubRegistrationRequestImage.java new file mode 100644 index 000000000..8f2055aba --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/model/ClubRegistrationRequestImage.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_registration_request_image") +@NoArgsConstructor(access = PROTECTED) +public class ClubRegistrationRequestImage 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 ClubRegistrationRequest 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 ClubRegistrationRequestImage( + Integer id, + ClubRegistrationRequest request, + String imageUrl, + Integer displayOrder + ) { + this.id = id; + this.request = request; + this.imageUrl = imageUrl; + this.displayOrder = displayOrder; + } + + public static class ClubRegistrationRequestImageBuilder { + + @Override + public String toString() { + return "ClubRegistrationRequestImage.ClubRegistrationRequestImageBuilder(" + + "id=" + id + + ", imageUrl=" + imageUrl + + ", displayOrder=" + displayOrder + + ")"; + } + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubRegistrationRequestRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubRegistrationRequestRepository.java new file mode 100644 index 000000000..86e029c0f --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubRegistrationRequestRepository.java @@ -0,0 +1,10 @@ +package gg.agit.konect.domain.club.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import gg.agit.konect.domain.club.model.ClubRegistrationRequest; + +@Repository +public interface ClubRegistrationRequestRepository 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 new file mode 100644 index 000000000..aa812da2b --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubRegistrationRequestService.java @@ -0,0 +1,41 @@ +package gg.agit.konect.domain.club.service; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import gg.agit.konect.domain.club.dto.ClubRegistrationRequestDto; +import gg.agit.konect.domain.club.event.ClubRegistrationRequestedEvent; +import gg.agit.konect.domain.club.model.ClubRegistrationRequest; +import gg.agit.konect.domain.club.repository.ClubRegistrationRequestRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class ClubRegistrationRequestService { + + private final ClubRegistrationRequestRepository clubRegistrationRequestRepository; + private final ApplicationEventPublisher applicationEventPublisher; + + public void register(ClubRegistrationRequestDto request) { + ClubRegistrationRequest entity = ClubRegistrationRequest.builder() + .universityName(request.universityName()) + .clubName(request.clubName()) + .clubCategory(request.clubCategory()) + .clubTopic(request.clubTopic()) + .clubEmoji(request.clubEmoji()) + .shortDescription(request.shortDescription()) + .fullIntroduction(request.fullIntroduction()) + .status(ClubRegistrationRequest.RegistrationStatus.PENDING) + .build(); + + // 이미지 추가 + if (request.imageUrls() != null && !request.imageUrls().isEmpty()) { + entity.addImages(request.imageUrls()); + } + + ClubRegistrationRequest saved = clubRegistrationRequestRepository.save(entity); + applicationEventPublisher.publishEvent(ClubRegistrationRequestedEvent.from(saved)); + } +} diff --git a/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubDetailResponse.java b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubDetailResponse.java index 340362a1d..7bc7f56ff 100644 --- a/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubDetailResponse.java +++ b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubDetailResponse.java @@ -4,8 +4,8 @@ import gg.agit.konect.domain.club.enums.ClubCategory; import gg.agit.konect.domain.university.enums.UniversityRegion; -import gg.agit.konect.domain.web.model.WebClub; -import gg.agit.konect.domain.web.model.WebUniversity; +import gg.agit.konect.domain.website.model.WebClub; +import gg.agit.konect.domain.website.model.WebUniversity; import io.swagger.v3.oas.annotations.media.Schema; public record WebsiteClubDetailResponse( diff --git a/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubsResponse.java b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubsResponse.java index f72e56f37..4ef2b5962 100644 --- a/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubsResponse.java +++ b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteClubsResponse.java @@ -1,5 +1,6 @@ package gg.agit.konect.domain.website.dto; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import java.util.Arrays; @@ -9,12 +10,12 @@ import gg.agit.konect.domain.club.enums.ClubCategory; import gg.agit.konect.domain.university.enums.UniversityRegion; -import gg.agit.konect.domain.web.model.WebClub; -import gg.agit.konect.domain.web.model.WebUniversity; +import gg.agit.konect.domain.website.model.WebClub; +import gg.agit.konect.domain.website.model.WebUniversity; import io.swagger.v3.oas.annotations.media.Schema; public record WebsiteClubsResponse( - @Schema(description = "대학 정보", requiredMode = REQUIRED) + @Schema(description = "대학 정보", requiredMode = NOT_REQUIRED) UniversityResponse university, @Schema(description = "전체 동아리 수", example = "28", requiredMode = REQUIRED) diff --git a/src/main/java/gg/agit/konect/domain/web/model/WebClub.java b/src/main/java/gg/agit/konect/domain/website/model/WebClub.java similarity index 98% rename from src/main/java/gg/agit/konect/domain/web/model/WebClub.java rename to src/main/java/gg/agit/konect/domain/website/model/WebClub.java index 73013859e..19165d813 100644 --- a/src/main/java/gg/agit/konect/domain/web/model/WebClub.java +++ b/src/main/java/gg/agit/konect/domain/website/model/WebClub.java @@ -1,4 +1,4 @@ -package gg.agit.konect.domain.web.model; +package gg.agit.konect.domain.website.model; import static jakarta.persistence.EnumType.STRING; import static jakarta.persistence.FetchType.LAZY; diff --git a/src/main/java/gg/agit/konect/domain/web/model/WebUniversity.java b/src/main/java/gg/agit/konect/domain/website/model/WebUniversity.java similarity index 97% rename from src/main/java/gg/agit/konect/domain/web/model/WebUniversity.java rename to src/main/java/gg/agit/konect/domain/website/model/WebUniversity.java index 819b9857d..48eb8e9e0 100644 --- a/src/main/java/gg/agit/konect/domain/web/model/WebUniversity.java +++ b/src/main/java/gg/agit/konect/domain/website/model/WebUniversity.java @@ -1,4 +1,4 @@ -package gg.agit.konect.domain.web.model; +package gg.agit.konect.domain.website.model; import static jakarta.persistence.EnumType.STRING; import static jakarta.persistence.GenerationType.IDENTITY; diff --git a/src/main/java/gg/agit/konect/domain/website/repository/WebsiteQueryRepository.java b/src/main/java/gg/agit/konect/domain/website/repository/WebsiteQueryRepository.java index e5027783e..d51432830 100644 --- a/src/main/java/gg/agit/konect/domain/website/repository/WebsiteQueryRepository.java +++ b/src/main/java/gg/agit/konect/domain/website/repository/WebsiteQueryRepository.java @@ -1,7 +1,7 @@ package gg.agit.konect.domain.website.repository; -import static gg.agit.konect.domain.web.model.QWebClub.webClub; -import static gg.agit.konect.domain.web.model.QWebUniversity.webUniversity; +import static gg.agit.konect.domain.website.model.QWebClub.webClub; +import static gg.agit.konect.domain.website.model.QWebUniversity.webUniversity; import java.util.LinkedHashMap; import java.util.List; @@ -21,8 +21,8 @@ import gg.agit.konect.domain.club.enums.ClubCategory; import gg.agit.konect.domain.university.enums.UniversityRegion; -import gg.agit.konect.domain.web.model.WebClub; -import gg.agit.konect.domain.web.model.WebUniversity; +import gg.agit.konect.domain.website.model.WebClub; +import gg.agit.konect.domain.website.model.WebUniversity; import gg.agit.konect.domain.website.model.WebsiteUniversitySummary; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/gg/agit/konect/domain/website/service/WebsiteService.java b/src/main/java/gg/agit/konect/domain/website/service/WebsiteService.java index eb5f3b09d..d74bcc032 100644 --- a/src/main/java/gg/agit/konect/domain/website/service/WebsiteService.java +++ b/src/main/java/gg/agit/konect/domain/website/service/WebsiteService.java @@ -13,12 +13,12 @@ import org.springframework.transaction.annotation.Transactional; import gg.agit.konect.domain.university.enums.UniversityRegion; -import gg.agit.konect.domain.web.model.WebClub; -import gg.agit.konect.domain.web.model.WebUniversity; import gg.agit.konect.domain.website.dto.WebsiteClubDetailResponse; import gg.agit.konect.domain.website.dto.WebsiteClubListCondition; import gg.agit.konect.domain.website.dto.WebsiteClubsResponse; import gg.agit.konect.domain.website.dto.WebsiteHomeResponse; +import gg.agit.konect.domain.website.model.WebClub; +import gg.agit.konect.domain.website.model.WebUniversity; import gg.agit.konect.domain.website.model.WebsiteUniversitySummary; import gg.agit.konect.domain.website.repository.WebsiteQueryRepository; import gg.agit.konect.global.exception.CustomException; 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 76711b27a..663907143 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 @@ -7,36 +7,48 @@ public enum SlackMessageTemplate { USER_REGISTER( """ - `%s님이 가입하셨습니다. Provider : %s` - """ + `%s님이 가입하셨습니다. Provider : %s` + """ ), USER_WITHDRAWAL( """ - `%s님이 탈퇴하셨습니다. Provider : %s` - """ + `%s님이 탈퇴하셨습니다. Provider : %s` + """ ), INQUIRY( """ - *:incoming_envelope: 사용자로부터 문의가 도착했습니다.* - > %s - """ + *:incoming_envelope: 사용자로부터 문의가 도착했습니다.* + > %s + """ ), ADMIN_CHAT_RECEIVED( """ - *:speech_balloon: 새로운 채팅이 도착했습니다.* - 보낸 사람: %s - > %s - """ + *:speech_balloon: 새로운 채팅이 도착했습니다.* + 보낸 사람: %s + > %s + """ ), SHEET_SYNC_FAILED( """ - *:warning: 시트 동기화 실패* - 동아리 ID: %s - 스프레드시트 ID: `%s` - 유형: %s - 발생 시각: %s - > %s + *:warning: 시트 동기화 실패* + 동아리 ID: %s + 스프레드시트 ID: `%s` + 유형: %s + 발생 시각: %s + > %s + """ + ), + CLUB_REGISTRATION_REQUEST( """ + *:new: 새로운 동아리 등록 요청이 들어왔습니다.* + 요청 ID: %s + 대학교: %s + 동아리명: %s %s + 분과: %s + 주제: %s + 한 줄 소개: %s + 첨부 이미지 수: %d개 + """ ), ; 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 new file mode 100644 index 000000000..5f21566fa --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/slack/listener/ClubRegistrationRequestSlackListener.java @@ -0,0 +1,39 @@ +package gg.agit.konect.infrastructure.slack.listener; + +import static org.springframework.transaction.event.TransactionPhase.AFTER_COMMIT; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +import gg.agit.konect.domain.club.event.ClubRegistrationRequestedEvent; +import gg.agit.konect.infrastructure.slack.service.SlackNotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ClubRegistrationRequestSlackListener { + + private final SlackNotificationService slackNotificationService; + + @Async("slackTaskExecutor") + @TransactionalEventListener(phase = AFTER_COMMIT) + public void handleClubRegistrationRequested(ClubRegistrationRequestedEvent event) { + try { + slackNotificationService.notifyClubRegistrationRequest( + event.requestId(), + event.universityName(), + event.clubName(), + event.category(), + event.topic(), + event.emoji(), + event.description(), + event.imageCount() + ); + } catch (RuntimeException e) { + log.warn("Failed to send club registration 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 86db2f7d1..708d895a6 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_REGISTRATION_REQUEST; import static gg.agit.konect.infrastructure.slack.enums.SlackMessageTemplate.INQUIRY; import static gg.agit.konect.infrastructure.slack.enums.SlackMessageTemplate.SHEET_SYNC_FAILED; import static gg.agit.konect.infrastructure.slack.enums.SlackMessageTemplate.USER_REGISTER; @@ -50,4 +51,27 @@ public void notifySheetSyncFailed(SheetSyncFailedEvent event) { ); slackClient.sendMessage(message, slackProperties.webhooks().error()); } + + public void notifyClubRegistrationRequest( + Integer requestId, + String universityName, + String clubName, + String category, + String topic, + String emoji, + String description, + int imageCount + ) { + String message = CLUB_REGISTRATION_REQUEST.format( + requestId, + universityName, + clubName, + category, + topic, + emoji, + description, + imageCount + ); + slackClient.sendMessage(message, slackProperties.webhooks().event()); + } } diff --git a/src/main/resources/db/migration/V77__create_club_registration_request.sql b/src/main/resources/db/migration/V77__create_club_registration_request.sql new file mode 100644 index 000000000..750aa39cc --- /dev/null +++ b/src/main/resources/db/migration/V77__create_club_registration_request.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS club_registration_request +( + id INT AUTO_INCREMENT PRIMARY KEY, + 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 '동아리 소개 (2000자)', + 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 +) COMMENT '동아리 등록 요청'; + +CREATE TABLE IF NOT EXISTS club_registration_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, + + FOREIGN KEY (request_id) REFERENCES club_registration_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 new file mode 100644 index 000000000..808bfd630 --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/club/ClubRegistrationRequestApiTest.java @@ -0,0 +1,123 @@ +package gg.agit.konect.integration.domain.club; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import gg.agit.konect.domain.club.dto.ClubRegistrationRequestDto; +import gg.agit.konect.domain.club.enums.ClubCategory; +import gg.agit.konect.support.IntegrationTestSupport; + +class ClubRegistrationRequestApiTest extends IntegrationTestSupport { + + @Test + @DisplayName("비로그인 사용자도 동아리 등록 요청을 보낼 수 있다") + void registerClubWithoutLogin() throws Exception { + // given + ClubRegistrationRequestDto request = new ClubRegistrationRequestDto( + "한국기술교육대학교", + "BCSD Lab", + ClubCategory.ACADEMIC, + "코딩", + "💻", + "코딩 동아리입니다.", + "상세한 동아리 소개 내용입니다.", + List.of("https://example.com/image1.jpg") + ); + + // when & then + performPost("/clubs/registration-requests", request) + .andExpect(status().isCreated()); + } + + @Test + @DisplayName("사진 및 영상이 없어도 동아리 등록 요청을 보낼 수 있다") + void registerClubWithoutImages() throws Exception { + // given + ClubRegistrationRequestDto request = new ClubRegistrationRequestDto( + "한국기술교육대학교", + "BCSD Lab", + ClubCategory.ACADEMIC, + "코딩", + "💻", + "코딩 동아리입니다.", + "상세한 동아리 소개 내용입니다.", + null + ); + + // when & then + performPost("/clubs/registration-requests", request) + .andExpect(status().isCreated()); + } + + @Test + @DisplayName("필수값이 없으면 400을 반환한다") + void registerClubWithMissingFields() throws Exception { + // given + ClubRegistrationRequestDto request = new ClubRegistrationRequestDto( + "", + "BCSD Lab", + ClubCategory.ACADEMIC, + "코딩", + "💻", + "코딩 동아리입니다.", + "상세한 동아리 소개 내용입니다.", + List.of() + ); + + // when & then + performPost("/clubs/registration-requests", request) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("이미지가 5개를 초과하면 400을 반환한다") + void registerClubWithTooManyImages() throws Exception { + // given + ClubRegistrationRequestDto request = new ClubRegistrationRequestDto( + "한국기술교육대학교", + "BCSD Lab", + ClubCategory.ACADEMIC, + "코딩", + "💻", + "코딩 동아리입니다.", + "상세한 동아리 소개 내용입니다.", + List.of( + "https://example.com/image1.jpg", + "https://example.com/image2.jpg", + "https://example.com/image3.jpg", + "https://example.com/image4.jpg", + "https://example.com/image5.jpg", + "https://example.com/image6.jpg" + ) + ); + + // when & then + performPost("/clubs/registration-requests", request) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("동아리 소개가 2000자를 초과하면 400을 반환한다") + void registerClubWithLongIntroduction() throws Exception { + // given + String longIntroduction = "a".repeat(2001); + ClubRegistrationRequestDto request = new ClubRegistrationRequestDto( + "한국기술교육대학교", + "BCSD Lab", + ClubCategory.ACADEMIC, + "코딩", + "💻", + "코딩 동아리입니다.", + longIntroduction, + List.of() + ); + + // when & then + performPost("/clubs/registration-requests", request) + .andExpect(status().isBadRequest()); + } +} diff --git a/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java b/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java index edd13ae06..acdebe42b 100644 --- a/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java @@ -17,8 +17,8 @@ import gg.agit.konect.domain.club.enums.ClubCategory; import gg.agit.konect.domain.university.enums.Campus; import gg.agit.konect.domain.university.enums.UniversityRegion; -import gg.agit.konect.domain.web.model.WebClub; -import gg.agit.konect.domain.web.model.WebUniversity; +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; diff --git a/src/test/java/gg/agit/konect/support/fixture/WebClubFixture.java b/src/test/java/gg/agit/konect/support/fixture/WebClubFixture.java index 05e0f16bc..3cc983142 100644 --- a/src/test/java/gg/agit/konect/support/fixture/WebClubFixture.java +++ b/src/test/java/gg/agit/konect/support/fixture/WebClubFixture.java @@ -3,8 +3,8 @@ import org.springframework.test.util.ReflectionTestUtils; import gg.agit.konect.domain.club.enums.ClubCategory; -import gg.agit.konect.domain.web.model.WebClub; -import gg.agit.konect.domain.web.model.WebUniversity; +import gg.agit.konect.domain.website.model.WebClub; +import gg.agit.konect.domain.website.model.WebUniversity; public class WebClubFixture { diff --git a/src/test/java/gg/agit/konect/support/fixture/WebUniversityFixture.java b/src/test/java/gg/agit/konect/support/fixture/WebUniversityFixture.java index 9cfdead7e..a822666c9 100644 --- a/src/test/java/gg/agit/konect/support/fixture/WebUniversityFixture.java +++ b/src/test/java/gg/agit/konect/support/fixture/WebUniversityFixture.java @@ -4,7 +4,7 @@ import gg.agit.konect.domain.university.enums.Campus; import gg.agit.konect.domain.university.enums.UniversityRegion; -import gg.agit.konect.domain.web.model.WebUniversity; +import gg.agit.konect.domain.website.model.WebUniversity; public class WebUniversityFixture { 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 new file mode 100644 index 000000000..63672c18f --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubRegistrationRequestServiceTest.java @@ -0,0 +1,79 @@ +package gg.agit.konect.unit.domain.club.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.context.ApplicationEventPublisher; + +import gg.agit.konect.domain.club.dto.ClubRegistrationRequestDto; +import gg.agit.konect.domain.club.enums.ClubCategory; +import gg.agit.konect.domain.club.event.ClubRegistrationRequestedEvent; +import gg.agit.konect.domain.club.model.ClubRegistrationRequest; +import gg.agit.konect.domain.club.repository.ClubRegistrationRequestRepository; +import gg.agit.konect.domain.club.service.ClubRegistrationRequestService; +import gg.agit.konect.support.ServiceTestSupport; + +class ClubRegistrationRequestServiceTest extends ServiceTestSupport { + + @Mock + private ClubRegistrationRequestRepository clubRegistrationRequestRepository; + + @Mock + private ApplicationEventPublisher applicationEventPublisher; + + @InjectMocks + private ClubRegistrationRequestService clubRegistrationRequestService; + + @Test + @DisplayName("동아리 등록 요청 저장 후 Slack 알림 이벤트를 발행한다") + void registerPublishesClubRegistrationRequestedEvent() { + // given + ClubRegistrationRequestDto request = new ClubRegistrationRequestDto( + "한국기술교육대학교", + "BCSD Lab", + ClubCategory.ACADEMIC, + "코딩", + "💻", + "코딩 동아리입니다.", + "상세한 동아리 소개 내용입니다.", + List.of("https://example.com/image1.jpg") + ); + ClubRegistrationRequest saved = ClubRegistrationRequest.builder() + .id(1) + .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(clubRegistrationRequestRepository.save(any(ClubRegistrationRequest.class))).willReturn(saved); + + // when + clubRegistrationRequestService.register(request); + + // then + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass( + ClubRegistrationRequestedEvent.class + ); + verify(applicationEventPublisher).publishEvent(eventCaptor.capture()); + + ClubRegistrationRequestedEvent event = eventCaptor.getValue(); + assertThat(event.requestId()).isEqualTo(saved.getId()); + assertThat(event.universityName()).isEqualTo(request.universityName()); + assertThat(event.clubName()).isEqualTo(request.clubName()); + assertThat(event.category()).isEqualTo(request.clubCategory().getDescription()); + assertThat(event.imageCount()).isEqualTo(1); + } +} 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 new file mode 100644 index 000000000..1850aa6b5 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/infrastructure/slack/listener/ClubRegistrationRequestSlackListenerTest.java @@ -0,0 +1,82 @@ +package gg.agit.konect.unit.infrastructure.slack.listener; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import gg.agit.konect.domain.club.event.ClubRegistrationRequestedEvent; +import gg.agit.konect.infrastructure.slack.listener.ClubRegistrationRequestSlackListener; +import gg.agit.konect.infrastructure.slack.service.SlackNotificationService; +import gg.agit.konect.support.ServiceTestSupport; + +class ClubRegistrationRequestSlackListenerTest extends ServiceTestSupport { + + @Mock + private SlackNotificationService slackNotificationService; + + @InjectMocks + private ClubRegistrationRequestSlackListener clubRegistrationRequestSlackListener; + + @Test + @DisplayName("동아리 등록 요청 이벤트를 Slack 알림 서비스에 위임한다") + void handleClubRegistrationRequestedDelegatesToSlackService() { + // given + ClubRegistrationRequestedEvent event = createEvent(); + + // when + clubRegistrationRequestSlackListener.handleClubRegistrationRequested(event); + + // then + verify(slackNotificationService).notifyClubRegistrationRequest( + event.requestId(), + event.universityName(), + event.clubName(), + event.category(), + event.topic(), + event.emoji(), + event.description(), + event.imageCount() + ); + } + + @Test + @DisplayName("Slack 알림 실패가 이벤트 처리 밖으로 전파되지 않는다") + void handleClubRegistrationRequestedSwallowsExceptions() { + // given + ClubRegistrationRequestedEvent event = createEvent(); + doThrow(new RuntimeException("slack error")) + .when(slackNotificationService) + .notifyClubRegistrationRequest( + event.requestId(), + event.universityName(), + event.clubName(), + event.category(), + event.topic(), + event.emoji(), + event.description(), + event.imageCount() + ); + + // when & then + assertThatCode(() -> clubRegistrationRequestSlackListener.handleClubRegistrationRequested(event)) + .doesNotThrowAnyException(); + } + + private ClubRegistrationRequestedEvent createEvent() { + return new ClubRegistrationRequestedEvent( + 1, + "한국기술교육대학교", + "BCSD Lab", + "학술", + "코딩", + "💻", + "코딩 동아리입니다.", + 1 + ); + } +}