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..02d940ea8 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubRegistrationRequestApi.java @@ -0,0 +1,20 @@ +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; + +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 new file mode 100644 index 000000000..91bf98b10 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubRegistrationRequestController.java @@ -0,0 +1,56 @@ +package gg.agit.konect.domain.club.controller; + +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; +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(); + } + + @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/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/enums/ClubCategory.java b/src/main/java/gg/agit/konect/domain/club/enums/ClubCategory.java index aa5fbf010..45422b532 100644 --- a/src/main/java/gg/agit/konect/domain/club/enums/ClubCategory.java +++ b/src/main/java/gg/agit/konect/domain/club/enums/ClubCategory.java @@ -1,18 +1,30 @@ package gg.agit.konect.domain.club.enums; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; + import lombok.Getter; import lombok.RequiredArgsConstructor; -@Getter @RequiredArgsConstructor public enum ClubCategory { - ACADEMIC("학술"), - SPORTS("운동"), - HOBBY("취미"), - RELIGION("종교"), - PERFORMANCE("공연"), - JUNIOR("준동아리") - ; + PERFORMANCE("공연", 1), + SOCIAL_SERVICE("사회/봉사", 2), + EXHIBITION_CREATION("전시/창작", 3), + RELIGION("종교", 4), + SPORTS("체육(운동)", 5), + HOBBY("취미", 6), + ACADEMIC("학술", 7), + ETC("기타", 8); + @Getter private final String description; + private final int displayOrder; + + public static List sortedForDisplay() { + return Arrays.stream(values()) + .sorted(Comparator.comparingInt(category -> category.displayOrder)) + .toList(); + } } 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/event/ClubRegistrationRequestedEvent.java b/src/main/java/gg/agit/konect/domain/club/event/ClubRegistrationRequestedEvent.java new file mode 100644 index 000000000..7d357f49c --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/event/ClubRegistrationRequestedEvent.java @@ -0,0 +1,38 @@ +package gg.agit.konect.domain.club.event; + +import java.util.List; + +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, + String fullIntroduction, + List imageUrls +) { + + 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.getFullIntroduction(), + request.getImages().stream() + .map(image -> image.getImageUrl()) + .toList() + ); + } + + public int imageCount() { + return imageUrls.size(); + } +} 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/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/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/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..2b2a25203 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubRegistrationRequestService.java @@ -0,0 +1,74 @@ +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.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 +@RequiredArgsConstructor +@Transactional +public class ClubRegistrationRequestService { + + private final ClubRegistrationRequestRepository clubRegistrationRequestRepository; + private final ClubInformationUpdateRequestRepository clubInformationUpdateRequestRepository; + private final WebsiteQueryRepository websiteQueryRepository; + 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)); + } + + 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/domain/university/enums/UniversityRegion.java b/src/main/java/gg/agit/konect/domain/university/enums/UniversityRegion.java index e58653a72..080bd3f9c 100644 --- a/src/main/java/gg/agit/konect/domain/university/enums/UniversityRegion.java +++ b/src/main/java/gg/agit/konect/domain/university/enums/UniversityRegion.java @@ -1,5 +1,9 @@ package gg.agit.konect.domain.university.enums; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; + import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -18,4 +22,16 @@ public enum UniversityRegion { ; private final String displayName; + + public static List sortedForDisplay() { + return Arrays.stream(values()) + // 지역 미지정은 실제 지역 필터가 아니므로 가나다 정렬과 무관하게 마지막에 노출한다. + .sorted(Comparator.comparing(UniversityRegion::isUnknown) + .thenComparing(UniversityRegion::getDisplayName)) + .toList(); + } + + private boolean isUnknown() { + return this == UNKNOWN; + } } 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 0b30a2691..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 @@ -2,12 +2,10 @@ import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; -import java.time.LocalDateTime; - import gg.agit.konect.domain.club.enums.ClubCategory; -import gg.agit.konect.domain.club.model.Club; -import gg.agit.konect.domain.club.model.ClubRecruitment; import gg.agit.konect.domain.university.enums.UniversityRegion; +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( @@ -26,6 +24,9 @@ public record WebsiteClubDetailResponse( @Schema(description = "분과명", example = "학술", requiredMode = REQUIRED) String categoryName, + @Schema(description = "동아리 주제", example = "코딩", requiredMode = REQUIRED) + String topic, + @Schema(description = "한 줄 소개", example = "테스트 동아리 소개", requiredMode = REQUIRED) String description, @@ -35,14 +36,8 @@ public record WebsiteClubDetailResponse( @Schema(description = "활동 위치", example = "학생회관 101호", requiredMode = REQUIRED) String location, - @Schema(description = "등록 회원 수", example = "31", requiredMode = REQUIRED) - Long memberCount, - @Schema(description = "대학 정보", requiredMode = REQUIRED) - University university, - - @Schema(description = "모집 정보", requiredMode = REQUIRED) - Recruitment recruitment + University university ) { @Schema(name = "WebsiteClubDetailUniversityResponse") @@ -74,59 +69,27 @@ public record University( ) { } - public record Recruitment( - @Schema(description = "모집 활성화 여부", example = "true", requiredMode = REQUIRED) - Boolean isRecruitmentEnabled, - - @Schema(description = "상시 모집 여부", example = "false", requiredMode = REQUIRED) - Boolean isAlwaysRecruiting, - - @Schema(description = "모집 시작 일시", requiredMode = REQUIRED) - LocalDateTime startAt, - - @Schema(description = "모집 마감 일시", requiredMode = REQUIRED) - LocalDateTime endAt, - - @Schema(description = "모집 공고 내용", requiredMode = REQUIRED) - String content - ) { - private static Recruitment from(Club club) { - ClubRecruitment recruitment = club.getClubRecruitment(); - if (recruitment == null) { - return new Recruitment(club.getIsRecruitmentEnabled(), false, null, null, null); - } - - return new Recruitment( - club.getIsRecruitmentEnabled(), - recruitment.getIsAlwaysRecruiting(), - recruitment.getStartAt(), - recruitment.getEndAt(), - recruitment.getContent() - ); - } - } - - public static WebsiteClubDetailResponse of(Club club, Long memberCount, Long universityClubCount) { + public static WebsiteClubDetailResponse of(WebClub club, Long universityClubCount) { + WebUniversity university = club.getUniversity(); return new WebsiteClubDetailResponse( club.getId(), club.getName(), club.getImageUrl(), club.getClubCategory(), club.getClubCategory().getDescription(), + club.getTopic(), club.getDescription(), club.getIntroduce(), club.getLocation(), - memberCount, new University( - club.getUniversity().getId(), - club.getUniversity().getKoreanName(), - club.getUniversity().getCampus().getDisplayName(), - club.getUniversity().getRegion(), - club.getUniversity().getRegion().getDisplayName(), - club.getUniversity().getImageUrl(), + university.getId(), + university.getKoreanName(), + university.getCampus().getDisplayName(), + university.getRegion(), + university.getRegion().getDisplayName(), + university.getImageUrl(), universityClubCount - ), - Recruitment.from(club) + ) ); } } 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 7ec84e588..d809c541f 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,21 +1,20 @@ 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; import java.util.List; -import java.util.Map; import org.springframework.data.domain.Page; import gg.agit.konect.domain.club.enums.ClubCategory; -import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.university.enums.UniversityRegion; -import gg.agit.konect.domain.university.model.University; +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) @@ -56,9 +55,12 @@ public record UniversityResponse( example = "https://example.com/koreatech-logo.png", requiredMode = REQUIRED ) - String imageUrl + String imageUrl, + + @Schema(description = "대학 전체 동아리 수", example = "28", requiredMode = REQUIRED) + Long clubCount ) { - public static UniversityResponse from(University university) { + public static UniversityResponse of(WebUniversity university, Long clubCount) { if (university == null) { return null; } @@ -69,7 +71,8 @@ public static UniversityResponse from(University university) { university.getCampus().getDisplayName(), university.getRegion(), university.getRegion().getDisplayName(), - university.getImageUrl() + university.getImageUrl(), + clubCount ); } } @@ -105,61 +108,61 @@ public record ClubResponse( @Schema(description = "분과명", example = "학술", requiredMode = REQUIRED) String categoryName, - @Schema(description = "한 줄 소개", example = "테스트 동아리 소개", requiredMode = REQUIRED) - String description, + @Schema(description = "동아리 주제", example = "코딩", requiredMode = REQUIRED) + String topic, - @Schema(description = "등록 회원 수", example = "31", requiredMode = REQUIRED) - Long memberCount + @Schema(description = "한 줄 소개", example = "테스트 동아리 소개", requiredMode = REQUIRED) + String description ) { - public static ClubResponse of(Club club, Long memberCount) { + public static ClubResponse from(WebClub club) { return new ClubResponse( club.getId(), club.getName(), club.getImageUrl(), club.getClubCategory(), club.getClubCategory().getDescription(), - club.getDescription(), - memberCount + club.getTopic(), + club.getDescription() ); } } public static WebsiteClubsResponse of( - University university, - Page page, - Map memberCounts, - Map categoryCounts + WebUniversity university, + Page page, + Long universityClubCount, + java.util.Map categoryCounts ) { return new WebsiteClubsResponse( - UniversityResponse.from(university), + UniversityResponse.of(university, universityClubCount), page.getTotalElements(), page.getTotalPages(), page.getNumber() + 1, createCategories(categoryCounts), - createClubs(page.getContent(), memberCounts) + createClubs(page.getContent()) ); } - public static WebsiteClubsResponse recent(List clubs, Map memberCounts) { + public static WebsiteClubsResponse recent(List clubs) { return new WebsiteClubsResponse( null, (long)clubs.size(), 1, 1, List.of(), - createClubs(clubs, memberCounts) + createClubs(clubs) ); } - private static List createCategories(Map categoryCounts) { - return Arrays.stream(ClubCategory.values()) + private static List createCategories(java.util.Map categoryCounts) { + return ClubCategory.sortedForDisplay().stream() .map(category -> CategoryCountResponse.of(category, categoryCounts.getOrDefault(category, 0L))) .toList(); } - private static List createClubs(List clubs, Map memberCounts) { + private static List createClubs(List clubs) { return clubs.stream() - .map(club -> ClubResponse.of(club, memberCounts.getOrDefault(club.getId(), 0L))) + .map(ClubResponse::from) .toList(); } } diff --git a/src/main/java/gg/agit/konect/domain/website/dto/WebsiteHomeResponse.java b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteHomeResponse.java index 8de1d2616..9ce7ab25f 100644 --- a/src/main/java/gg/agit/konect/domain/website/dto/WebsiteHomeResponse.java +++ b/src/main/java/gg/agit/konect/domain/website/dto/WebsiteHomeResponse.java @@ -12,9 +12,23 @@ public record WebsiteHomeResponse( @Schema(description = "검색 결과 대학 수", example = "28", requiredMode = REQUIRED) Integer totalUniversityCount, + @Schema(description = "지역 목록", requiredMode = REQUIRED) + List regions, + @Schema(description = "대학 목록", requiredMode = REQUIRED) List universities ) { + public record RegionResponse( + @Schema(description = "지역 코드", example = "CHUNGCHEONG", requiredMode = REQUIRED) + UniversityRegion region, + + @Schema(description = "지역명", example = "충청도", requiredMode = REQUIRED) + String regionName + ) { + public static RegionResponse from(UniversityRegion region) { + return new RegionResponse(region, region.getDisplayName()); + } + } @Schema(name = "WebsiteHomeUniversityResponse") public record UniversityResponse( @@ -59,9 +73,16 @@ public static UniversityResponse from(WebsiteUniversitySummary summary) { public static WebsiteHomeResponse from(List summaries) { return new WebsiteHomeResponse( summaries.size(), + createRegions(), summaries.stream() .map(UniversityResponse::from) .toList() ); } + + private static List createRegions() { + return UniversityRegion.sortedForDisplay().stream() + .map(RegionResponse::from) + .toList(); + } } diff --git a/src/main/java/gg/agit/konect/domain/website/model/WebClub.java b/src/main/java/gg/agit/konect/domain/website/model/WebClub.java new file mode 100644 index 000000000..90e186302 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/website/model/WebClub.java @@ -0,0 +1,83 @@ +package gg.agit.konect.domain.website.model; + +import static jakarta.persistence.EnumType.STRING; +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +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.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 = "web_club") +@NoArgsConstructor(access = PROTECTED) +public class WebClub extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "id", nullable = false, updatable = false, unique = true) + private Integer id; + + @NotNull + @Enumerated(value = STRING) + @Column(name = "club_category", nullable = false) + private ClubCategory clubCategory; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "university_id", nullable = false) + private WebUniversity university; + + @Column(name = "name", length = 50, nullable = false) + private String name; + + @Column(name = "topic", length = 20, nullable = false) + private String topic; + + @Column(name = "description", length = 30, nullable = false) + private String description; + + @Column(name = "introduce", columnDefinition = "TEXT", nullable = false) + private String introduce; + + @Column(name = "image_url", length = 255, nullable = false) + private String imageUrl; + + @Column(name = "location", length = 255, nullable = false) + private String location; + + @Builder + private WebClub( + Integer id, + ClubCategory clubCategory, + WebUniversity university, + String name, + String topic, + String description, + String introduce, + String imageUrl, + String location + ) { + this.id = id; + this.clubCategory = clubCategory; + this.university = university; + this.name = name; + this.topic = topic; + this.description = description; + this.introduce = introduce; + this.imageUrl = imageUrl; + this.location = location; + } +} diff --git a/src/main/java/gg/agit/konect/domain/website/model/WebUniversity.java b/src/main/java/gg/agit/konect/domain/website/model/WebUniversity.java new file mode 100644 index 000000000..48eb8e9e0 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/website/model/WebUniversity.java @@ -0,0 +1,66 @@ +package gg.agit.konect.domain.website.model; + +import static jakarta.persistence.EnumType.STRING; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import gg.agit.konect.domain.university.enums.Campus; +import gg.agit.konect.domain.university.enums.UniversityRegion; +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.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table( + name = "web_university", + uniqueConstraints = { + @UniqueConstraint( + name = "uq_web_university_korean_name_campus", + columnNames = {"korean_name", "campus"} + ), + }) +@NoArgsConstructor(access = PROTECTED) +public class WebUniversity extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "id", nullable = false, updatable = false, unique = true) + private Integer id; + + @NotNull + @Column(name = "korean_name", nullable = false) + private String koreanName; + + @NotNull + @Enumerated(value = STRING) + @Column(name = "campus", nullable = false) + private Campus campus; + + @NotNull + @Enumerated(value = STRING) + @Column(name = "region", nullable = false) + private UniversityRegion region; + + @NotNull + @Column(name = "image_url", nullable = false) + private String imageUrl; + + @Builder + private WebUniversity(Integer id, String koreanName, Campus campus, UniversityRegion region, String imageUrl) { + this.id = id; + this.koreanName = koreanName; + this.campus = campus; + this.region = region; + this.imageUrl = imageUrl; + } +} 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 d4f00ee99..c8d2acec3 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,9 +1,7 @@ package gg.agit.konect.domain.website.repository; -import static gg.agit.konect.domain.club.model.QClub.club; -import static gg.agit.konect.domain.club.model.QClubMember.clubMember; -import static gg.agit.konect.domain.club.model.QClubRecruitment.clubRecruitment; -import static gg.agit.konect.domain.university.model.QUniversity.university; +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; @@ -22,9 +20,9 @@ import com.querydsl.jpa.impl.JPAQueryFactory; import gg.agit.konect.domain.club.enums.ClubCategory; -import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.university.enums.UniversityRegion; -import gg.agit.konect.domain.university.model.University; +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; @@ -38,51 +36,51 @@ public List findUniversitySummaries(String query, Univ BooleanBuilder condition = new BooleanBuilder(); addUniversitySearchCondition(condition, query); addUniversityRegionCondition(condition, region); - NumberExpression clubCount = club.id.countDistinct(); + NumberExpression clubCount = webClub.id.countDistinct(); List rows = jpaQueryFactory .select( - university.id, - university.koreanName, - university.campus, - university.region, - university.imageUrl, + webUniversity.id, + webUniversity.koreanName, + webUniversity.campus, + webUniversity.region, + webUniversity.imageUrl, clubCount ) - .from(university) - .leftJoin(club).on(club.university.id.eq(university.id)) + .from(webUniversity) + .leftJoin(webClub).on(webClub.university.id.eq(webUniversity.id)) .where(condition) .groupBy( - university.id, - university.koreanName, - university.campus, - university.region, - university.imageUrl + webUniversity.id, + webUniversity.koreanName, + webUniversity.campus, + webUniversity.region, + webUniversity.imageUrl ) - .orderBy(university.koreanName.asc(), university.campus.asc()) + .orderBy(webUniversity.koreanName.asc(), webUniversity.campus.asc()) .fetch(); return rows.stream() .map(row -> new WebsiteUniversitySummary( - row.get(university.id), - row.get(university.koreanName), - row.get(university.campus).getDisplayName(), - row.get(university.region), - row.get(university.region).getDisplayName(), - row.get(university.imageUrl), + row.get(webUniversity.id), + row.get(webUniversity.koreanName), + row.get(webUniversity.campus).getDisplayName(), + row.get(webUniversity.region), + row.get(webUniversity.region).getDisplayName(), + row.get(webUniversity.imageUrl), row.get(clubCount) )) .toList(); } - public Optional findUniversity(Integer universityId) { + public Optional findUniversity(Integer universityId) { return Optional.ofNullable(jpaQueryFactory - .selectFrom(university) - .where(university.id.eq(universityId)) + .selectFrom(webUniversity) + .where(webUniversity.id.eq(universityId)) .fetchOne()); } - public Page findClubs( + public Page findClubs( Integer universityId, String query, ClubCategory category, @@ -90,19 +88,18 @@ public Page findClubs( ) { BooleanBuilder condition = createClubCondition(universityId, query, category); - List clubs = jpaQueryFactory - .selectFrom(club) - .join(club.university, university).fetchJoin() - .leftJoin(club.clubRecruitment, clubRecruitment).fetchJoin() + List clubs = jpaQueryFactory + .selectFrom(webClub) + .join(webClub.university, webUniversity).fetchJoin() .where(condition) - .orderBy(club.name.asc(), club.id.asc()) + .orderBy(webClub.name.asc(), webClub.id.asc()) .offset(pageable.getOffset()) .limit(pageable.getPageSize()) .fetch(); Long total = jpaQueryFactory - .select(club.count()) - .from(club) + .select(webClub.count()) + .from(webClub) .where(condition) .fetchOne(); @@ -111,80 +108,57 @@ public Page findClubs( public Map countClubCategories(Integer universityId, String query) { BooleanBuilder condition = createClubCondition(universityId, query, null); - NumberExpression clubCount = club.count(); + NumberExpression clubCount = webClub.count(); List rows = jpaQueryFactory - .select(club.clubCategory, clubCount) - .from(club) + .select(webClub.clubCategory, clubCount) + .from(webClub) .where(condition) - .groupBy(club.clubCategory) + .groupBy(webClub.clubCategory) .fetch(); Map categoryCounts = new LinkedHashMap<>(); - rows.forEach(row -> categoryCounts.put(row.get(club.clubCategory), row.get(clubCount))); + rows.forEach(row -> categoryCounts.put(row.get(webClub.clubCategory), row.get(clubCount))); return categoryCounts; } public Long countClubsByUniversityId(Integer universityId) { Long count = jpaQueryFactory - .select(club.count()) - .from(club) + .select(webClub.count()) + .from(webClub) .where(createClubCondition(universityId, null, null)) .fetchOne(); return count == null ? 0 : count; } - public Optional findClub(Integer clubId) { + public Optional findClub(Integer clubId) { return Optional.ofNullable(jpaQueryFactory - .selectFrom(club) - .join(club.university, university).fetchJoin() - .leftJoin(club.clubRecruitment, clubRecruitment).fetchJoin() - .where(club.id.eq(clubId)) + .selectFrom(webClub) + .join(webClub.university, webUniversity).fetchJoin() + .where(webClub.id.eq(clubId)) .fetchOne()); } - public List findClubs(List clubIds) { + public List findClubs(List clubIds) { if (clubIds.isEmpty()) { return List.of(); } return jpaQueryFactory - .selectFrom(club) - .join(club.university, university).fetchJoin() - .leftJoin(club.clubRecruitment, clubRecruitment).fetchJoin() - .where(club.id.in(clubIds)) + .selectFrom(webClub) + .join(webClub.university, webUniversity).fetchJoin() + .where(webClub.id.in(clubIds)) .fetch(); } - public Map countMembersByClubIds(List clubIds) { - if (clubIds.isEmpty()) { - return Map.of(); - } - NumberExpression memberCount = clubMember.count(); - - List rows = jpaQueryFactory - .select(clubMember.club.id, memberCount) - .from(clubMember) - .where( - clubMember.club.id.in(clubIds), - clubMember.user.deletedAt.isNull() - ) - .groupBy(clubMember.club.id) - .fetch(); - - Map memberCounts = new LinkedHashMap<>(); - rows.forEach(row -> memberCounts.put(row.get(clubMember.club.id), row.get(memberCount))); - return memberCounts; - } - private BooleanBuilder createClubCondition(Integer universityId, String query, ClubCategory category) { BooleanBuilder condition = new BooleanBuilder(); - condition.and(club.university.id.eq(universityId)); + condition.and(webClub.university.id.eq(universityId)); addClubSearchCondition(condition, query); if (category != null) { - condition.and(club.clubCategory.eq(category)); + condition.and(webClub.clubCategory.eq(category)); } return condition; @@ -196,7 +170,7 @@ private void addUniversitySearchCondition(BooleanBuilder condition, String query } String normalizedQuery = query.trim().toLowerCase(); - condition.and(university.koreanName.lower().contains(normalizedQuery)); + condition.and(webUniversity.koreanName.lower().contains(normalizedQuery)); } private void addUniversityRegionCondition(BooleanBuilder condition, UniversityRegion region) { @@ -204,7 +178,7 @@ private void addUniversityRegionCondition(BooleanBuilder condition, UniversityRe return; } - condition.and(university.region.eq(region)); + condition.and(webUniversity.region.eq(region)); } private void addClubSearchCondition(BooleanBuilder condition, String query) { @@ -213,7 +187,7 @@ private void addClubSearchCondition(BooleanBuilder condition, String query) { } String normalizedQuery = query.trim().toLowerCase(); - BooleanExpression nameContains = club.name.lower().contains(normalizedQuery); + BooleanExpression nameContains = webClub.name.lower().contains(normalizedQuery); condition.and(nameContains); } } 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 5902e611f..4c68e0dac 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 @@ -12,13 +12,13 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.university.enums.UniversityRegion; -import gg.agit.konect.domain.university.model.University; 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; @@ -37,52 +37,45 @@ public WebsiteHomeResponse getHome(String query, UniversityRegion region) { } public WebsiteClubsResponse getUniversityClubs(Integer universityId, WebsiteClubListCondition condition) { - University university = websiteQueryRepository.findUniversity(universityId) + WebUniversity university = websiteQueryRepository.findUniversity(universityId) .orElseThrow(() -> CustomException.of(UNIVERSITY_NOT_FOUND)); PageRequest pageable = PageRequest.of(condition.page() - 1, condition.limit()); - Page clubs = websiteQueryRepository.findClubs( + Page clubs = websiteQueryRepository.findClubs( universityId, condition.query(), condition.category(), pageable ); - List clubIds = clubs.getContent().stream() - .map(Club::getId) - .toList(); return WebsiteClubsResponse.of( university, clubs, - websiteQueryRepository.countMembersByClubIds(clubIds), + websiteQueryRepository.countClubsByUniversityId(universityId), websiteQueryRepository.countClubCategories(universityId, condition.query()) ); } public WebsiteClubDetailResponse getClubDetail(Integer clubId) { - Club club = websiteQueryRepository.findClub(clubId) + WebClub club = websiteQueryRepository.findClub(clubId) .orElseThrow(() -> CustomException.of(NOT_FOUND_CLUB)); - Long memberCount = websiteQueryRepository.countMembersByClubIds(List.of(clubId)).getOrDefault(clubId, 0L); Long universityClubCount = websiteQueryRepository.countClubsByUniversityId(club.getUniversity().getId()); - return WebsiteClubDetailResponse.of(club, memberCount, universityClubCount); + return WebsiteClubDetailResponse.of(club, universityClubCount); } public WebsiteClubsResponse getRecentClubs(List clubIds) { List distinctClubIds = clubIds.stream() .distinct() .toList(); - List clubs = websiteQueryRepository.findClubs(distinctClubIds); + List clubs = websiteQueryRepository.findClubs(distinctClubIds); Map order = createOrder(clubIds); - List sortedClubs = clubs.stream() + List sortedClubs = clubs.stream() .sorted(Comparator.comparingInt(club -> order.getOrDefault(club.getId(), Integer.MAX_VALUE))) .toList(); - return WebsiteClubsResponse.recent( - sortedClubs, - websiteQueryRepository.countMembersByClubIds(distinctClubIds) - ); + return WebsiteClubsResponse.recent(sortedClubs); } private Map createOrder(List clubIds) { 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..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 @@ -7,36 +7,78 @@ 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( + """ + :sparkles: *새 동아리 등록 요청이 도착했어요* + + :school: *대학교* : *`%s`* + %s *동아리* : *`%s`* + :label: *분과* : *`%s`* + :dart: *주제* : *`%s`* + :art: *요청 이모지* : *`%s`* + + :memo: *한 줄 소개* + ```%s``` + + :page_facing_up: *상세 소개* + ```%s``` + + :paperclip: *첨부 이미지* + ```%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 + """ ), ; 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..5f853b9c7 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/slack/listener/ClubRegistrationRequestSlackListener.java @@ -0,0 +1,73 @@ +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.ClubInformationUpdateRequestedEvent; +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.fullIntroduction(), + event.imageUrls() + ); + } catch (RuntimeException e) { + 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 86db2f7d1..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,11 +1,16 @@ 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; import static gg.agit.konect.infrastructure.slack.enums.SlackMessageTemplate.USER_REGISTER; import static gg.agit.konect.infrastructure.slack.enums.SlackMessageTemplate.USER_WITHDRAWAL; +import java.util.List; +import java.util.Objects; + import org.springframework.stereotype.Service; import gg.agit.konect.domain.club.event.SheetSyncFailedEvent; @@ -50,4 +55,103 @@ 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, + String fullIntroduction, + List imageUrls + ) { + String message = CLUB_REGISTRATION_REQUEST.format( + universityName, + emoji, + clubName, + category, + topic, + emoji, + description, + fullIntroduction, + formatImageUrls(imageUrls) + ); + 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 "없음"; + } + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < imageUrls.size(); i++) { + builder.append(imageUrls.get(i)); + if (i < imageUrls.size() - 1) { + builder.append(System.lineSeparator()); + } + } + return builder.toString(); + } } diff --git a/src/main/resources/db/migration/V75__add_topic_to_club.sql b/src/main/resources/db/migration/V75__add_topic_to_club.sql new file mode 100644 index 000000000..bd18e86ff --- /dev/null +++ b/src/main/resources/db/migration/V75__add_topic_to_club.sql @@ -0,0 +1,2 @@ +ALTER TABLE club + ADD COLUMN topic VARCHAR(20) NOT NULL DEFAULT '기타' AFTER name; diff --git a/src/main/resources/db/migration/V76__create_web_tables.sql b/src/main/resources/db/migration/V76__create_web_tables.sql new file mode 100644 index 000000000..57f5d6ff8 --- /dev/null +++ b/src/main/resources/db/migration/V76__create_web_tables.sql @@ -0,0 +1,63 @@ +-- 웹용 university 테이블 생성 (앱용 university 테이블 구조 복사) +CREATE TABLE IF NOT EXISTS web_university +( + id INT AUTO_INCREMENT PRIMARY KEY, + korean_name VARCHAR(255) NOT NULL, + campus VARCHAR(255) NOT NULL, + region VARCHAR(50) NOT NULL DEFAULT 'UNKNOWN', + image_url VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL, + + CONSTRAINT uq_web_university_korean_name_campus UNIQUE (korean_name, campus) +); + +-- 앱용 university 데이터를 웹용 테이블로 복사 +INSERT INTO web_university (id, korean_name, campus, region, image_url, created_at, updated_at) +SELECT id, korean_name, campus, region, image_url, created_at, updated_at +FROM university; + +-- 웹용 club 테이블 생성 (앱용 club 테이블 구조 복사) +CREATE TABLE IF NOT EXISTS web_club +( + id INT AUTO_INCREMENT PRIMARY KEY, + university_id INT NOT NULL, + club_category VARCHAR(255) NOT NULL, + name VARCHAR(50) NOT NULL, + topic VARCHAR(20) NOT NULL DEFAULT '기타', + description VARCHAR(30) NOT NULL, + introduce TEXT NOT NULL, + image_url VARCHAR(255) NOT NULL, + location VARCHAR(255) NOT NULL, + fee_amount VARCHAR(100) NULL, + fee_bank VARCHAR(100) NULL, + fee_account_number VARCHAR(100) NULL, + fee_account_holder VARCHAR(100) NULL, + is_fee_required TINYINT(1) DEFAULT 0, + is_recruitment_enabled TINYINT(1) NOT NULL DEFAULT 0, + is_application_enabled TINYINT(1) NOT NULL DEFAULT 0, + google_sheet_id VARCHAR(255) NULL, + sheet_column_mapping JSON NULL, + drive_folder_id VARCHAR(255) NULL, + template_spreadsheet_id VARCHAR(255) NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL, + + FOREIGN KEY (university_id) REFERENCES web_university (id) +); + +-- 앱용 club 데이터를 웹용 테이블로 복사 +INSERT INTO web_club ( + id, university_id, club_category, name, topic, description, introduce, image_url, location, + fee_amount, fee_bank, fee_account_number, fee_account_holder, + is_fee_required, is_recruitment_enabled, is_application_enabled, + google_sheet_id, sheet_column_mapping, drive_folder_id, template_spreadsheet_id, + created_at, updated_at +) +SELECT + id, university_id, club_category, name, topic, description, introduce, image_url, location, + fee_amount, fee_bank, fee_account_number, fee_account_holder, + is_fee_required, is_recruitment_enabled, is_application_enabled, + google_sheet_id, sheet_column_mapping, drive_folder_id, template_spreadsheet_id, + created_at, updated_at +FROM club; 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/main/resources/db/migration/V78__drop_unused_web_club_columns.sql b/src/main/resources/db/migration/V78__drop_unused_web_club_columns.sql new file mode 100644 index 000000000..730d8d99e --- /dev/null +++ b/src/main/resources/db/migration/V78__drop_unused_web_club_columns.sql @@ -0,0 +1,12 @@ +ALTER TABLE web_club + DROP COLUMN fee_amount, + DROP COLUMN fee_bank, + DROP COLUMN fee_account_number, + DROP COLUMN fee_account_holder, + DROP COLUMN is_fee_required, + DROP COLUMN is_recruitment_enabled, + DROP COLUMN is_application_enabled, + DROP COLUMN google_sheet_id, + DROP COLUMN sheet_column_mapping, + DROP COLUMN drive_folder_id, + DROP COLUMN template_spreadsheet_id; diff --git a/src/main/resources/db/migration/V79__add_updated_at_to_club_registration_request_image.sql b/src/main/resources/db/migration/V79__add_updated_at_to_club_registration_request_image.sql new file mode 100644 index 000000000..e14451f51 --- /dev/null +++ b/src/main/resources/db/migration/V79__add_updated_at_to_club_registration_request_image.sql @@ -0,0 +1,2 @@ +ALTER TABLE club_registration_request_image + ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL; diff --git a/src/main/resources/db/migration/V80__drop_topic_from_club.sql b/src/main/resources/db/migration/V80__drop_topic_from_club.sql new file mode 100644 index 000000000..964a37bad --- /dev/null +++ b/src/main/resources/db/migration/V80__drop_topic_from_club.sql @@ -0,0 +1,2 @@ +ALTER TABLE club + DROP COLUMN topic; 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/main/resources/db/migration/V82__replace_junior_club_category_with_etc.sql b/src/main/resources/db/migration/V82__replace_junior_club_category_with_etc.sql new file mode 100644 index 000000000..a361fe28f --- /dev/null +++ b/src/main/resources/db/migration/V82__replace_junior_club_category_with_etc.sql @@ -0,0 +1,56 @@ +-- Replace the retired JUNIOR category with ETC. +-- Backup recommendation before production deploy: +-- mysqldump club web_club club_registration_request club_information_update_request > backup_before_v82.sql +-- Expected impact: all rows that still use JUNIOR across club-related tables. + +SELECT 'before' AS phase, 'club' AS table_name, COUNT(*) AS junior_count +FROM club +WHERE club_category = 'JUNIOR'; + +SELECT 'before' AS phase, 'web_club' AS table_name, COUNT(*) AS junior_count +FROM web_club +WHERE club_category = 'JUNIOR'; + +SELECT 'before' AS phase, 'club_registration_request' AS table_name, COUNT(*) AS junior_count +FROM club_registration_request +WHERE club_category = 'JUNIOR'; + +SELECT 'before' AS phase, 'club_information_update_request' AS table_name, COUNT(*) AS junior_count +FROM club_information_update_request +WHERE club_category = 'JUNIOR'; + +UPDATE club +SET club_category = 'ETC', + updated_at = updated_at +WHERE club_category = 'JUNIOR'; + +UPDATE web_club +SET club_category = 'ETC', + updated_at = updated_at +WHERE club_category = 'JUNIOR'; + +UPDATE club_registration_request +SET club_category = 'ETC', + updated_at = updated_at +WHERE club_category = 'JUNIOR'; + +UPDATE club_information_update_request +SET club_category = 'ETC', + updated_at = updated_at +WHERE club_category = 'JUNIOR'; + +SELECT 'after' AS phase, 'club' AS table_name, COUNT(*) AS junior_count +FROM club +WHERE club_category = 'JUNIOR'; + +SELECT 'after' AS phase, 'web_club' AS table_name, COUNT(*) AS junior_count +FROM web_club +WHERE club_category = 'JUNIOR'; + +SELECT 'after' AS phase, 'club_registration_request' AS table_name, COUNT(*) AS junior_count +FROM club_registration_request +WHERE club_category = 'JUNIOR'; + +SELECT 'after' AS phase, 'club_information_update_request' AS table_name, COUNT(*) AS junior_count +FROM club_information_update_request +WHERE club_category = 'JUNIOR'; 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..6110b3d4b --- /dev/null +++ b/src/test/java/gg/agit/konect/integration/domain/club/ClubRegistrationRequestApiTest.java @@ -0,0 +1,187 @@ +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.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 { + + @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()); + } + + @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/integration/domain/website/WebsiteApiTest.java b/src/test/java/gg/agit/konect/integration/domain/website/WebsiteApiTest.java index e357d5dda..c4ebbfdaa 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 @@ -1,5 +1,6 @@ package gg.agit.konect.integration.domain.website; +import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.hasSize; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; @@ -7,7 +8,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import java.time.LocalDateTime; import java.util.stream.Collectors; import java.util.stream.IntStream; @@ -16,16 +16,13 @@ import org.junit.jupiter.api.Test; import gg.agit.konect.domain.club.enums.ClubCategory; -import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.university.enums.Campus; import gg.agit.konect.domain.university.enums.UniversityRegion; -import gg.agit.konect.domain.university.model.University; -import gg.agit.konect.domain.user.model.User; +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.ClubMemberFixture; -import gg.agit.konect.support.fixture.ClubRecruitmentFixture; -import gg.agit.konect.support.fixture.UniversityFixture; -import gg.agit.konect.support.fixture.UserFixture; +import gg.agit.konect.support.fixture.WebClubFixture; +import gg.agit.konect.support.fixture.WebUniversityFixture; class WebsiteApiTest extends IntegrationTestSupport { @@ -37,26 +34,35 @@ class GetHome { @DisplayName("로그인 없이 대학 목록과 등록 동아리 수를 조회한다") void getHomeWithoutLogin() throws Exception { // given - University koreatech = persist(UniversityFixture.create( + WebUniversity koreatech = persist(WebUniversityFixture.create( "한국기술교육대학교", Campus.MAIN, UniversityRegion.CHUNGCHEONG, "https://example.com/koreatech-logo.png" )); - University seoul = persist(UniversityFixture.create( + WebUniversity seoul = persist(WebUniversityFixture.create( "서울대학교", Campus.MAIN, UniversityRegion.SEOUL )); - persist(createClub(koreatech, "BCSD Lab", ClubCategory.ACADEMIC)); - persist(createClub(koreatech, "COK", ClubCategory.SPORTS)); - persist(createClub(seoul, "서울 동아리", ClubCategory.HOBBY)); + persist(WebClubFixture.create(koreatech, "BCSD Lab", ClubCategory.ACADEMIC)); + persist(WebClubFixture.create(koreatech, "COK", ClubCategory.SPORTS)); + persist(WebClubFixture.create(seoul, "서울 동아리", ClubCategory.HOBBY)); clearPersistenceContext(); // when & then performGet("/konect/home?query=한국®ion=CHUNGCHEONG") .andExpect(status().isOk()) .andExpect(jsonPath("$.totalUniversityCount").value(1)) + .andExpect(jsonPath("$.regions", hasSize(8))) + .andExpect(jsonPath("$.regions[0].region").value("GANGWON")) + .andExpect(jsonPath("$.regions[1].region").value("GYEONGGI")) + .andExpect(jsonPath("$.regions[2].region").value("GYEONGSANG")) + .andExpect(jsonPath("$.regions[3].region").value("SEOUL")) + .andExpect(jsonPath("$.regions[4].region").value("JEOLLA")) + .andExpect(jsonPath("$.regions[5].region").value("JEJU")) + .andExpect(jsonPath("$.regions[6].region").value("CHUNGCHEONG")) + .andExpect(jsonPath("$.regions[7].region").value("UNKNOWN")) .andExpect(jsonPath("$.universities[0].name").value("한국기술교육대학교")) .andExpect(jsonPath("$.universities[0].campusName").value("본교")) .andExpect(jsonPath("$.universities[0].region").value("CHUNGCHEONG")) @@ -74,22 +80,18 @@ void getHomeWithoutLogin() throws Exception { class GetUniversityClubs { @Test - @DisplayName("검색어와 분과로 동아리 목록을 조회하고 분과별 개수를 함께 반환한다") + @DisplayName("검색어와 분과로 동아리 목록을 조회하고 대학 전체 동아리 수를 함께 반환한다") void getUniversityClubsWithFilters() throws Exception { // given - University university = persist(UniversityFixture.create( + WebUniversity university = persist(WebUniversityFixture.create( "한국기술교육대학교", Campus.MAIN, UniversityRegion.CHUNGCHEONG, "https://example.com/koreatech-logo.png" )); - Club bcsd = persist(createClub(university, "BCSD Lab", ClubCategory.ACADEMIC)); - Club study = persist(createClub(university, "경영전략연구회", ClubCategory.ACADEMIC)); - persist(createClub(university, "ZEST", ClubCategory.PERFORMANCE)); - persistMember(bcsd, "회원1", "2024000001"); - persistMember(bcsd, "회원2", "2024000002"); - withdraw(persistMember(bcsd, "탈퇴회원", "2024000004")); - persistMember(study, "회원3", "2024000003"); + persist(WebClubFixture.create(university, "BCSD Lab", ClubCategory.ACADEMIC)); + persist(WebClubFixture.create(university, "경영전략연구회", ClubCategory.ACADEMIC)); + persist(WebClubFixture.create(university, "ZEST", ClubCategory.PERFORMANCE)); clearPersistenceContext(); // when & then @@ -98,12 +100,20 @@ void getUniversityClubsWithFilters() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.university.name").value("한국기술교육대학교")) .andExpect(jsonPath("$.university.imageUrl").value("https://example.com/koreatech-logo.png")) + .andExpect(jsonPath("$.university.clubCount").value(3)) .andExpect(jsonPath("$.totalCount").value(1)) .andExpect(jsonPath("$.clubs", hasSize(1))) .andExpect(jsonPath("$.clubs[0].name").value("BCSD Lab")) - .andExpect(jsonPath("$.clubs[0].memberCount").value(2)) - .andExpect(jsonPath("$.categories[0].category").value("ACADEMIC")) - .andExpect(jsonPath("$.categories[0].count").value(1)); + .andExpect(jsonPath("$.categories[0].category").value("PERFORMANCE")) + .andExpect(jsonPath("$.categories[1].category").value("SOCIAL_SERVICE")) + .andExpect(jsonPath("$.categories[2].category").value("EXHIBITION_CREATION")) + .andExpect(jsonPath("$.categories[3].category").value("RELIGION")) + .andExpect(jsonPath("$.categories[4].category").value("SPORTS")) + .andExpect(jsonPath("$.categories[5].category").value("HOBBY")) + .andExpect(jsonPath("$.categories[6].category").value("ACADEMIC")) + .andExpect(jsonPath("$.categories[?(@.category == 'ACADEMIC')].count") + .value(contains(1))) + .andExpect(jsonPath("$.categories[7].category").value("ETC")); } @Test @@ -120,19 +130,17 @@ void getUniversityClubsNotFound() throws Exception { class GetClubDetail { @Test - @DisplayName("동아리 상세 소개와 모집 정보를 조회한다") + @DisplayName("동아리 상세 소개를 조회한다") void getClubDetailSuccess() throws Exception { // given - University university = persist(UniversityFixture.create( + WebUniversity university = persist(WebUniversityFixture.create( "한국기술교육대학교", Campus.MAIN, UniversityRegion.CHUNGCHEONG, "https://example.com/koreatech-logo.png" )); - Club club = persist(createClub(university, "ZEST", ClubCategory.PERFORMANCE)); - persist(createClub(university, "BCSD Lab", ClubCategory.ACADEMIC)); - persist(ClubRecruitmentFixture.createAlwaysRecruiting(club)); - persistMember(club, "회장", "2024000004"); + WebClub club = persist(WebClubFixture.create(university, "ZEST", ClubCategory.PERFORMANCE)); + persist(WebClubFixture.create(university, "BCSD Lab", ClubCategory.ACADEMIC)); clearPersistenceContext(); // when & then @@ -140,13 +148,11 @@ void getClubDetailSuccess() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.name").value("ZEST")) .andExpect(jsonPath("$.categoryName").value("공연")) + .andExpect(jsonPath("$.topic").value("코딩")) .andExpect(jsonPath("$.university.name").value("한국기술교육대학교")) .andExpect(jsonPath("$.university.region").value("CHUNGCHEONG")) .andExpect(jsonPath("$.university.imageUrl").value("https://example.com/koreatech-logo.png")) - .andExpect(jsonPath("$.university.clubCount").value(2)) - .andExpect(jsonPath("$.memberCount").value(1)) - .andExpect(jsonPath("$.recruitment.isAlwaysRecruiting").value(true)) - .andExpect(jsonPath("$.recruitment.content").value("상시 모집 공고 내용입니다.")); + .andExpect(jsonPath("$.university.clubCount").value(2)); } } @@ -158,13 +164,13 @@ class GetRecentClubs { @DisplayName("요청한 동아리 ID 순서대로 카드 정보를 반환한다") void getRecentClubsKeepsRequestOrder() throws Exception { // given - University university = persist(UniversityFixture.create( + WebUniversity university = persist(WebUniversityFixture.create( "한국기술교육대학교", Campus.MAIN, UniversityRegion.CHUNGCHEONG )); - Club first = persist(createClub(university, "첫 번째", ClubCategory.ACADEMIC)); - Club second = persist(createClub(university, "두 번째", ClubCategory.SPORTS)); + WebClub first = persist(WebClubFixture.create(university, "첫 번째", ClubCategory.ACADEMIC)); + WebClub second = persist(WebClubFixture.create(university, "두 번째", ClubCategory.SPORTS)); clearPersistenceContext(); // when & then @@ -204,29 +210,4 @@ void getRecentClubsRejectsMissingClubIds() throws Exception { .andExpect(status().isBadRequest()); } } - - private Club createClub(University university, String name, ClubCategory category) { - return Club.builder() - .university(university) - .name(name) - .description("한 줄 소개") - .introduce("상세 소개입니다.") - .imageUrl("https://example.com/" + name + ".png") - .location("학생회관 101호") - .clubCategory(category) - .isRecruitmentEnabled(false) - .isApplicationEnabled(false) - .isFeeRequired(false) - .build(); - } - - private User persistMember(Club club, String name, String studentNumber) { - User user = persist(UserFixture.createUser(club.getUniversity(), name, studentNumber)); - persist(ClubMemberFixture.createMember(club, user)); - return user; - } - - private void withdraw(User user) { - user.withdraw(LocalDateTime.now()); - } } diff --git a/src/test/java/gg/agit/konect/support/fixture/WebClubFixture.java b/src/test/java/gg/agit/konect/support/fixture/WebClubFixture.java new file mode 100644 index 000000000..f098b95e3 --- /dev/null +++ b/src/test/java/gg/agit/konect/support/fixture/WebClubFixture.java @@ -0,0 +1,39 @@ +package gg.agit.konect.support.fixture; + +import org.springframework.test.util.ReflectionTestUtils; + +import gg.agit.konect.domain.club.enums.ClubCategory; +import gg.agit.konect.domain.website.model.WebClub; +import gg.agit.konect.domain.website.model.WebUniversity; + +public class WebClubFixture { + + public static WebClub create(WebUniversity university) { + return create(university, "BCSD Lab", ClubCategory.ACADEMIC); + } + + public static WebClub create(WebUniversity university, String name, ClubCategory category) { + return WebClub.builder() + .university(university) + .name(name) + .description("한 줄 소개") + .introduce("상세 소개입니다.") + .imageUrl("https://example.com/" + name + ".png") + .location("학생회관 101호") + .clubCategory(category) + .topic("코딩") + .build(); + } + + public static WebClub createWithId(Integer id, WebUniversity university) { + WebClub club = create(university); + ReflectionTestUtils.setField(club, "id", id); + return club; + } + + public static WebClub createWithId(Integer id, WebUniversity university, String name, ClubCategory category) { + WebClub club = create(university, name, category); + ReflectionTestUtils.setField(club, "id", id); + return club; + } +} diff --git a/src/test/java/gg/agit/konect/support/fixture/WebUniversityFixture.java b/src/test/java/gg/agit/konect/support/fixture/WebUniversityFixture.java new file mode 100644 index 000000000..a822666c9 --- /dev/null +++ b/src/test/java/gg/agit/konect/support/fixture/WebUniversityFixture.java @@ -0,0 +1,48 @@ +package gg.agit.konect.support.fixture; + +import org.springframework.test.util.ReflectionTestUtils; + +import gg.agit.konect.domain.university.enums.Campus; +import gg.agit.konect.domain.university.enums.UniversityRegion; +import gg.agit.konect.domain.website.model.WebUniversity; + +public class WebUniversityFixture { + + private static final String DEFAULT_IMAGE_URL = + "https://stage-static.koreatech.in/konect/university/university_logo_sample.webp"; + + public static WebUniversity create() { + return create("한국기술교육대학교", Campus.MAIN); + } + + public static WebUniversity create(String koreanName, Campus campus) { + return create(koreanName, campus, UniversityRegion.CHUNGCHEONG); + } + + public static WebUniversity create(String koreanName, Campus campus, UniversityRegion region) { + return create(koreanName, campus, region, DEFAULT_IMAGE_URL); + } + + public static WebUniversity create(String koreanName, Campus campus, UniversityRegion region, String imageUrl) { + return WebUniversity.builder() + .koreanName(koreanName) + .campus(campus) + .region(region) + .imageUrl(imageUrl) + .build(); + } + + public static WebUniversity createWithName(String koreanName) { + return create(koreanName, Campus.MAIN); + } + + public static WebUniversity createWithId(Integer id) { + return createWithId(id, "한국기술교육대학교", Campus.MAIN); + } + + public static WebUniversity createWithId(Integer id, String koreanName, Campus campus) { + WebUniversity university = create(koreanName, campus); + ReflectionTestUtils.setField(university, "id", id); + return university; + } +} 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..1c33a978a --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/domain/club/service/ClubRegistrationRequestServiceTest.java @@ -0,0 +1,163 @@ +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 java.util.Optional; + +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.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; + + @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.topic()).isEqualTo(request.clubTopic()); + assertThat(event.emoji()).isEqualTo(request.clubEmoji()); + assertThat(event.description()).isEqualTo(request.shortDescription()); + assertThat(event.fullIntroduction()).isEqualTo(request.fullIntroduction()); + 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 new file mode 100644 index 000000000..1ed28914f --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/infrastructure/slack/listener/ClubRegistrationRequestSlackListenerTest.java @@ -0,0 +1,173 @@ +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 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.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; +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.fullIntroduction(), + event.imageUrls() + ); + } + + @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.fullIntroduction(), + event.imageUrls() + ); + + // when & then + assertThatCode(() -> clubRegistrationRequestSlackListener.handleClubRegistrationRequested(event)) + .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, + "한국기술교육대학교", + "BCSD Lab", + "학술", + "코딩", + "💻", + "코딩 동아리입니다.", + "상세한 동아리 소개 내용입니다.", + 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 new file mode 100644 index 000000000..26727677c --- /dev/null +++ b/src/test/java/gg/agit/konect/unit/infrastructure/slack/service/SlackNotificationServiceTest.java @@ -0,0 +1,138 @@ +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.verify; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; + +import gg.agit.konect.infrastructure.slack.client.SlackClient; +import gg.agit.konect.infrastructure.slack.config.SlackProperties; +import gg.agit.konect.infrastructure.slack.service.SlackNotificationService; +import gg.agit.konect.support.ServiceTestSupport; + +class SlackNotificationServiceTest extends ServiceTestSupport { + + private static final String ERROR_WEBHOOK_URL = "https://hooks.slack.com/error"; + private static final String EVENT_WEBHOOK_URL = "https://hooks.slack.com/event"; + + @Mock + private SlackClient slackClient; + + private SlackNotificationService slackNotificationService; + + @BeforeEach + void setUp() { + SlackProperties slackProperties = new SlackProperties( + new SlackProperties.Webhooks(ERROR_WEBHOOK_URL, EVENT_WEBHOOK_URL), + "signing-secret", + "bot-token" + ); + slackNotificationService = new SlackNotificationService(slackProperties, slackClient); + } + + @Test + @DisplayName("동아리 등록 요청 Slack 메시지를 마크다운과 이모지로 구성한다") + void notifyClubRegistrationRequestFormatsSlackMessageWithMarkdownAndEmoji() { + // when + slackNotificationService.notifyClubRegistrationRequest( + 1, + "한국기술교육대학교", + "BCSD Lab", + "학술", + "코딩", + "💻", + "코딩 동아리입니다.", + "상세한 동아리 소개 내용입니다.", + List.of("https://example.com/image1.jpg", "https://example.com/image2.jpg") + ); + + // then + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(String.class); + verify(slackClient).sendMessage(messageCaptor.capture(), eq(EVENT_WEBHOOK_URL)); + assertThat(messageCaptor.getValue()).isEqualTo( + """ + :sparkles: *새 동아리 등록 요청이 도착했어요* + + :school: *대학교* : *`한국기술교육대학교`* + 💻 *동아리* : *`BCSD Lab`* + :label: *분과* : *`학술`* + :dart: *주제* : *`코딩`* + :art: *요청 이모지* : *`💻`* + + :memo: *한 줄 소개* + ```코딩 동아리입니다.``` + + :page_facing_up: *상세 소개* + ```상세한 동아리 소개 내용입니다.``` + + :paperclip: *첨부 이미지* + ```https://example.com/image1.jpg + https://example.com/image2.jpg``` + """ + ); + } + + @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``` + """ + ); + } +}