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..605805ce7 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubRegistrationRequestApi.java @@ -0,0 +1,25 @@ +package gg.agit.konect.domain.club.controller; + +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 gg.agit.konect.domain.club.dto.ClubRegistrationRequest; +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; + +@Tag(name = "(Normal) Club - Registration Request: 신규 동아리 등록 요청") +@RequestMapping("/clubs/registration-requests") +public interface ClubRegistrationRequestApi { + + @Operation(summary = "신규 동아리 등록 요청을 보낸다.", description = """ + 로그인하지 않은 사용자도 신규 동아리 등록 요청을 보낼 수 있습니다. + 요청 내용은 가입/탈퇴 알림과 같은 Slack event webhook으로 전달됩니다. + """) + @PostMapping + @PublicApi + ResponseEntity submitClubRegistrationRequest(@Valid @RequestBody ClubRegistrationRequest 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..2dd1c1ad9 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubRegistrationRequestController.java @@ -0,0 +1,23 @@ +package gg.agit.konect.domain.club.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import gg.agit.konect.domain.club.dto.ClubRegistrationRequest; +import gg.agit.konect.domain.club.service.ClubRegistrationRequestService; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/clubs/registration-requests") +public class ClubRegistrationRequestController implements ClubRegistrationRequestApi { + + private final ClubRegistrationRequestService clubRegistrationRequestService; + + @Override + public ResponseEntity submitClubRegistrationRequest(ClubRegistrationRequest request) { + clubRegistrationRequestService.submitClubRegistrationRequest(request); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubCreateRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubCreateRequest.java index 038738742..c5ec369d9 100644 --- a/src/main/java/gg/agit/konect/domain/club/dto/ClubCreateRequest.java +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubCreateRequest.java @@ -47,7 +47,11 @@ public record ClubCreateRequest( @Schema(description = "동아리 주제", example = "코딩", requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank(message = "동아리 주제는 필수 입력입니다.") @Size(max = 20, message = "동아리 주제는 20자 이하여야 합니다.") - String topic + String topic, + + @Schema(description = "동아리 텍스트 이모지", example = "💻") + @Size(max = 20, message = "동아리 이모지는 20자 이하여야 합니다.") + String emoji ) { public Club toEntity(University university) { return Club.builder() @@ -58,6 +62,7 @@ public Club toEntity(University university) { .location(location) .clubCategory(clubCategory) .topic(topic) + .emoji(emoji) .university(university) .isRecruitmentEnabled(false) .isApplicationEnabled(true) diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubRegistrationRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubRegistrationRequest.java new file mode 100644 index 000000000..22207bf7a --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubRegistrationRequest.java @@ -0,0 +1,59 @@ +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.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record ClubRegistrationRequest( + @NotBlank(message = "대학교 명은 필수 입력입니다.") + @Size(max = 50, message = "대학교 명은 50자 이하여야 합니다.") + @Schema(description = "대학교 명", example = "한국기술교육대학교", requiredMode = REQUIRED) + String universityName, + + @NotBlank(message = "동아리 명은 필수 입력입니다.") + @Size(max = 50, message = "동아리 명은 50자 이하여야 합니다.") + @Schema(description = "동아리 명", example = "BCSD Lab", requiredMode = REQUIRED) + String clubName, + + @NotNull(message = "동아리 분과는 필수 입력입니다.") + @Schema(description = "동아리 분과", example = "ACADEMIC", requiredMode = REQUIRED) + ClubCategory clubCategory, + + @NotBlank(message = "동아리 주제는 필수 입력입니다.") + @Size(max = 20, message = "동아리 주제는 20자 이하여야 합니다.") + @Schema(description = "동아리 주제", example = "개발", requiredMode = REQUIRED) + String topic, + + @NotBlank(message = "동아리 이모지는 필수 입력입니다.") + @Size(max = 20, message = "동아리 이모지는 20자 이하여야 합니다.") + @Schema(description = "동아리 텍스트 이모지", example = "💻", requiredMode = REQUIRED) + String emoji, + + @NotBlank(message = "한 줄 소개는 필수 입력입니다.") + @Size(max = 30, message = "한 줄 소개는 30자 이하여야 합니다.") + @Schema(description = "한 줄 소개", example = "즐겁게 서비스 만드는 동아리", requiredMode = REQUIRED) + String description, + + @NotEmpty(message = "사진 및 영상은 필수 입력입니다.") + @Size(max = 5, message = "사진 및 영상은 5개 이하여야 합니다.") + @ArraySchema( + schema = @Schema(description = "사진 및 영상 URL", example = "https://example.com/club-1.png"), + arraySchema = @Schema(description = "사진 및 영상 URL 목록", requiredMode = REQUIRED) + ) + List<@NotBlank(message = "사진 및 영상 URL은 비어 있을 수 없습니다.") + @Size(max = 255, message = "사진 및 영상 URL은 255자 이하여야 합니다.") String> mediaUrls, + + @NotBlank(message = "동아리 소개는 필수 입력입니다.") + @Size(max = 2000, message = "동아리 소개는 2000자 이하여야 합니다.") + @Schema(description = "동아리 소개", example = "BCSD Lab은 IT 서비스 개발 동아리입니다.", requiredMode = REQUIRED) + String introduce +) { +} 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..165876254 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/event/ClubRegistrationRequestedEvent.java @@ -0,0 +1,45 @@ +package gg.agit.konect.domain.club.event; + +import java.util.List; + +import gg.agit.konect.domain.club.dto.ClubRegistrationRequest; +import gg.agit.konect.domain.club.enums.ClubCategory; +import gg.agit.konect.domain.club.model.ClubRegistrationRequestEntity; + +public record ClubRegistrationRequestedEvent( + String universityName, + String clubName, + ClubCategory clubCategory, + String topic, + String emoji, + String description, + List mediaUrls, + String introduce +) { + + public static ClubRegistrationRequestedEvent from(ClubRegistrationRequest request) { + return new ClubRegistrationRequestedEvent( + request.universityName(), + request.clubName(), + request.clubCategory(), + request.topic(), + request.emoji(), + request.description(), + List.copyOf(request.mediaUrls()), + request.introduce() + ); + } + + public static ClubRegistrationRequestedEvent from(ClubRegistrationRequestEntity request) { + return new ClubRegistrationRequestedEvent( + request.getUniversityName(), + request.getClubName(), + request.getClubCategory(), + request.getTopic(), + request.getEmoji(), + request.getDescription(), + request.getMediaUrls(), + request.getIntroduce() + ); + } +} 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 9a01e6673..fcde24fdc 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 @@ -54,6 +54,9 @@ public class Club extends BaseEntity { @Column(name = "topic", length = 20, nullable = false) private String topic; + @Column(name = "emoji", length = 20) + private String emoji; + @Column(name = "description", length = 20, nullable = false) private String description; @@ -109,6 +112,7 @@ private Club( University university, String name, String topic, + String emoji, String description, String introduce, String imageUrl, @@ -127,6 +131,7 @@ private Club( this.university = university; this.name = name; this.topic = topic; + this.emoji = emoji; this.description = description; this.introduce = introduce; this.imageUrl = imageUrl; diff --git a/src/main/java/gg/agit/konect/domain/club/model/ClubRegistrationRequestEntity.java b/src/main/java/gg/agit/konect/domain/club/model/ClubRegistrationRequestEntity.java new file mode 100644 index 000000000..74d67988b --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/model/ClubRegistrationRequestEntity.java @@ -0,0 +1,111 @@ +package gg.agit.konect.domain.club.model; + +import static jakarta.persistence.CascadeType.ALL; +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.dto.ClubRegistrationRequest; +import gg.agit.konect.domain.club.enums.ClubCategory; +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.OneToMany; +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 ClubRegistrationRequestEntity extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "id", nullable = false, updatable = false, unique = true) + private Integer id; + + @Column(name = "university_name", length = 50, nullable = false) + private String universityName; + + @Column(name = "club_name", length = 50, nullable = false) + private String clubName; + + @NotNull + @Enumerated(value = STRING) + @Column(name = "club_category", length = 20, nullable = false) + private ClubCategory clubCategory; + + @Column(name = "topic", length = 20, nullable = false) + private String topic; + + @Column(name = "emoji", length = 20, nullable = false) + private String emoji; + + @Column(name = "description", length = 30, nullable = false) + private String description; + + @Column(name = "introduce", columnDefinition = "TEXT", nullable = false) + private String introduce; + + @OneToMany(mappedBy = "clubRegistrationRequest", cascade = ALL, orphanRemoval = true) + private List media = new ArrayList<>(); + + @Builder + private ClubRegistrationRequestEntity( + Integer id, + String universityName, + String clubName, + ClubCategory clubCategory, + String topic, + String emoji, + String description, + String introduce + ) { + this.id = id; + this.universityName = universityName; + this.clubName = clubName; + this.clubCategory = clubCategory; + this.topic = topic; + this.emoji = emoji; + this.description = description; + this.introduce = introduce; + } + + public static ClubRegistrationRequestEntity from(ClubRegistrationRequest request) { + ClubRegistrationRequestEntity entity = ClubRegistrationRequestEntity.builder() + .universityName(request.universityName()) + .clubName(request.clubName()) + .clubCategory(request.clubCategory()) + .topic(request.topic()) + .emoji(request.emoji()) + .description(request.description()) + .introduce(request.introduce()) + .build(); + + for (int index = 0; index < request.mediaUrls().size(); index++) { + entity.addMedia(request.mediaUrls().get(index), index); + } + return entity; + } + + public List getMediaUrls() { + return media.stream() + .sorted(java.util.Comparator.comparing(ClubRegistrationRequestMedia::getDisplayOrder)) + .map(ClubRegistrationRequestMedia::getUrl) + .toList(); + } + + private void addMedia(String url, Integer displayOrder) { + media.add(ClubRegistrationRequestMedia.of(url, displayOrder, this)); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/model/ClubRegistrationRequestMedia.java b/src/main/java/gg/agit/konect/domain/club/model/ClubRegistrationRequestMedia.java new file mode 100644 index 000000000..d384d3328 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/model/ClubRegistrationRequestMedia.java @@ -0,0 +1,66 @@ +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_media") +@NoArgsConstructor(access = PROTECTED) +public class ClubRegistrationRequestMedia extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "id", nullable = false, updatable = false, unique = true) + private Integer id; + + @Column(name = "url", length = 255, nullable = false) + private String url; + + @Column(name = "display_order", nullable = false) + private Integer displayOrder; + + @NotNull + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "club_registration_request_id", nullable = false) + private ClubRegistrationRequestEntity clubRegistrationRequest; + + @Builder + private ClubRegistrationRequestMedia( + Integer id, + String url, + Integer displayOrder, + ClubRegistrationRequestEntity clubRegistrationRequest + ) { + this.id = id; + this.url = url; + this.displayOrder = displayOrder; + this.clubRegistrationRequest = clubRegistrationRequest; + } + + public static ClubRegistrationRequestMedia of( + String url, + Integer displayOrder, + ClubRegistrationRequestEntity clubRegistrationRequest + ) { + return ClubRegistrationRequestMedia.builder() + .url(url) + .displayOrder(displayOrder) + .clubRegistrationRequest(clubRegistrationRequest) + .build(); + } +} 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..5222860a0 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubRegistrationRequestRepository.java @@ -0,0 +1,8 @@ +package gg.agit.konect.domain.club.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import gg.agit.konect.domain.club.model.ClubRegistrationRequestEntity; + +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..9633ceb45 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubRegistrationRequestService.java @@ -0,0 +1,26 @@ +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.ClubRegistrationRequest; +import gg.agit.konect.domain.club.event.ClubRegistrationRequestedEvent; +import gg.agit.konect.domain.club.model.ClubRegistrationRequestEntity; +import gg.agit.konect.domain.club.repository.ClubRegistrationRequestRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ClubRegistrationRequestService { + + private final ApplicationEventPublisher applicationEventPublisher; + private final ClubRegistrationRequestRepository clubRegistrationRequestRepository; + + @Transactional + public void submitClubRegistrationRequest(ClubRegistrationRequest request) { + ClubRegistrationRequestEntity savedRequest = + clubRegistrationRequestRepository.save(ClubRegistrationRequestEntity.from(request)); + applicationEventPublisher.publishEvent(ClubRegistrationRequestedEvent.from(savedRequest)); + } +} 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..a3568f6d5 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,51 @@ 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 + """ + ), + CLUB_REGISTRATION_REQUEST( """ + *:sparkles: 신규 동아리 등록 요청이 도착했습니다.* + 대학교: %s + 동아리명: %s + 분과: %s + 주제: %s + 이모지: %s + 한 줄 소개: %s + 사진 및 영상: + %s + 동아리 소개: + > %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 + """ ), ; 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..e4d9681e2 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/slack/listener/ClubRegistrationRequestSlackListener.java @@ -0,0 +1,24 @@ +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; + +@Component +@RequiredArgsConstructor +public class ClubRegistrationRequestSlackListener { + + private final SlackNotificationService slackNotificationService; + + @Async("slackTaskExecutor") + @TransactionalEventListener(phase = AFTER_COMMIT) + public void handleClubRegistrationRequested(ClubRegistrationRequestedEvent event) { + slackNotificationService.notifyClubRegistrationRequest(event); + } +} 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..16a0f3baf 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; @@ -8,6 +9,7 @@ import org.springframework.stereotype.Service; +import gg.agit.konect.domain.club.event.ClubRegistrationRequestedEvent; import gg.agit.konect.domain.club.event.SheetSyncFailedEvent; import gg.agit.konect.infrastructure.slack.client.SlackClient; import gg.agit.konect.infrastructure.slack.config.SlackProperties; @@ -35,6 +37,20 @@ public void notifyInquiry(String content) { slackClient.sendMessage(message, slackProperties.webhooks().event()); } + public void notifyClubRegistrationRequest(ClubRegistrationRequestedEvent event) { + String message = CLUB_REGISTRATION_REQUEST.format( + event.universityName(), + event.clubName(), + event.clubCategory().getDescription(), + event.topic(), + event.emoji(), + event.description(), + formatMediaUrls(event), + event.introduce() + ); + slackClient.sendMessage(message, slackProperties.webhooks().event()); + } + public void notifyAdminChatReceived(String senderName, String content) { String message = ADMIN_CHAT_RECEIVED.format(senderName, content); slackClient.sendMessage(message, slackProperties.webhooks().event()); @@ -50,4 +66,10 @@ public void notifySheetSyncFailed(SheetSyncFailedEvent event) { ); slackClient.sendMessage(message, slackProperties.webhooks().error()); } + + private String formatMediaUrls(ClubRegistrationRequestedEvent event) { + return String.join("\n", event.mediaUrls().stream() + .map(url -> "- " + url) + .toList()); + } } diff --git a/src/main/resources/db/migration/V76__add_club_registration_request_table.sql b/src/main/resources/db/migration/V76__add_club_registration_request_table.sql new file mode 100644 index 000000000..f1d45ad88 --- /dev/null +++ b/src/main/resources/db/migration/V76__add_club_registration_request_table.sql @@ -0,0 +1,27 @@ +CREATE TABLE club_registration_request +( + id INT AUTO_INCREMENT PRIMARY KEY, + university_name VARCHAR(50) NOT NULL, + club_name VARCHAR(50) NOT NULL, + club_category VARCHAR(20) NOT NULL, + topic VARCHAR(20) NOT NULL, + emoji VARCHAR(20) NOT NULL, + description VARCHAR(30) NOT NULL, + introduce TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +CREATE TABLE club_registration_request_media +( + id INT AUTO_INCREMENT PRIMARY KEY, + club_registration_request_id INT NOT NULL, + url VARCHAR(255) NOT NULL, + display_order INT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_club_registration_request_media_request + FOREIGN KEY (club_registration_request_id) + REFERENCES club_registration_request (id) + ON DELETE CASCADE +); diff --git a/src/main/resources/db/migration/V77__add_emoji_to_club.sql b/src/main/resources/db/migration/V77__add_emoji_to_club.sql new file mode 100644 index 000000000..54dd182cc --- /dev/null +++ b/src/main/resources/db/migration/V77__add_emoji_to_club.sql @@ -0,0 +1,2 @@ +ALTER TABLE club + ADD COLUMN emoji VARCHAR(20) NULL AFTER topic; diff --git a/src/test/java/gg/agit/konect/integration/domain/club/ClubBasicApiTest.java b/src/test/java/gg/agit/konect/integration/domain/club/ClubBasicApiTest.java index 8ecfd259f..4a6a161f2 100644 --- a/src/test/java/gg/agit/konect/integration/domain/club/ClubBasicApiTest.java +++ b/src/test/java/gg/agit/konect/integration/domain/club/ClubBasicApiTest.java @@ -8,10 +8,12 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; import gg.agit.konect.domain.club.dto.ClubCreateRequest; import gg.agit.konect.domain.club.enums.ClubCategory; import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.repository.ClubRepository; import gg.agit.konect.domain.university.model.University; import gg.agit.konect.domain.user.model.User; import gg.agit.konect.support.IntegrationTestSupport; @@ -26,6 +28,9 @@ class ClubBasicApiTest extends IntegrationTestSupport { private User normalUser; private User adminUser; + @Autowired + private ClubRepository clubRepository; + @BeforeEach void setUp() throws Exception { university = persist(UniversityFixture.create()); @@ -153,7 +158,8 @@ void createClubByAdminSuccess() throws Exception { "https://example.com/image.png", "학생회관 201호", ClubCategory.ACADEMIC, - "코딩" + "코딩", + "💻" ); // when & then @@ -163,6 +169,10 @@ void createClubByAdminSuccess() throws Exception { .andExpect(jsonPath("$.topic").value("코딩")) .andExpect(jsonPath("$.presidentName").value("새회장")) .andExpect(jsonPath("$.memberCount").value(1)); + + clearPersistenceContext(); + Club savedClub = clubRepository.findAll().getFirst(); + org.assertj.core.api.Assertions.assertThat(savedClub.getEmoji()).isEqualTo("💻"); } @Test @@ -179,7 +189,8 @@ void createClubByNormalUserFails() throws Exception { "https://example.com/image.png", "학생회관 201호", ClubCategory.ACADEMIC, - "코딩" + "코딩", + null ); // when & then 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..c3f489a0a --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/club/ClubRegistrationRequestApiTest.java @@ -0,0 +1,140 @@ +package gg.agit.konect.integration.domain.club; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +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.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import gg.agit.konect.domain.club.dto.ClubRegistrationRequest; +import gg.agit.konect.domain.club.enums.ClubCategory; +import gg.agit.konect.domain.club.model.ClubRegistrationRequestEntity; +import gg.agit.konect.domain.club.repository.ClubRegistrationRequestRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.support.IntegrationTestSupport; + +class ClubRegistrationRequestApiTest extends IntegrationTestSupport { + + private static final String CLUB_REGISTRATION_REQUESTS_ENDPOINT = "/clubs/registration-requests"; + + @Autowired + private ClubRegistrationRequestRepository clubRegistrationRequestRepository; + + @Nested + @DisplayName("POST /clubs/registration-requests - 신규 동아리 등록 요청") + class SubmitClubRegistrationRequest { + + @Test + @DisplayName("비로그인 사용자가 신규 동아리 등록 요청을 보낸다") + void submitClubRegistrationRequestSuccess() throws Exception { + // given + ClubRegistrationRequest request = createRequest(); + + // when & then + performPost(CLUB_REGISTRATION_REQUESTS_ENDPOINT, request) + .andExpect(status().isOk()); + + clearPersistenceContext(); + List requests = clubRegistrationRequestRepository.findAll(); + org.assertj.core.api.Assertions.assertThat(requests).hasSize(1); + ClubRegistrationRequestEntity savedRequest = requests.getFirst(); + org.assertj.core.api.Assertions.assertThat(savedRequest.getUniversityName()).isEqualTo("한국기술교육대학교"); + org.assertj.core.api.Assertions.assertThat(savedRequest.getClubName()).isEqualTo("BCSD Lab"); + org.assertj.core.api.Assertions.assertThat(savedRequest.getClubCategory()).isEqualTo(ClubCategory.ACADEMIC); + org.assertj.core.api.Assertions.assertThat(savedRequest.getTopic()).isEqualTo("개발"); + org.assertj.core.api.Assertions.assertThat(savedRequest.getEmoji()).isEqualTo("💻"); + org.assertj.core.api.Assertions.assertThat(savedRequest.getDescription()).isEqualTo("즐겁게 서비스 만드는 동아리"); + org.assertj.core.api.Assertions.assertThat(savedRequest.getMediaUrls()) + .containsExactly("https://example.com/club-1.png", "https://example.com/club-2.mp4"); + org.assertj.core.api.Assertions.assertThat(savedRequest.getIntroduce()) + .isEqualTo("BCSD Lab은 IT 서비스 개발 동아리입니다."); + } + + @Test + @DisplayName("한 줄 소개가 30자를 초과하면 400을 반환한다") + void submitClubRegistrationRequestWithTooLongDescriptionFails() throws Exception { + // given + ClubRegistrationRequest request = new ClubRegistrationRequest( + "한국기술교육대학교", + "BCSD Lab", + ClubCategory.ACADEMIC, + "개발", + "💻", + "가".repeat(31), + List.of("https://example.com/club-1.png"), + "BCSD Lab은 IT 서비스 개발 동아리입니다." + ); + + // when & then + performPost(CLUB_REGISTRATION_REQUESTS_ENDPOINT, request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + @DisplayName("사진 및 영상 URL이 5개를 초과하면 400을 반환한다") + void submitClubRegistrationRequestWithTooManyMediaUrlsFails() throws Exception { + // given + ClubRegistrationRequest request = new ClubRegistrationRequest( + "한국기술교육대학교", + "BCSD Lab", + ClubCategory.ACADEMIC, + "개발", + "💻", + "즐겁게 서비스 만드는 동아리", + List.of( + "https://example.com/club-1.png", + "https://example.com/club-2.png", + "https://example.com/club-3.png", + "https://example.com/club-4.png", + "https://example.com/club-5.png", + "https://example.com/club-6.png" + ), + "BCSD Lab은 IT 서비스 개발 동아리입니다." + ); + + // when & then + performPost(CLUB_REGISTRATION_REQUESTS_ENDPOINT, request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + @DisplayName("동아리 소개가 2000자를 초과하면 400을 반환한다") + void submitClubRegistrationRequestWithTooLongIntroduceFails() throws Exception { + // given + ClubRegistrationRequest request = new ClubRegistrationRequest( + "한국기술교육대학교", + "BCSD Lab", + ClubCategory.ACADEMIC, + "개발", + "💻", + "즐겁게 서비스 만드는 동아리", + List.of("https://example.com/club-1.png"), + "가".repeat(2001) + ); + + // when & then + performPost(CLUB_REGISTRATION_REQUESTS_ENDPOINT, request) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ApiResponseCode.INVALID_REQUEST_BODY.getCode())); + } + + private ClubRegistrationRequest createRequest() { + return new ClubRegistrationRequest( + "한국기술교육대학교", + "BCSD Lab", + ClubCategory.ACADEMIC, + "개발", + "💻", + "즐겁게 서비스 만드는 동아리", + List.of("https://example.com/club-1.png", "https://example.com/club-2.mp4"), + "BCSD Lab은 IT 서비스 개발 동아리입니다." + ); + } + } +} 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..713fc036a --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubRegistrationRequestServiceTest.java @@ -0,0 +1,69 @@ +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.ClubRegistrationRequest; +import gg.agit.konect.domain.club.enums.ClubCategory; +import gg.agit.konect.domain.club.event.ClubRegistrationRequestedEvent; +import gg.agit.konect.domain.club.model.ClubRegistrationRequestEntity; +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 ApplicationEventPublisher applicationEventPublisher; + + @Mock + private ClubRegistrationRequestRepository clubRegistrationRequestRepository; + + @InjectMocks + private ClubRegistrationRequestService clubRegistrationRequestService; + + @Test + @DisplayName("신규 동아리 등록 요청 내용을 이벤트로 발행한다") + void submitClubRegistrationRequestPublishesEvent() { + // given + ClubRegistrationRequest request = new ClubRegistrationRequest( + "한국기술교육대학교", + "BCSD Lab", + ClubCategory.ACADEMIC, + "개발", + "💻", + "즐겁게 서비스 만드는 동아리", + List.of("https://example.com/club-1.png", "https://example.com/club-2.mp4"), + "BCSD Lab은 IT 서비스 개발 동아리입니다." + ); + given(clubRegistrationRequestRepository.save(any(ClubRegistrationRequestEntity.class))) + .willAnswer(invocation -> invocation.getArgument(0)); + + // when + clubRegistrationRequestService.submitClubRegistrationRequest(request); + + // then + ArgumentCaptor entityCaptor = + ArgumentCaptor.forClass(ClubRegistrationRequestEntity.class); + verify(clubRegistrationRequestRepository).save(entityCaptor.capture()); + assertThat(entityCaptor.getValue().getClubName()).isEqualTo("BCSD Lab"); + assertThat(entityCaptor.getValue().getMediaUrls()) + .containsExactly("https://example.com/club-1.png", "https://example.com/club-2.mp4"); + + ArgumentCaptor eventCaptor = + ArgumentCaptor.forClass(ClubRegistrationRequestedEvent.class); + verify(applicationEventPublisher).publishEvent(eventCaptor.capture()); + assertThat(eventCaptor.getValue()).isEqualTo(ClubRegistrationRequestedEvent.from(request)); + } +} diff --git a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubServiceTest.java b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubServiceTest.java index 437c9b466..9c1491922 100644 --- a/src/test/java/gg/agit/konect/unit/domain/club/service/ClubServiceTest.java +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubServiceTest.java @@ -111,7 +111,8 @@ void createClubCreatesClubPresidentAndDefaultQuestions() { "https://example.com/club.png", "학생회관 101호", ClubCategory.ACADEMIC, - "코딩" + "코딩", + null ); Club savedClub = request.toEntity(presidentUser.getUniversity()); ReflectionTestUtils.setField(savedClub, "id", 100); @@ -169,7 +170,8 @@ void createClubRejectsNonAdminRequester() { "https://example.com/club.png", "학생회관 101호", ClubCategory.ACADEMIC, - "코딩" + "코딩", + null ); given(userRepository.getById(userId)).willReturn(user); 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..6da292266 --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/infrastructure/slack/listener/ClubRegistrationRequestSlackListenerTest.java @@ -0,0 +1,48 @@ +package gg.agit.konect.unit.infrastructure.slack.listener; + +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.InjectMocks; +import org.mockito.Mock; + +import gg.agit.konect.domain.club.dto.ClubRegistrationRequest; +import gg.agit.konect.domain.club.enums.ClubCategory; +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 handleClubRegistrationRequestedDelegatesEventToSlackService() { + // given + ClubRegistrationRequestedEvent event = ClubRegistrationRequestedEvent.from(new ClubRegistrationRequest( + "한국기술교육대학교", + "BCSD Lab", + ClubCategory.ACADEMIC, + "개발", + "💻", + "즐겁게 서비스 만드는 동아리", + List.of("https://example.com/club-1.png"), + "BCSD Lab은 IT 서비스 개발 동아리입니다." + )); + + // when + clubRegistrationRequestSlackListener.handleClubRegistrationRequested(event); + + // then + verify(slackNotificationService).notifyClubRegistrationRequest(event); + } +} 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 new file mode 100644 index 000000000..c296ef8fd --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/infrastructure/slack/service/SlackNotificationServiceTest.java @@ -0,0 +1,59 @@ +package gg.agit.konect.unit.infrastructure.slack.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +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 gg.agit.konect.domain.club.dto.ClubRegistrationRequest; +import gg.agit.konect.domain.club.enums.ClubCategory; +import gg.agit.konect.domain.club.event.ClubRegistrationRequestedEvent; +import gg.agit.konect.infrastructure.slack.client.SlackClient; +import gg.agit.konect.infrastructure.slack.config.SlackProperties; +import gg.agit.konect.infrastructure.slack.service.SlackNotificationService; + +class SlackNotificationServiceTest { + + @Test + @DisplayName("신규 동아리 등록 요청을 가입/탈퇴 알림과 같은 event webhook으로 전송한다") + void notifyClubRegistrationRequestSendsMessageToEventWebhook() { + // given + SlackProperties slackProperties = new SlackProperties( + new SlackProperties.Webhooks("https://hooks.slack.com/error", "https://hooks.slack.com/event"), + "secret", + "bot-token" + ); + SlackClient slackClient = mock(SlackClient.class); + SlackNotificationService slackNotificationService = + new SlackNotificationService(slackProperties, slackClient); + ClubRegistrationRequestedEvent event = ClubRegistrationRequestedEvent.from(new ClubRegistrationRequest( + "한국기술교육대학교", + "BCSD Lab", + ClubCategory.ACADEMIC, + "개발", + "💻", + "즐겁게 서비스 만드는 동아리", + List.of("https://example.com/club-1.png"), + "BCSD Lab은 IT 서비스 개발 동아리입니다." + )); + + // when + slackNotificationService.notifyClubRegistrationRequest(event); + + // then + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(String.class); + verify(slackClient).sendMessage(messageCaptor.capture(), eq("https://hooks.slack.com/event")); + assertThat(messageCaptor.getValue()) + .contains("신규 동아리 등록 요청") + .contains("한국기술교육대학교") + .contains("BCSD Lab") + .contains("학술") + .contains("https://example.com/club-1.png"); + } +}