diff --git a/src/main/java/org/ject/support/common/security/jwt/JwtAuthenticationFilter.java b/src/main/java/org/ject/support/common/security/jwt/JwtAuthenticationFilter.java index c4705d86..134607ef 100644 --- a/src/main/java/org/ject/support/common/security/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/org/ject/support/common/security/jwt/JwtAuthenticationFilter.java @@ -9,8 +9,6 @@ import org.ject.support.common.exception.GlobalErrorCode; import org.ject.support.common.exception.GlobalException; import org.ject.support.common.security.CustomUserDetails; -import org.ject.support.domain.auth.exception.AuthErrorCode; -import org.ject.support.domain.auth.exception.AuthException; import org.ject.support.domain.member.Role; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; @@ -51,6 +49,9 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse if (jwtTokenProvider.validateToken(accessToken)) { Authentication auth = jwtTokenProvider.getAuthenticationByToken(accessToken); SecurityContextHolder.getContext().setAuthentication(auth); + + chain.doFilter(request, response); + return; } else { clearAuthCookie(response, "accessToken"); SecurityContextHolder.clearContext(); @@ -64,15 +65,15 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse jwtTokenProvider.resolveVerificationToken(request); if (verificationToken != null) { - if (!jwtTokenProvider.validateToken(verificationToken)) { - // verification token은 실패 시 에러가 맞음 - throw new AuthException(AuthErrorCode.INVALID_TOKEN); - } + if (jwtTokenProvider.validateToken(verificationToken)) { + String email = jwtTokenProvider.extractEmailFromVerificationToken(verificationToken); - String email = jwtTokenProvider.extractEmailFromVerificationToken(verificationToken); - - Authentication auth = createVerificationAuthentication(email); - SecurityContextHolder.getContext().setAuthentication(auth); + Authentication auth = createVerificationAuthentication(email); + SecurityContextHolder.getContext().setAuthentication(auth); + } else { + clearAuthCookie(response, "verificationToken"); + SecurityContextHolder.clearContext(); + } } chain.doFilter(request, response); diff --git a/src/main/java/org/ject/support/common/security/jwt/JwtTokenProvider.java b/src/main/java/org/ject/support/common/security/jwt/JwtTokenProvider.java index 06e54037..478bf68c 100644 --- a/src/main/java/org/ject/support/common/security/jwt/JwtTokenProvider.java +++ b/src/main/java/org/ject/support/common/security/jwt/JwtTokenProvider.java @@ -10,6 +10,7 @@ import jakarta.annotation.PostConstruct; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.Getter; import lombok.RequiredArgsConstructor; import org.ject.support.common.exception.GlobalException; @@ -205,7 +206,19 @@ public String createVerificationToken(String email) { .signWith(secretKey) .compact(); } - + + /** + * 인증번호 검증 쿠키 삭제 + */ + public void deleteVerificationCookie(HttpServletResponse response) { + Cookie cookie = new Cookie("verificationToken", null); + cookie.setPath("/"); + cookie.setHttpOnly(true); + cookie.setSecure(true); + cookie.setMaxAge(0); + response.addCookie(cookie); + } + /** * 인증번호 검증 토큰에서 이메일 추출 */ diff --git a/src/main/java/org/ject/support/domain/admin/controller/AdminTempApplyApiSpec.java b/src/main/java/org/ject/support/domain/admin/controller/AdminTempApplyApiSpec.java index 101f9a2a..28ed0c15 100644 --- a/src/main/java/org/ject/support/domain/admin/controller/AdminTempApplyApiSpec.java +++ b/src/main/java/org/ject/support/domain/admin/controller/AdminTempApplyApiSpec.java @@ -37,6 +37,7 @@ public interface AdminTempApplyApiSpec { ) Page getTempApplies( @RequestParam(required = false) JobFamily jobFamily, + @RequestParam(required = false) final Long semesterId, @PageableDefault(size = 15) final Pageable pageable ); } diff --git a/src/main/java/org/ject/support/domain/admin/controller/AdminTempApplyController.java b/src/main/java/org/ject/support/domain/admin/controller/AdminTempApplyController.java index f4f43251..f757488d 100644 --- a/src/main/java/org/ject/support/domain/admin/controller/AdminTempApplyController.java +++ b/src/main/java/org/ject/support/domain/admin/controller/AdminTempApplyController.java @@ -40,7 +40,8 @@ public void deleteTempApply(@PathVariable final Long tempApplyId) { @GetMapping() public Page getTempApplies(@RequestParam(required = false) JobFamily jobFamily, + @RequestParam(required = false) final Long semesterId, @PageableDefault(size = 15) Pageable pageable) { - return adminTempApplyService.getTempApplies(jobFamily, pageable); + return adminTempApplyService.getTempApplies(jobFamily, semesterId, pageable); } } diff --git a/src/main/java/org/ject/support/domain/admin/controller/SubmittedApplyApiSpec.java b/src/main/java/org/ject/support/domain/admin/controller/SubmittedApplyApiSpec.java index b4371fba..b5660a4a 100644 --- a/src/main/java/org/ject/support/domain/admin/controller/SubmittedApplyApiSpec.java +++ b/src/main/java/org/ject/support/domain/admin/controller/SubmittedApplyApiSpec.java @@ -28,6 +28,7 @@ public interface SubmittedApplyApiSpec { summary = "제출된 지원서 목록 조회", description = "제출된 지원서들의 목록을 조회합니다.") Page findSubmittedApplies(@RequestParam(required = false) final JobFamily jobFamily, + @RequestParam(required = false) final Long semesterId, @PageableDefault(size = 15) final Pageable pageable); @Operation( diff --git a/src/main/java/org/ject/support/domain/admin/controller/SubmittedApplyController.java b/src/main/java/org/ject/support/domain/admin/controller/SubmittedApplyController.java index 6065c031..2316db9d 100644 --- a/src/main/java/org/ject/support/domain/admin/controller/SubmittedApplyController.java +++ b/src/main/java/org/ject/support/domain/admin/controller/SubmittedApplyController.java @@ -37,8 +37,9 @@ public SubmittedApplyCountResponse getSubmittedApplyCount() { @Override @GetMapping public Page findSubmittedApplies(@RequestParam(required = false) final JobFamily jobFamily, + @RequestParam(required = false) final Long semesterId, @PageableDefault(size = 15) final Pageable pageable) { - return submittedApplyService.findSubmittedApplies(jobFamily, pageable); + return submittedApplyService.findSubmittedApplies(jobFamily, semesterId, pageable); } @Override diff --git a/src/main/java/org/ject/support/domain/admin/service/AdminAuthService.java b/src/main/java/org/ject/support/domain/admin/service/AdminAuthService.java index 0b8b3c4e..93d942cc 100644 --- a/src/main/java/org/ject/support/domain/admin/service/AdminAuthService.java +++ b/src/main/java/org/ject/support/domain/admin/service/AdminAuthService.java @@ -9,8 +9,9 @@ import org.ject.support.domain.admin.exception.AdminException; import org.ject.support.domain.member.MemberStatus; import org.ject.support.domain.member.entity.Member; -import org.ject.support.external.discord.DiscordComponent; import org.ject.support.external.infrastructure.DiscordRateLimiter; +import org.ject.support.external.notification.event.AdminLoginNotificationEvent; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; @@ -24,10 +25,10 @@ @Transactional(readOnly = true) public class AdminAuthService { + private final ApplicationEventPublisher applicationEventPublisher; private final RedisTemplate redisTemplate; private final AdminMemberComponent adminMemberComponent; private final DiscordRateLimiter discordRateLimiter; - private final DiscordComponent discordComponent; private final JwtTokenProvider jwtTokenProvider; private static final String ADMIN_LOGIN_AUTH_CODE_KEY_PREFIX = "admin-login:"; @@ -46,9 +47,9 @@ public String sendAdminAuthCode(String email) { if (discordRateLimiter.tryConsume(1)) { redisTemplate.opsForValue().set(key, authCode, Duration.ofSeconds(ADMIN_LOGIN_AUTH_CODE_EXPIRATION)); - discordComponent.sendAdminLoginMessage(member.getEmail(), authCode) - .doOnError(e -> log.error("Discord 전송 실패: {}", member.getEmail(), e)) - .subscribe(); + + applicationEventPublisher + .publishEvent(new AdminLoginNotificationEvent(email, authCode)); } else { throw new AdminException(AdminErrorCode.TOO_MANY_REQUESTS); } diff --git a/src/main/java/org/ject/support/domain/admin/service/AdminTempApplyService.java b/src/main/java/org/ject/support/domain/admin/service/AdminTempApplyService.java index 1285908f..21840e55 100644 --- a/src/main/java/org/ject/support/domain/admin/service/AdminTempApplyService.java +++ b/src/main/java/org/ject/support/domain/admin/service/AdminTempApplyService.java @@ -56,9 +56,9 @@ public void deleteTempApply(Long applyId) { applyRepository.delete(apply); } - public Page getTempApplies(JobFamily jobFamily, Pageable pageable) { + public Page getTempApplies(JobFamily jobFamily, Long semesterId, Pageable pageable) { Apply.Status tempSavedStatus = Apply.Status.TEMP_SAVED; - Page applyPage = applyRepository.findAppliesByStatus(jobFamily, tempSavedStatus, pageable); + Page applyPage = applyRepository.findAppliesByStatus(jobFamily, tempSavedStatus, semesterId, pageable); List content = applyPage.getContent().stream() .map(this::toTempSavedApplyResponse) diff --git a/src/main/java/org/ject/support/domain/admin/service/SubmittedApplyService.java b/src/main/java/org/ject/support/domain/admin/service/SubmittedApplyService.java index beb772ce..d3af7d93 100644 --- a/src/main/java/org/ject/support/domain/admin/service/SubmittedApplyService.java +++ b/src/main/java/org/ject/support/domain/admin/service/SubmittedApplyService.java @@ -41,8 +41,9 @@ public class SubmittedApplyService { @Transactional(readOnly = true) public Page findSubmittedApplies(final JobFamily jobFamily, + final Long semesterId, final Pageable pageable) { - Page applyPage = applyRepository.findAppliesByStatus(jobFamily, Status.SUBMITTED, pageable); + Page applyPage = applyRepository.findAppliesByStatus(jobFamily, Status.SUBMITTED, semesterId, pageable); List content = applyPage.getContent().stream() .map(this::toSubmittedApplyResponse) diff --git a/src/main/java/org/ject/support/domain/apply/repository/ApplyQueryRepository.java b/src/main/java/org/ject/support/domain/apply/repository/ApplyQueryRepository.java index b88afde3..84804910 100644 --- a/src/main/java/org/ject/support/domain/apply/repository/ApplyQueryRepository.java +++ b/src/main/java/org/ject/support/domain/apply/repository/ApplyQueryRepository.java @@ -8,5 +8,6 @@ public interface ApplyQueryRepository { Page findAppliesByStatus(final JobFamily jobFamily, final Apply.Status status, + final Long semesterId, final Pageable pageable); } diff --git a/src/main/java/org/ject/support/domain/apply/repository/ApplyQueryRepositoryImpl.java b/src/main/java/org/ject/support/domain/apply/repository/ApplyQueryRepositoryImpl.java index 206f0026..5d0b9132 100644 --- a/src/main/java/org/ject/support/domain/apply/repository/ApplyQueryRepositoryImpl.java +++ b/src/main/java/org/ject/support/domain/apply/repository/ApplyQueryRepositoryImpl.java @@ -26,7 +26,9 @@ public class ApplyQueryRepositoryImpl implements ApplyQueryRepository { @Override public Page findAppliesByStatus(final JobFamily jobFamily, - final Apply.Status status, final Pageable pageable) { + final Apply.Status status, + final Long semesterId, + final Pageable pageable) { List content = queryFactory .selectFrom(apply) @@ -37,7 +39,8 @@ public Page findAppliesByStatus(final JobFamily jobFamily, .where( apply.member.isDeleted.eq(false), eqJobFamily(jobFamily), - eqApplyStatus(status) + eqApplyStatus(status), + eqSemesterId(semesterId) ) .orderBy(apply.createdAt.desc()) .offset(pageable.getOffset()) @@ -51,7 +54,8 @@ public Page findAppliesByStatus(final JobFamily jobFamily, .where( apply.member.isDeleted.eq(false), eqJobFamily(jobFamily), - eqApplyStatus(status) + eqApplyStatus(status), + eqSemesterId(semesterId) ).fetchOne(); return PageResponse.from(content, pageable, total); @@ -67,4 +71,10 @@ private BooleanExpression eqApplyStatus(final Apply.Status status) { .map(apply.status::eq) .orElse(null); } + + private BooleanExpression eqSemesterId(final Long semesterId) { + return Optional.ofNullable(semesterId) + .map(apply.recruit.semester.id::eq) + .orElse(null); + } } diff --git a/src/main/java/org/ject/support/domain/apply/service/ApplyService.java b/src/main/java/org/ject/support/domain/apply/service/ApplyService.java index 7c00fc10..9008aaac 100644 --- a/src/main/java/org/ject/support/domain/apply/service/ApplyService.java +++ b/src/main/java/org/ject/support/domain/apply/service/ApplyService.java @@ -27,6 +27,8 @@ import org.ject.support.domain.recruit.exception.RecruitErrorCode; import org.ject.support.domain.recruit.exception.RecruitException; import org.ject.support.domain.recruit.repository.RecruitRepository; +import org.ject.support.external.n8n.event.ApplicationSubmittedEvent; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -34,7 +36,6 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Map; -import java.util.Optional; import static org.ject.support.domain.apply.domain.Apply.Status.JOINED; import static org.ject.support.domain.apply.domain.Apply.Status.SUBMITTED; @@ -53,6 +54,8 @@ public class ApplyService implements ApplyUsecase { private final Map2JsonSerializer map2JsonSerializer; private final String2MapSerializer string2MapSerializer; + private final ApplicationEventPublisher applicationEventPublisher; + @Override @PeriodAccessible(permitAllJob = true) @Transactional(readOnly = true) @@ -167,6 +170,10 @@ public void submitApplication(Long memberId, // 5. Apply 엔티티에 제출 위임 (검증 및 상태 변경 포함) apply.submit(applicationForm); + + // 6. n8n에 지원 완료 이벤트 발행 + applicationEventPublisher + .publishEvent(new ApplicationSubmittedEvent(apply.getId())); } @Override diff --git a/src/main/java/org/ject/support/domain/auth/service/AuthSupporterTokenService.java b/src/main/java/org/ject/support/domain/auth/service/AuthSupporterTokenService.java index 9308327b..8ce6b5f7 100644 --- a/src/main/java/org/ject/support/domain/auth/service/AuthSupporterTokenService.java +++ b/src/main/java/org/ject/support/domain/auth/service/AuthSupporterTokenService.java @@ -11,7 +11,8 @@ import org.ject.support.domain.member.Role; import org.ject.support.domain.member.entity.Member; import org.ject.support.domain.member.repository.MemberRepository; -import org.ject.support.external.discord.DiscordComponent; +import org.ject.support.external.notification.event.SupporterTokenIssuedEvent; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -28,10 +29,10 @@ public class AuthSupporterTokenService { private final long TOKEN_EXPIRATION_MILLIS = 24 * 60 * 60 * 1000; // 1일 + private final ApplicationEventPublisher applicationEventPublisher; private final JwtTokenProvider jwtTokenProvider; private final MemberRepository memberRepository; private final PasswordEncoder passwordEncoder; - private final DiscordComponent discordComponent; public void issueReadOnlyToken(String email, String pin) { Member member = memberRepository.findByEmailAndRole(email, Role.ADMIN) @@ -50,10 +51,7 @@ public void issueReadOnlyToken(String email, String pin) { String token = jwtTokenProvider.createToken(claims, TOKEN_EXPIRATION_MILLIS); - discordComponent.sendSupporterTokenIssueMessage(email, token) - .doOnError(e -> { - log.error("지원자 토큰 전송 실패: {}", email, e); - }) - .subscribe(); + applicationEventPublisher + .publishEvent(new SupporterTokenIssuedEvent(email, token)); } } diff --git a/src/main/java/org/ject/support/domain/jectalk/controller/JectalkApiSpec.java b/src/main/java/org/ject/support/domain/jectalk/controller/JectalkApiSpec.java index 2d4bd726..e89b16de 100644 --- a/src/main/java/org/ject/support/domain/jectalk/controller/JectalkApiSpec.java +++ b/src/main/java/org/ject/support/domain/jectalk/controller/JectalkApiSpec.java @@ -1,11 +1,14 @@ package org.ject.support.domain.jectalk.controller; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import org.ject.support.domain.jectalk.dto.JectalkResponse; +import org.ject.support.domain.project.entity.Project; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.RequestParam; @Tag(name = "Jectalk", description = "젝톡 API") public interface JectalkApiSpec { @@ -14,5 +17,8 @@ public interface JectalkApiSpec { summary = "젝톡 목록 조회", description = "젝톡 목록을 조회합니다." ) - Page findJectalks(@PageableDefault(size = 12) Pageable pageable); + Page findJectalks( + @PageableDefault(size = 12) Pageable pageable, + @Parameter(description = "기수 (SEMESTER_1, SEMESTER_2, SEMESTER_3)", example = "SEMESTER_1") + @RequestParam(required = false) Project.Category category); } diff --git a/src/main/java/org/ject/support/domain/jectalk/controller/JectalkController.java b/src/main/java/org/ject/support/domain/jectalk/controller/JectalkController.java index d607fe5f..fc6171ae 100644 --- a/src/main/java/org/ject/support/domain/jectalk/controller/JectalkController.java +++ b/src/main/java/org/ject/support/domain/jectalk/controller/JectalkController.java @@ -3,11 +3,13 @@ import lombok.RequiredArgsConstructor; import org.ject.support.domain.jectalk.dto.JectalkResponse; import org.ject.support.domain.jectalk.service.JectalkService; +import org.ject.support.domain.project.entity.Project; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -19,7 +21,9 @@ public class JectalkController implements JectalkApiSpec { @Override @GetMapping - public Page findJectalks(@PageableDefault(size = 12) Pageable pageable) { - return jectalkService.findJectalks(pageable); + public Page findJectalks( + @PageableDefault(size = 12) Pageable pageable, + @RequestParam(required = false) Project.Category category) { + return jectalkService.findJectalks(pageable, category); } } diff --git a/src/main/java/org/ject/support/domain/jectalk/dto/JectalkResponse.java b/src/main/java/org/ject/support/domain/jectalk/dto/JectalkResponse.java index 111651b2..b5f39f1f 100644 --- a/src/main/java/org/ject/support/domain/jectalk/dto/JectalkResponse.java +++ b/src/main/java/org/ject/support/domain/jectalk/dto/JectalkResponse.java @@ -2,9 +2,17 @@ import com.querydsl.core.annotations.QueryProjection; import lombok.Builder; +import org.ject.support.domain.jectalk.enums.ContentType; @Builder -public record JectalkResponse(Long id, String name, String youtubeUrl, String imageUrl, String summary) { +public record JectalkResponse( + Long id, + String title, + String description, + String contentUrl, + ContentType contentType, + String thumbnailUrl, + String summary) { @QueryProjection public JectalkResponse { diff --git a/src/main/java/org/ject/support/domain/jectalk/entity/Jectalk.java b/src/main/java/org/ject/support/domain/jectalk/entity/Jectalk.java index 9067ede1..dd60f2e6 100644 --- a/src/main/java/org/ject/support/domain/jectalk/entity/Jectalk.java +++ b/src/main/java/org/ject/support/domain/jectalk/entity/Jectalk.java @@ -4,10 +4,14 @@ import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; +import lombok.Getter; import lombok.NoArgsConstructor; import org.ject.support.domain.base.BaseTimeEntity; +import org.ject.support.domain.jectalk.enums.ContentType; +import org.ject.support.domain.project.entity.Project.Category; @Entity +@Getter @Builder @AllArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -18,14 +22,25 @@ public class Jectalk extends BaseTimeEntity { private Long id; @Column(length = 50, nullable = false) - private String name; + private String title; @Column(nullable = false) - private String summary; + private String description; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private ContentType contentType; @Column(length = 2083) - private String youtubeUrl; + private String contentUrl; @Column(length = 2083) - private String imageUrl; + private String thumbnailUrl; + + @Column(nullable = false) + private String author; + + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "varchar(30)", nullable = false) + private Category category; } diff --git a/src/main/java/org/ject/support/domain/jectalk/enums/ContentType.java b/src/main/java/org/ject/support/domain/jectalk/enums/ContentType.java new file mode 100644 index 00000000..f49333f1 --- /dev/null +++ b/src/main/java/org/ject/support/domain/jectalk/enums/ContentType.java @@ -0,0 +1,13 @@ +package org.ject.support.domain.jectalk.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ContentType { + YOUTUBE("YouTube"), + ; + + private final String name; +} diff --git a/src/main/java/org/ject/support/domain/jectalk/repository/JectalkQueryRepository.java b/src/main/java/org/ject/support/domain/jectalk/repository/JectalkQueryRepository.java index ccf75a8e..91af7254 100644 --- a/src/main/java/org/ject/support/domain/jectalk/repository/JectalkQueryRepository.java +++ b/src/main/java/org/ject/support/domain/jectalk/repository/JectalkQueryRepository.java @@ -1,9 +1,10 @@ package org.ject.support.domain.jectalk.repository; import org.ject.support.domain.jectalk.dto.JectalkResponse; +import org.ject.support.domain.project.entity.Project; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; public interface JectalkQueryRepository { - Page findJectalks(Pageable pageable); + Page findJectalks(Pageable pageable, Project.Category category); } diff --git a/src/main/java/org/ject/support/domain/jectalk/repository/JectalkQueryRepositoryImpl.java b/src/main/java/org/ject/support/domain/jectalk/repository/JectalkQueryRepositoryImpl.java index 962a678e..cde1106b 100644 --- a/src/main/java/org/ject/support/domain/jectalk/repository/JectalkQueryRepositoryImpl.java +++ b/src/main/java/org/ject/support/domain/jectalk/repository/JectalkQueryRepositoryImpl.java @@ -1,18 +1,21 @@ package org.ject.support.domain.jectalk.repository; +import static org.ject.support.domain.jectalk.entity.QJectalk.jectalk; + +import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.List; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.ject.support.common.data.PageResponse; import org.ject.support.domain.jectalk.dto.JectalkResponse; import org.ject.support.domain.jectalk.dto.QJectalkResponse; +import org.ject.support.domain.project.entity.Project; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; -import static org.ject.support.domain.jectalk.entity.QJectalk.jectalk; - @Repository @RequiredArgsConstructor public class JectalkQueryRepositoryImpl implements JectalkQueryRepository { @@ -20,16 +23,19 @@ public class JectalkQueryRepositoryImpl implements JectalkQueryRepository { private final JPAQueryFactory queryFactory; @Override - public Page findJectalks(Pageable pageable) { + public Page findJectalks(Pageable pageable, Project.Category category) { List content = queryFactory .select(new QJectalkResponse( jectalk.id, - jectalk.name, - jectalk.youtubeUrl, - jectalk.imageUrl, - jectalk.summary + jectalk.title, + jectalk.description, + jectalk.contentUrl, + jectalk.contentType, + jectalk.thumbnailUrl, + jectalk.author )) .from(jectalk) + .where(categoryEq(category)) .orderBy(jectalk.id.desc()) .offset(pageable.getOffset()) .limit(pageable.getPageSize()) @@ -37,8 +43,15 @@ public Page findJectalks(Pageable pageable) { JPAQuery countQuery = queryFactory .select(jectalk.count()) - .from(jectalk); + .from(jectalk) + .where(categoryEq(category)); return PageResponse.from(content, pageable, countQuery.fetchFirst()); } + + private BooleanExpression categoryEq(Project.Category category) { + return Optional.ofNullable(category) + .map(jectalk.category::eq) + .orElse(null); + } } diff --git a/src/main/java/org/ject/support/domain/jectalk/service/JectalkService.java b/src/main/java/org/ject/support/domain/jectalk/service/JectalkService.java index 2e0d833c..70db546c 100644 --- a/src/main/java/org/ject/support/domain/jectalk/service/JectalkService.java +++ b/src/main/java/org/ject/support/domain/jectalk/service/JectalkService.java @@ -3,6 +3,7 @@ import lombok.RequiredArgsConstructor; import org.ject.support.domain.jectalk.dto.JectalkResponse; import org.ject.support.domain.jectalk.repository.JectalkRepository; +import org.ject.support.domain.project.entity.Project; import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -15,9 +16,9 @@ public class JectalkService { private final JectalkRepository jectalkRepository; - @Cacheable(value = "jectalk", key = "#pageable.pageNumber + ':' + #pageable.pageSize") + @Cacheable(value = "jectalk", key = "#pageable.pageNumber + ':' + #pageable.pageSize + ':' + (#category != null ? #category.name() : 'ALL')") @Transactional(readOnly = true) - public Page findJectalks(Pageable pageable) { - return jectalkRepository.findJectalks(pageable); + public Page findJectalks(Pageable pageable, Project.Category category) { + return jectalkRepository.findJectalks(pageable, category); } } diff --git a/src/main/java/org/ject/support/domain/member/controller/MemberController.java b/src/main/java/org/ject/support/domain/member/controller/MemberController.java index 5f8c21d5..89bbec49 100644 --- a/src/main/java/org/ject/support/domain/member/controller/MemberController.java +++ b/src/main/java/org/ject/support/domain/member/controller/MemberController.java @@ -50,6 +50,9 @@ public boolean registerTempMember(HttpServletRequest request, HttpServletRespons Authentication authentication = memberService.registerTempMember(registerRequest, email); customSuccessHandler.onAuthenticationSuccess(request, response, authentication); + // verification 토큰은 더 이상 필요 없으므로 삭제 + jwtTokenProvider.deleteVerificationCookie(response); + return true; } diff --git a/src/main/java/org/ject/support/domain/ministudy/dto/MiniStudyResponse.java b/src/main/java/org/ject/support/domain/ministudy/dto/MiniStudyResponse.java index 47013de0..e595947e 100644 --- a/src/main/java/org/ject/support/domain/ministudy/dto/MiniStudyResponse.java +++ b/src/main/java/org/ject/support/domain/ministudy/dto/MiniStudyResponse.java @@ -4,7 +4,7 @@ import lombok.Builder; @Builder -public record MiniStudyResponse(Long id, String name, String linkUrl, String imageUrl, String summary) { +public record MiniStudyResponse(Long id, String name, String linkUrl, String imageUrl, String summary, String tag) { @QueryProjection public MiniStudyResponse { diff --git a/src/main/java/org/ject/support/domain/ministudy/entity/MiniStudy.java b/src/main/java/org/ject/support/domain/ministudy/entity/MiniStudy.java index 70cc6594..15926598 100644 --- a/src/main/java/org/ject/support/domain/ministudy/entity/MiniStudy.java +++ b/src/main/java/org/ject/support/domain/ministudy/entity/MiniStudy.java @@ -23,6 +23,9 @@ public class MiniStudy extends BaseTimeEntity { @Column(nullable = false) private String summary; + @Column(nullable = false) + private String tag; + @Column(length = 2083) private String linkUrl; diff --git a/src/main/java/org/ject/support/domain/ministudy/repository/MiniStudyQueryRepositoryImpl.java b/src/main/java/org/ject/support/domain/ministudy/repository/MiniStudyQueryRepositoryImpl.java index 421ae830..aab37ef2 100644 --- a/src/main/java/org/ject/support/domain/ministudy/repository/MiniStudyQueryRepositoryImpl.java +++ b/src/main/java/org/ject/support/domain/ministudy/repository/MiniStudyQueryRepositoryImpl.java @@ -28,7 +28,8 @@ public Page findMiniStudies(Pageable pageable) { miniStudy.name, miniStudy.linkUrl, miniStudy.imageUrl, - miniStudy.summary + miniStudy.summary, + miniStudy.tag )) .from(miniStudy) .orderBy(miniStudy.id.desc()) diff --git a/src/main/java/org/ject/support/external/discord/DiscordComponent.java b/src/main/java/org/ject/support/external/discord/DiscordComponent.java deleted file mode 100644 index 5b632722..00000000 --- a/src/main/java/org/ject/support/external/discord/DiscordComponent.java +++ /dev/null @@ -1,104 +0,0 @@ -package org.ject.support.external.discord; - -import lombok.extern.slf4j.Slf4j; -import org.ject.support.external.notification.NotificationMessageFactory; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.env.Environment; -import org.springframework.core.env.Profiles; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; -import org.springframework.web.reactive.function.client.WebClient; -import reactor.core.publisher.Mono; - -@Slf4j -@Component -public class DiscordComponent { - - private final WebClient webClient; - private final String prefix; - private final NotificationMessageFactory messageFactory; - - @Value("${notification.discord.webhook.admin-login}") - private String adminLoginWebhook; - - @Value("${notification.discord.webhook.supporter-token-issue}") - private String supporterTokenIssueWebhook; - - public DiscordComponent( - WebClient webClient, - Environment environment, - NotificationMessageFactory messageFactory - ) { - this.webClient = webClient; - this.prefix = resolvePrefix(environment); - this.messageFactory = messageFactory; - } - - public Mono sendAdminLoginMessage(String email, String code) { - String description = messageFactory.adminLoginCode(email, code); - - DiscordWebhookPayload.Embed embed = - new DiscordWebhookPayload.Embed("로그인 인증 요청", description); - - DiscordWebhookPayload payload = new DiscordWebhookPayload(); - payload.setContent("[관리자 로그인]"); - payload.getEmbeds().add(embed); - return sendMessage(adminLoginWebhook, payload); - } - - public Mono sendSupporterTokenIssueMessage(String email, String accessToken) { - String description = messageFactory.supporterAccessTokenIssued(email, accessToken); - - DiscordWebhookPayload.Embed embed = - new DiscordWebhookPayload.Embed("서포터즈 엑세스 토큰 발급 요청", description); - - DiscordWebhookPayload payload = new DiscordWebhookPayload(); - payload.setContent("[서포터즈 엑세스 토큰 발급]"); - payload.getEmbeds().add(embed); - return sendMessage(supporterTokenIssueWebhook, payload); - } - - /** - * 논블로킹 방식으로 메시지 전송. 호출자가 구독/에러처리를 담당해야 함. - * 기존 payload는 변경하지 않고 사본을 만들어 전송. - */ - public Mono sendMessage(String webhookUrl, DiscordWebhookPayload payload) { - if (!StringUtils.hasText(webhookUrl)) { - log.warn("Discord webhook URL이 설정되지 않았습니다, 메시지 전송을 건너뜁니다."); - return Mono.empty(); - } - - DiscordWebhookPayload toSend = createPayloadCopyWithPrefix(payload, prefix); - - return webClient.post() - .uri(webhookUrl) - .bodyValue(toSend) - .retrieve() - .toBodilessEntity() - .doOnSuccess((ResponseEntity resp) -> - log.info("Discord message sent successfully")) - .doOnError(e -> - log.error("Discord message send failed: {} - {}", e.getClass().getSimpleName(), e.getMessage())) - .then(); - } - - private DiscordWebhookPayload createPayloadCopyWithPrefix(DiscordWebhookPayload original, String prefix) { - DiscordWebhookPayload copy = new DiscordWebhookPayload(); - String originalContent = original == null || original.getContent() == null ? "" : original.getContent(); - copy.setContent(prefix + originalContent); - - if (original != null && original.getEmbeds() != null) { - copy.getEmbeds().addAll(original.getEmbeds()); - } - return copy; - } - - private String resolvePrefix(Environment environment) { - if (environment != null && environment.acceptsProfiles(Profiles.of("prod", "production"))) { - return ""; - } else { - return "[개발] "; - } - } -} diff --git a/src/main/java/org/ject/support/external/discord/DiscordWebhookPayload.java b/src/main/java/org/ject/support/external/discord/DiscordWebhookPayload.java deleted file mode 100644 index cda26a80..00000000 --- a/src/main/java/org/ject/support/external/discord/DiscordWebhookPayload.java +++ /dev/null @@ -1,21 +0,0 @@ -package org.ject.support.external.discord; - -import lombok.AllArgsConstructor; -import lombok.Data; - -import java.util.ArrayList; -import java.util.List; - -@Data -public class DiscordWebhookPayload { - private String content; - private boolean tts = false; - private List embeds = new ArrayList<>(); - - @Data - @AllArgsConstructor - public static class Embed { - private String title; - private String description; - } -} diff --git a/src/main/java/org/ject/support/external/n8n/client/N8nClient.java b/src/main/java/org/ject/support/external/n8n/client/N8nClient.java new file mode 100644 index 00000000..321c3f7c --- /dev/null +++ b/src/main/java/org/ject/support/external/n8n/client/N8nClient.java @@ -0,0 +1,47 @@ +package org.ject.support.external.n8n.client; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ject.support.domain.admin.dto.SubmittedApplyDetailResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +@Slf4j +@Component +@RequiredArgsConstructor +public class N8nClient { + + private final WebClient webClient; + + @Value("${n8n.secret-key}") + private String secretKey; + + @Value("${n8n.webhook.application-submit}") + private String applicationSubmitWebhook; + + public void send(SubmittedApplyDetailResponse payload) { + webClient.post() + .uri(applicationSubmitWebhook) + .header("Apply-Webhook-Secret", secretKey) + .bodyValue(payload) + .retrieve() + .toBodilessEntity() + .doOnSuccess(this::logSuccess) + .doOnError(this::logError) + .block(); + } + + private void logSuccess(ResponseEntity response) { + log.info("N8n message sent successfully (status={})", response.getStatusCode()); + } + + private void logError(Throwable e) { + log.error( + "N8n message send failed apply: {} - {}", + e.getClass().getSimpleName(), + e.getMessage() + ); + } +} diff --git a/src/main/java/org/ject/support/external/n8n/event/ApplicationSubmittedEvent.java b/src/main/java/org/ject/support/external/n8n/event/ApplicationSubmittedEvent.java new file mode 100644 index 00000000..49e19385 --- /dev/null +++ b/src/main/java/org/ject/support/external/n8n/event/ApplicationSubmittedEvent.java @@ -0,0 +1,5 @@ +package org.ject.support.external.n8n.event; + +public record ApplicationSubmittedEvent( + Long applyId +) {} diff --git a/src/main/java/org/ject/support/external/n8n/listener/N8nApplicationSubmittedListener.java b/src/main/java/org/ject/support/external/n8n/listener/N8nApplicationSubmittedListener.java new file mode 100644 index 00000000..0752f6e4 --- /dev/null +++ b/src/main/java/org/ject/support/external/n8n/listener/N8nApplicationSubmittedListener.java @@ -0,0 +1,26 @@ +package org.ject.support.external.n8n.listener; + +import lombok.RequiredArgsConstructor; +import org.ject.support.external.n8n.event.ApplicationSubmittedEvent; +import org.ject.support.external.n8n.service.N8nApplyService; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * Admin Page가 생기기 전 까지 n8n으로 지원서 제출 알림을 보냄 + * - Admin Page가 생기면 해당 기능은 제거될 예정 + */ +@Component +@RequiredArgsConstructor +public class N8nApplicationSubmittedListener { + + private final N8nApplyService n8nApplyService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(ApplicationSubmittedEvent event) { + n8nApplyService.sendToN8n(event.applyId()); + } +} diff --git a/src/main/java/org/ject/support/external/n8n/service/N8nApplyService.java b/src/main/java/org/ject/support/external/n8n/service/N8nApplyService.java new file mode 100644 index 00000000..3d6a2e86 --- /dev/null +++ b/src/main/java/org/ject/support/external/n8n/service/N8nApplyService.java @@ -0,0 +1,66 @@ +package org.ject.support.external.n8n.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ject.support.common.util.String2MapSerializer; +import org.ject.support.domain.admin.dto.SubmittedApplyDetailResponse; +import org.ject.support.domain.apply.domain.ApplicationForm; +import org.ject.support.domain.apply.domain.Apply; +import org.ject.support.domain.apply.dto.ApplyPortfolioDto; +import org.ject.support.domain.apply.exception.ApplyErrorCode; +import org.ject.support.domain.apply.exception.ApplyException; +import org.ject.support.domain.apply.repository.ApplyRepository; +import org.ject.support.external.n8n.client.N8nClient; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class N8nApplyService { + + private final ApplyRepository applyRepository; + private final String2MapSerializer string2MapSerializer; + private final N8nClient n8nClient; + + @Transactional(readOnly = true) + public void sendToN8n(final Long applyId) { + // SubmittedApplyDetailResponse n8n 전송용으로 임시 재사용 + SubmittedApplyDetailResponse response = applyRepository.findByIdAndStatusWithMember(applyId, Apply.Status.SUBMITTED) + .map(this::toSubmittedApplyDetailResponse) + .orElseThrow(() -> new ApplyException(ApplyErrorCode.NOT_FOUND_APPLY)); + try { + n8nClient.send(response); + } catch (Exception e) { + log.error("Failed to send apply {} to n8n", applyId, e); + } + + } + + private SubmittedApplyDetailResponse toSubmittedApplyDetailResponse(final Apply apply) { + ApplicationForm submittedApplicationForm = apply.getApplicationForm(); + Map content = extractContent(submittedApplicationForm); + List portfolios = extractPortfolios(submittedApplicationForm); + return SubmittedApplyDetailResponse.from(apply, content, portfolios); + } + + private Map extractContent(final ApplicationForm applicationForm) { + return Optional.ofNullable(applicationForm) + .map(ApplicationForm::getContent) + .map(string2MapSerializer::serializeAsMap) + .orElse(Map.of()); + } + + private List extractPortfolios(final ApplicationForm applicationForm) { + return Optional.ofNullable(applicationForm) + .map(ApplicationForm::getPortfolios) + .orElse(List.of()) + .stream() + .map(ApplyPortfolioDto::from) + .toList(); + } +} diff --git a/src/main/java/org/ject/support/external/notification/DiscordNotificationMessageFactory.java b/src/main/java/org/ject/support/external/notification/DiscordNotificationMessageFactory.java deleted file mode 100644 index 120f12f0..00000000 --- a/src/main/java/org/ject/support/external/notification/DiscordNotificationMessageFactory.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.ject.support.external.notification; - -import org.springframework.context.annotation.Primary; -import org.springframework.stereotype.Component; - -@Primary -@Component -public class DiscordNotificationMessageFactory implements NotificationMessageFactory { - - @Override - public String adminLoginCode(String email, String code) { - return """ - 관리자 로그인 인증 코드 요청 - 관리자 이메일: %s - 인증 코드 : ||%s|| - """.formatted(email, code); - } - - @Override - public String supporterAccessTokenIssued(String email, String accessToken) { - return """ - 서포터즈 엑세스 토큰 요청 - 요청 이메일 : %s - 토큰 : ||%s|| - """.formatted(email, accessToken); - } -} diff --git a/src/main/java/org/ject/support/external/notification/NotificationMessageFactory.java b/src/main/java/org/ject/support/external/notification/NotificationMessageFactory.java deleted file mode 100644 index 6945274f..00000000 --- a/src/main/java/org/ject/support/external/notification/NotificationMessageFactory.java +++ /dev/null @@ -1,6 +0,0 @@ -package org.ject.support.external.notification; - -public interface NotificationMessageFactory { - String adminLoginCode(String email, String code); - String supporterAccessTokenIssued(String email, String accessToken); -} diff --git a/src/main/java/org/ject/support/external/notification/client/DiscordClient.java b/src/main/java/org/ject/support/external/notification/client/DiscordClient.java new file mode 100644 index 00000000..b45c0025 --- /dev/null +++ b/src/main/java/org/ject/support/external/notification/client/DiscordClient.java @@ -0,0 +1,49 @@ +package org.ject.support.external.notification.client; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.ject.support.external.notification.payload.DiscordWebhookPayload; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DiscordClient { + + private final WebClient webClient; + + public Mono send( + String webhookUrl, + DiscordWebhookPayload payload + ) { + if (!StringUtils.hasText(webhookUrl)) { + log.warn("Discord webhook URL이 비어있어 메시지를 전송하지 않습니다."); + return Mono.empty(); + } + + return webClient.post() + .uri(webhookUrl) + .bodyValue(payload) + .retrieve() + .toBodilessEntity() + .doOnSuccess(this::logSuccess) + .doOnError(this::logError) + .then(); + } + + private void logSuccess(ResponseEntity response) { + log.info("Discord message sent successfully (status={})", response.getStatusCode()); + } + + private void logError(Throwable e) { + log.error( + "Discord message send failed: {} - {}", + e.getClass().getSimpleName(), + e.getMessage() + ); + } +} diff --git a/src/main/java/org/ject/support/external/notification/event/AdminLoginNotificationEvent.java b/src/main/java/org/ject/support/external/notification/event/AdminLoginNotificationEvent.java new file mode 100644 index 00000000..49cb7772 --- /dev/null +++ b/src/main/java/org/ject/support/external/notification/event/AdminLoginNotificationEvent.java @@ -0,0 +1,6 @@ +package org.ject.support.external.notification.event; + +public record AdminLoginNotificationEvent( + String email, + String code +) {} diff --git a/src/main/java/org/ject/support/external/notification/event/SupporterTokenIssuedEvent.java b/src/main/java/org/ject/support/external/notification/event/SupporterTokenIssuedEvent.java new file mode 100644 index 00000000..ef66060c --- /dev/null +++ b/src/main/java/org/ject/support/external/notification/event/SupporterTokenIssuedEvent.java @@ -0,0 +1,6 @@ +package org.ject.support.external.notification.event; + +public record SupporterTokenIssuedEvent( + String email, + String accessToken +) {} diff --git a/src/main/java/org/ject/support/external/notification/executor/NotificationExecutorService.java b/src/main/java/org/ject/support/external/notification/executor/NotificationExecutorService.java new file mode 100644 index 00000000..d4893fa0 --- /dev/null +++ b/src/main/java/org/ject/support/external/notification/executor/NotificationExecutorService.java @@ -0,0 +1,20 @@ +package org.ject.support.external.notification.executor; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +import java.time.Duration; + +@Component +@Slf4j +public class NotificationExecutorService { + + public void execute(Mono mono) { + mono + .retry(3) + .timeout(Duration.ofSeconds(3)) + .doOnError(e -> log.error("Notification failed", e)) + .subscribe(); + } +} diff --git a/src/main/java/org/ject/support/external/notification/listener/NotificationEventListener.java b/src/main/java/org/ject/support/external/notification/listener/NotificationEventListener.java new file mode 100644 index 00000000..0c21cedd --- /dev/null +++ b/src/main/java/org/ject/support/external/notification/listener/NotificationEventListener.java @@ -0,0 +1,37 @@ +package org.ject.support.external.notification.listener; + +import lombok.RequiredArgsConstructor; +import org.ject.support.external.notification.sender.NotificationSender; +import org.ject.support.external.notification.event.AdminLoginNotificationEvent; +import org.ject.support.external.notification.event.SupporterTokenIssuedEvent; +import org.ject.support.external.notification.executor.NotificationExecutorService; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class NotificationEventListener { + + private final NotificationSender notificationSender; + private final NotificationExecutorService notificationExecutorService; + + @EventListener + public void handle(AdminLoginNotificationEvent event) { + notificationExecutorService.execute( + notificationSender.sendAdminLogin( + event.email(), + event.code() + ) + ); + } + + @EventListener + public void handle(SupporterTokenIssuedEvent event) { + notificationExecutorService.execute( + notificationSender.sendSupporterTokenIssued( + event.email(), + event.accessToken() + ) + ); + } +} diff --git a/src/main/java/org/ject/support/external/notification/payload/DiscordWebhookPayload.java b/src/main/java/org/ject/support/external/notification/payload/DiscordWebhookPayload.java new file mode 100644 index 00000000..edf8592b --- /dev/null +++ b/src/main/java/org/ject/support/external/notification/payload/DiscordWebhookPayload.java @@ -0,0 +1,66 @@ +package org.ject.support.external.notification.payload; + +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +public class DiscordWebhookPayload { + private String content; + private boolean tts = false; + private List embeds = new ArrayList<>(); + + @Data + @AllArgsConstructor + public static class Embed { + private String title; + private String description; + } + + public DiscordWebhookPayload withPrefix(String prefix) { + DiscordWebhookPayload copy = new DiscordWebhookPayload(); + copy.setContent(prefix + this.content); + copy.getEmbeds().addAll(this.embeds); + return copy; + } + + public static DiscordWebhookPayload adminLogin( + String email, + String code + ) { + DiscordWebhookPayload payload = new DiscordWebhookPayload(); + payload.setContent("[관리자 로그인]"); + + payload.getEmbeds().add( + new Embed( + "관리자 로그인 인증 코드 요청", + """ + 이메일: %s + 인증 코드 : ||%s|| + """.formatted(email, code) + ) + ); + return payload; + } + + public static DiscordWebhookPayload supporterToken( + String email, + String accessToken + ) { + DiscordWebhookPayload payload = new DiscordWebhookPayload(); + payload.setContent("[서포터즈 임시 토큰 발급]"); + + payload.getEmbeds().add( + new Embed( + "서포터즈 토큰 발급", + """ + 이메일: %s + 토큰 : ||%s|| + """.formatted(email, accessToken) + ) + ); + return payload; + } +} diff --git a/src/main/java/org/ject/support/external/notification/sender/DiscordNotificationSender.java b/src/main/java/org/ject/support/external/notification/sender/DiscordNotificationSender.java new file mode 100644 index 00000000..ea9b6921 --- /dev/null +++ b/src/main/java/org/ject/support/external/notification/sender/DiscordNotificationSender.java @@ -0,0 +1,60 @@ +package org.ject.support.external.notification.sender; + +import lombok.RequiredArgsConstructor; +import org.ject.support.external.notification.client.DiscordClient; +import org.ject.support.external.notification.payload.DiscordWebhookPayload; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Primary; +import org.springframework.core.env.Environment; +import org.springframework.core.env.Profiles; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +@Primary +@Component +@RequiredArgsConstructor +public class DiscordNotificationSender implements NotificationSender { + + private final DiscordClient discordClient; + private final Environment environment; + + @Value("${notification.discord.webhook.admin-login}") + private String adminLoginWebhook; + + @Value("${notification.discord.webhook.supporter-token-issue}") + private String supporterTokenIssueWebhook; + + @Override + public Mono sendAdminLogin( + String email, + String code + ) { + return discordClient.send( + adminLoginWebhook, + DiscordWebhookPayload + .adminLogin(email, code) + .withPrefix(resolvePrefix()) + ); + } + + @Override + public Mono sendSupporterTokenIssued( + String email, + String accessToken + ) { + return discordClient.send( + supporterTokenIssueWebhook, + DiscordWebhookPayload + .supporterToken(email, accessToken) + .withPrefix(resolvePrefix()) + ); + } + + private String resolvePrefix() { + if (environment.acceptsProfiles(Profiles.of("prod", "production"))) { + return ""; + } else { + return "[개발] "; + } + } +} diff --git a/src/main/java/org/ject/support/external/notification/sender/NotificationSender.java b/src/main/java/org/ject/support/external/notification/sender/NotificationSender.java new file mode 100644 index 00000000..e1385f38 --- /dev/null +++ b/src/main/java/org/ject/support/external/notification/sender/NotificationSender.java @@ -0,0 +1,16 @@ +package org.ject.support.external.notification.sender; + +import reactor.core.publisher.Mono; + +public interface NotificationSender { + + Mono sendAdminLogin( + String email, + String code + ); + + Mono sendSupporterTokenIssued( + String email, + String accessToken + ); +} diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index a78911b1..d0376df3 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -69,3 +69,8 @@ notification: webhook: admin-login: dummy-url-for-test supporter-token-issue: dummy-url-for-test + +n8n: + secret-key: n8n-test-secret-key + webhook: + application-submit: http://localhost:5678/webhook/application-submit diff --git a/src/main/resources/db/migration/V14__add_tag_column_to_mini_study.sql b/src/main/resources/db/migration/V14__add_tag_column_to_mini_study.sql new file mode 100644 index 00000000..4dfc181b --- /dev/null +++ b/src/main/resources/db/migration/V14__add_tag_column_to_mini_study.sql @@ -0,0 +1,2 @@ +ALTER TABLE mini_study + ADD COLUMN tag VARCHAR(255) NOT NULL DEFAULT ''; diff --git a/src/main/resources/db/migration/V15__alter_jectalk_table_structure.sql b/src/main/resources/db/migration/V15__alter_jectalk_table_structure.sql new file mode 100644 index 00000000..8f1a5a63 --- /dev/null +++ b/src/main/resources/db/migration/V15__alter_jectalk_table_structure.sql @@ -0,0 +1,27 @@ +-- Jectalk 테이블 구조 변경 +-- 기존: name, summary, youtube_url, image_url +-- 변경: title, description, content_type, content_url, thumbnail_url, author, category + +-- author 컬럼 추가 +ALTER TABLE jectalk ADD COLUMN author VARCHAR(255) NOT NULL DEFAULT ''; + +-- 기존 summary 데이터를 author로 마이그레이션 +UPDATE jectalk SET author = summary; + +-- content_type 컬럼 추가 +ALTER TABLE jectalk ADD COLUMN content_type VARCHAR(50) NOT NULL DEFAULT 'YOUTUBE'; + +-- category 컬럼 추가 +ALTER TABLE jectalk ADD COLUMN category VARCHAR(30) NOT NULL DEFAULT 'SEMESTER_1'; + +-- 컬럼명 변경 +ALTER TABLE jectalk CHANGE COLUMN name title VARCHAR(50) NOT NULL; +ALTER TABLE jectalk CHANGE COLUMN summary description VARCHAR(255) NOT NULL; +ALTER TABLE jectalk CHANGE COLUMN youtube_url content_url VARCHAR(2083) NULL; +ALTER TABLE jectalk CHANGE COLUMN image_url thumbnail_url VARCHAR(2083) NULL; + +-- DEFAULT 제거 +ALTER TABLE jectalk MODIFY COLUMN author VARCHAR(255) NOT NULL; +ALTER TABLE jectalk MODIFY COLUMN content_type VARCHAR(50) NOT NULL; +ALTER TABLE jectalk MODIFY COLUMN category VARCHAR(30) NOT NULL; + diff --git a/src/test/java/org/ject/support/domain/admin/service/AdminAuthServiceTest.java b/src/test/java/org/ject/support/domain/admin/service/AdminAuthServiceTest.java index d5f5d098..42a02b15 100644 --- a/src/test/java/org/ject/support/domain/admin/service/AdminAuthServiceTest.java +++ b/src/test/java/org/ject/support/domain/admin/service/AdminAuthServiceTest.java @@ -9,21 +9,21 @@ import org.ject.support.domain.member.Role; import org.ject.support.domain.member.entity.Member; import org.ject.support.domain.member.repository.MemberRepository; -import org.ject.support.external.discord.DiscordComponent; import org.ject.support.external.infrastructure.DiscordRateLimiter; +import org.ject.support.external.notification.event.AdminLoginNotificationEvent; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; import org.springframework.security.core.Authentication; -import reactor.core.publisher.Mono; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; @@ -48,15 +48,15 @@ class AdminAuthServiceTest extends UnitTestSupport { @Mock DiscordRateLimiter discordRateLimiter; - @Mock - DiscordComponent discordComponent; - @Mock JwtTokenProvider jwtTokenProvider; @Mock private Authentication authentication; + @Mock + ApplicationEventPublisher applicationEventPublisher; + @Test void 관리자로그인_1차인증시_LOCKED_상태의_상태의_계정일_경우_LOCKED_ADMIN_예외_발생() { // given @@ -97,7 +97,7 @@ class AdminAuthServiceTest extends UnitTestSupport { } @Test - void 관리자_로그인_시도를_성공한_경우_관리자의_Email_반환() { + void 관리자_로그인_인증코드_요청_시_이벤트를_발행한다() { // given long memberId = 1L; String email = "test.com"; @@ -107,10 +107,10 @@ class AdminAuthServiceTest extends UnitTestSupport { .status(MemberStatus.ACTIVE) .role(Role.ADMIN) .build(); + given(adminMemberComponent.getMemberAdminByEmail(email)).willReturn(adminMember); given(redisTemplate.opsForValue()).willReturn(valueOperations); given(discordRateLimiter.tryConsume(1)).willReturn(true); - given(discordComponent.sendAdminLoginMessage(anyString(), anyString())).willReturn(Mono.empty()); // when String result = adminAuthService.sendAdminAuthCode(email); @@ -118,6 +118,8 @@ class AdminAuthServiceTest extends UnitTestSupport { // then verify(adminMemberComponent).getMemberAdminByEmail(email); verify(discordRateLimiter).tryConsume(1); + verify(applicationEventPublisher) + .publishEvent(any(AdminLoginNotificationEvent.class)); assertEquals(email, result); } diff --git a/src/test/java/org/ject/support/domain/admin/service/AdminTempApplyServiceTest.java b/src/test/java/org/ject/support/domain/admin/service/AdminTempApplyServiceTest.java index b3cee337..041c9d32 100644 --- a/src/test/java/org/ject/support/domain/admin/service/AdminTempApplyServiceTest.java +++ b/src/test/java/org/ject/support/domain/admin/service/AdminTempApplyServiceTest.java @@ -164,16 +164,17 @@ class AdminTempApplyServiceTest extends UnitTestSupport { void 임시_저장된_지원서를_조회할때_결과가_없으면_빈페이지_반환한다() { // given Pageable pageable = PageRequest.of(0, 10); + Long semesterId = null; Page applyPage = new PageImpl<>(List.of(), pageable, 0); - given(applyRepository.findAppliesByStatus(null, Apply.Status.TEMP_SAVED, pageable)) + given(applyRepository.findAppliesByStatus(null, Apply.Status.TEMP_SAVED, semesterId, pageable)) .willReturn(applyPage); // when - Page result = adminTempApplyService.getTempApplies(null, pageable); + Page result = adminTempApplyService.getTempApplies(null, semesterId, pageable); // then - verify(applyRepository).findAppliesByStatus(null, Apply.Status.TEMP_SAVED, pageable); + verify(applyRepository).findAppliesByStatus(null, Apply.Status.TEMP_SAVED, semesterId, pageable); assertThat(result.getTotalElements()).isEqualTo(0); assertThat(result.getContent()).isEmpty(); } @@ -182,6 +183,7 @@ class AdminTempApplyServiceTest extends UnitTestSupport { void 임시_저장된_지원서를_조회_한다() { // given Pageable pageable = PageRequest.of(0, 10); + Long semesterId = null; Member m1 = Member.builder().name("김1").build(); Member m2 = Member.builder().name("김2").build(); @@ -210,20 +212,55 @@ class AdminTempApplyServiceTest extends UnitTestSupport { List applies = List.of(a1, a2); Page applyPage = new PageImpl<>(applies, pageable, applies.size()); - given(applyRepository.findAppliesByStatus(null, Apply.Status.TEMP_SAVED, pageable)) + given(applyRepository.findAppliesByStatus(null, Apply.Status.TEMP_SAVED, semesterId, pageable)) .willReturn(applyPage); // applicationForm.content가 "{}" 이므로 이 호출을 stub 처리 given(string2MapSerializer.serializeAsMap("{}")).willReturn(java.util.Map.of()); Page result = - adminTempApplyService.getTempApplies(null, pageable); + adminTempApplyService.getTempApplies(null, semesterId, pageable); - verify(applyRepository).findAppliesByStatus(null,Apply.Status.TEMP_SAVED, pageable); + verify(applyRepository).findAppliesByStatus(null, Apply.Status.TEMP_SAVED, semesterId, pageable); assertThat(result.getTotalElements()).isEqualTo(2); assertThat(result.getContent()).hasSize(2); assertThat(result.getContent().get(0).applyId()).isEqualTo(1L); assertThat(result.getContent().get(1).applyId()).isEqualTo(2L); } + @Test + void 임시_저장된_지원서를_semesterId로_필터링하여_조회한다() { + // given + Pageable pageable = PageRequest.of(0, 10); + Long semesterId = 1L; + + Member member = Member.builder().name("김젝트").build(); + Semester semester = Semester.builder().name("1").build(); + Recruit recruit = Recruit.builder().semester(semester).build(); + ApplicationForm form = ApplicationForm.builder().content("{}").build(); + + Apply apply = Apply.builder() + .id(1L) + .member(member) + .recruit(recruit) + .status(Apply.Status.TEMP_SAVED) + .applicationForm(form) + .build(); + + List applies = List.of(apply); + Page applyPage = new PageImpl<>(applies, pageable, applies.size()); + + given(applyRepository.findAppliesByStatus(null, Apply.Status.TEMP_SAVED, semesterId, pageable)) + .willReturn(applyPage); + given(string2MapSerializer.serializeAsMap("{}")).willReturn(java.util.Map.of()); + + // when + Page result = adminTempApplyService.getTempApplies(null, semesterId, pageable); + + // then + verify(applyRepository).findAppliesByStatus(null, Apply.Status.TEMP_SAVED, semesterId, pageable); + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).applyId()).isEqualTo(1L); + } } diff --git a/src/test/java/org/ject/support/domain/admin/service/SubmittedApplyServiceTest.java b/src/test/java/org/ject/support/domain/admin/service/SubmittedApplyServiceTest.java index 8b4e2a73..6978088a 100644 --- a/src/test/java/org/ject/support/domain/admin/service/SubmittedApplyServiceTest.java +++ b/src/test/java/org/ject/support/domain/admin/service/SubmittedApplyServiceTest.java @@ -219,39 +219,41 @@ void setUp() { // given var pageable = PageRequest.of(0, 15); var jobFamily = JobFamily.BE; + Long semesterId = null; var applies = List.of(submittedApply); var page = new PageImpl<>(applies, pageable, 1L); - given(applyRepository.findAppliesByStatus(jobFamily, Status.SUBMITTED ,pageable)) + given(applyRepository.findAppliesByStatus(jobFamily, Status.SUBMITTED, semesterId, pageable)) .willReturn(page); // when - Page result = submittedApplyService.findSubmittedApplies(jobFamily, pageable); + Page result = submittedApplyService.findSubmittedApplies(jobFamily, semesterId, pageable); // then assertThat(result.getContent()).hasSize(1); assertThat(result.getTotalElements()).isEqualTo(1); - verify(applyRepository).findAppliesByStatus(jobFamily, Status.SUBMITTED, pageable); + verify(applyRepository).findAppliesByStatus(jobFamily, Status.SUBMITTED, semesterId, pageable); } @Test void 제출된_지원서_목록_조회시_JobFamily가_null이면_전체_조회() { // given var pageable = PageRequest.of(0, 15); + Long semesterId = null; var applies = List.of(submittedApply); var page = new PageImpl<>(applies, pageable, 1L); - given(applyRepository.findAppliesByStatus(null, Status.SUBMITTED, pageable)) + given(applyRepository.findAppliesByStatus(null, Status.SUBMITTED, semesterId, pageable)) .willReturn(page); // when - Page result = submittedApplyService.findSubmittedApplies(null, pageable); + Page result = submittedApplyService.findSubmittedApplies(null, semesterId, pageable); // then assertThat(result.getContent()).hasSize(1); assertThat(result.getTotalElements()).isEqualTo(1); - verify(applyRepository).findAppliesByStatus(null, Status.SUBMITTED, pageable); + verify(applyRepository).findAppliesByStatus(null, Status.SUBMITTED, semesterId, pageable); } @Test @@ -259,18 +261,19 @@ void setUp() { // given var pageable = PageRequest.of(0, 15); var jobFamily = JobFamily.BE; + Long semesterId = null; var page = new PageImpl(List.of(), pageable, 0L); - given(applyRepository.findAppliesByStatus(jobFamily, Status.SUBMITTED, pageable)) + given(applyRepository.findAppliesByStatus(jobFamily, Status.SUBMITTED, semesterId, pageable)) .willReturn(page); // when - Page result = submittedApplyService.findSubmittedApplies(jobFamily, pageable); + Page result = submittedApplyService.findSubmittedApplies(jobFamily, semesterId, pageable); // then assertThat(result.getContent()).isEmpty(); assertThat(result.getTotalElements()).isZero(); - verify(applyRepository).findAppliesByStatus(jobFamily, Status.SUBMITTED, pageable); + verify(applyRepository).findAppliesByStatus(jobFamily, Status.SUBMITTED, semesterId, pageable); } @Test @@ -278,6 +281,7 @@ void setUp() { // given var pageable = PageRequest.of(1, 10, Sort.by("createdAt").descending()); var jobFamily = JobFamily.BE; + Long semesterId = null; var member2 = Member.builder().name("김젝트2").build(); var apply2 = Apply.builder() @@ -291,18 +295,40 @@ void setUp() { var applies = List.of(submittedApply, apply2); var page = new PageImpl<>(applies, pageable, 25L); - given(applyRepository.findAppliesByStatus(jobFamily, Status.SUBMITTED, pageable)) + given(applyRepository.findAppliesByStatus(jobFamily, Status.SUBMITTED, semesterId, pageable)) .willReturn(page); // when - Page result = submittedApplyService.findSubmittedApplies(jobFamily, pageable); + Page result = submittedApplyService.findSubmittedApplies(jobFamily, semesterId, pageable); // then assertThat(result.getContent()).hasSize(2); assertThat(result.getTotalElements()).isEqualTo(25L); assertThat(result.getNumber()).isEqualTo(1); assertThat(result.getSize()).isEqualTo(10); - verify(applyRepository).findAppliesByStatus(jobFamily, Status.SUBMITTED, pageable); + verify(applyRepository).findAppliesByStatus(jobFamily, Status.SUBMITTED, semesterId, pageable); + } + + @Test + void 제출된_지원서_목록을_semesterId로_필터링하여_조회() { + // given + var pageable = PageRequest.of(0, 15); + var jobFamily = JobFamily.BE; + Long semesterId = 1L; + + var applies = List.of(submittedApply); + var page = new PageImpl<>(applies, pageable, 1L); + + given(applyRepository.findAppliesByStatus(jobFamily, Status.SUBMITTED, semesterId, pageable)) + .willReturn(page); + + // when + Page result = submittedApplyService.findSubmittedApplies(jobFamily, semesterId, pageable); + + // then + assertThat(result.getContent()).hasSize(1); + assertThat(result.getTotalElements()).isEqualTo(1); + verify(applyRepository).findAppliesByStatus(jobFamily, Status.SUBMITTED, semesterId, pageable); } @Test diff --git a/src/test/java/org/ject/support/domain/apply/repository/ApplyQueryRepositoryTest.java b/src/test/java/org/ject/support/domain/apply/repository/ApplyQueryRepositoryTest.java index 0aa3f62e..3646f99e 100644 --- a/src/test/java/org/ject/support/domain/apply/repository/ApplyQueryRepositoryTest.java +++ b/src/test/java/org/ject/support/domain/apply/repository/ApplyQueryRepositoryTest.java @@ -83,7 +83,7 @@ void setUp() { Apply.Status status = SUBMITTED; // when - Page result = applyRepository.findAppliesByStatus(BE, status, pageable); + Page result = applyRepository.findAppliesByStatus(BE, status, null, pageable); // then assertThat(result.getContent()).hasSize(2); @@ -110,7 +110,7 @@ void setUp() { Apply.Status status = SUBMITTED; // when - Page result = applyRepository.findAppliesByStatus(null, status, pageable); + Page result = applyRepository.findAppliesByStatus(null, status, null, pageable); // then assertThat(result.getContent()).hasSize(3); @@ -132,7 +132,7 @@ void setUp() { Apply.Status status = SUBMITTED; // when - Page result = applyRepository.findAppliesByStatus(BE, status, pageable); + Page result = applyRepository.findAppliesByStatus(BE, status, null, pageable); // then assertThat(result.getContent()).hasSize(1); @@ -155,7 +155,7 @@ void setUp() { Apply.Status status = SUBMITTED; // when - Page result = applyRepository.findAppliesByStatus(BE, status, pageable); + Page result = applyRepository.findAppliesByStatus(BE, status, null, pageable); // then assertThat(result.getContent()).hasSize(1); @@ -176,7 +176,7 @@ void setUp() { Apply.Status status = SUBMITTED; // when - Page result = applyRepository.findAppliesByStatus(BE, status, pageable); + Page result = applyRepository.findAppliesByStatus(BE, status, null, pageable); // then assertThat(result.getContent()).hasSize(10); @@ -192,7 +192,7 @@ void setUp() { Apply.Status status = SUBMITTED; // when - Page result = applyRepository.findAppliesByStatus(BE, status, pageable); + Page result = applyRepository.findAppliesByStatus(BE, status, null, pageable); // then @@ -221,7 +221,7 @@ void setUp() { Apply.Status status = SUBMITTED; // when - Page result = applyRepository.findAppliesByStatus(BE, status, pageable); + Page result = applyRepository.findAppliesByStatus(BE, status, null, pageable); // then assertThat(result.getContent()).hasSize(3); @@ -230,6 +230,48 @@ void setUp() { assertThat(result.getContent().get(2)).isEqualTo(apply1); } + @Test + void semesterId로_제출된_지원서_필터링_조회() { + // given + Semester semester2 = semesterRepository.save(Semester.builder() + .name("2기") + .isRecruiting(false) + .build()); + + Recruit recruit1 = recruitRepository.save(Recruit.builder() + .semester(semester) + .startDate(LocalDateTime.now().minusDays(1)) + .endDate(LocalDateTime.now().plusDays(7)) + .jobFamily(BE) + .build()); + + Recruit recruit2 = recruitRepository.save(Recruit.builder() + .semester(semester2) + .startDate(LocalDateTime.now().minusDays(1)) + .endDate(LocalDateTime.now().plusDays(7)) + .jobFamily(BE) + .build()); + + Member member1 = createMember("be1@test.com", BE); + Member member2 = createMember("be2@test.com", BE); + memberRepository.saveAll(List.of(member1, member2)); + + Apply apply1 = getApply(member1, recruit1, SUBMITTED); + Apply apply2 = getApply(member2, recruit2, SUBMITTED); + applyRepository.saveAll(List.of(apply1, apply2)); + + Pageable pageable = PageRequest.of(0, 15); + Apply.Status status = SUBMITTED; + + // when + Page result = applyRepository.findAppliesByStatus(BE, status, semester.getId(), pageable); + + // then + assertThat(result.getContent()).hasSize(1); + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getContent().getFirst().getRecruit().getSemester()).isEqualTo(semester); + } + private Recruit getRecruit(JobFamily jobFamily) { return Recruit.builder() .semester(semester) diff --git a/src/test/java/org/ject/support/domain/apply/service/ApplyServiceTest.java b/src/test/java/org/ject/support/domain/apply/service/ApplyServiceTest.java index 152aa5a4..dac07094 100644 --- a/src/test/java/org/ject/support/domain/apply/service/ApplyServiceTest.java +++ b/src/test/java/org/ject/support/domain/apply/service/ApplyServiceTest.java @@ -1,23 +1,5 @@ package org.ject.support.domain.apply.service; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.ject.support.domain.apply.domain.Apply.Status.JOINED; -import static org.ject.support.domain.apply.domain.Apply.Status.SUBMITTED; -import static org.ject.support.domain.apply.domain.Apply.Status.TEMP_SAVED; -import static org.ject.support.domain.member.JobFamily.BE; -import static org.ject.support.domain.member.JobFamily.PD; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; -import java.util.Optional; import org.ject.support.base.UnitTestSupport; import org.ject.support.common.util.Map2JsonSerializer; import org.ject.support.common.util.String2MapSerializer; @@ -45,10 +27,31 @@ import org.ject.support.domain.recruit.domain.Semester; import org.ject.support.domain.recruit.exception.QuestionException; import org.ject.support.domain.recruit.repository.RecruitRepository; +import org.ject.support.external.n8n.event.ApplicationSubmittedEvent; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.springframework.context.ApplicationEventPublisher; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.ject.support.domain.apply.domain.Apply.Status.JOINED; +import static org.ject.support.domain.apply.domain.Apply.Status.SUBMITTED; +import static org.ject.support.domain.apply.domain.Apply.Status.TEMP_SAVED; +import static org.ject.support.domain.member.JobFamily.BE; +import static org.ject.support.domain.member.JobFamily.PD; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; class ApplyServiceTest extends UnitTestSupport { @@ -73,6 +76,9 @@ class ApplyServiceTest extends UnitTestSupport { @Mock String2MapSerializer string2MapSerializer; + @Mock + ApplicationEventPublisher applicationEventPublisher; + @Test void 지원서_제출_성공() { // given @@ -113,6 +119,10 @@ class ApplyServiceTest extends UnitTestSupport { // 3. apply의 상태가 SUBMITTED로 변경되었는지 확인 assertThat(apply.getStatus()).isEqualTo(SUBMITTED); + + // 4. AdminLoginNotificationEvent 이벤트가 발행되었는지 확인 + verify(applicationEventPublisher) + .publishEvent(any(ApplicationSubmittedEvent.class)); } @Test @@ -164,6 +174,8 @@ class ApplyServiceTest extends UnitTestSupport { // then assertThat(apply.getStatus()).isEqualTo(SUBMITTED); assertThat(applicationForm.getPortfolios()).hasSize(1); + verify(applicationEventPublisher) + .publishEvent(any(ApplicationSubmittedEvent.class)); } @Test diff --git a/src/test/java/org/ject/support/domain/jectalk/repository/JectalkQueryRepositoryTest.java b/src/test/java/org/ject/support/domain/jectalk/repository/JectalkQueryRepositoryTest.java index 2b5ec0ed..30b1671a 100644 --- a/src/test/java/org/ject/support/domain/jectalk/repository/JectalkQueryRepositoryTest.java +++ b/src/test/java/org/ject/support/domain/jectalk/repository/JectalkQueryRepositoryTest.java @@ -3,6 +3,7 @@ import java.util.List; import org.ject.support.domain.jectalk.dto.JectalkResponse; import org.ject.support.domain.jectalk.entity.Jectalk; +import org.ject.support.domain.project.entity.Project; import org.ject.support.testconfig.IntegrationTest; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -30,7 +31,7 @@ void findJectalks() { jectalkRepository.saveAll(List.of(jectalk1, jectalk2, jectalk3)); // when - Page result = jectalkRepository.findJectalks(PageRequest.of(0, 2)); + Page result = jectalkRepository.findJectalks (PageRequest.of(0, 2), null); // then assertThat(result).isNotNull(); @@ -43,30 +44,88 @@ void findJectalks() { assertThat(responses).hasSize(2); // 현재 페이지의 데이터 개수 responses.forEach(jectalkResponse -> { assertThat(jectalkResponse.id()).isNotNull(); - assertThat(jectalkResponse.name()).isNotNull(); - assertThat(jectalkResponse.imageUrl()).isNotNull(); - assertThat(jectalkResponse.youtubeUrl()).isNotNull(); + assertThat(jectalkResponse.title()).isNotNull(); + assertThat(jectalkResponse.thumbnailUrl()).isNotNull(); + assertThat(jectalkResponse.contentUrl()).isNotNull(); + assertThat(jectalkResponse.description()).isNotNull(); + assertThat(jectalkResponse.contentType()).isNotNull(); assertThat(jectalkResponse.summary()).isNotNull(); }); - JectalkResponse firstResponse = responses.get(0); - assertThat(firstResponse.name()).isEqualTo("젝톡 3"); // ID 내림차순이므로 마지막에 생성된 데이터가 첫 번째 - assertThat(firstResponse.summary()).isEqualTo("summary"); - assertThat(firstResponse.youtubeUrl()).isEqualTo("https://youtube.com/jectalk3"); - assertThat(firstResponse.imageUrl()).isEqualTo("https://image.com/jectalk3.png"); + assertThat(firstResponse.title()).isEqualTo("젝톡 3"); // ID 내림차순이므로 마지막에 생성된 데이터가 첫 번째 + assertThat(firstResponse.description()).isEqualTo("description"); + assertThat(firstResponse.contentUrl()).isEqualTo("https://youtube.com/jectalk3"); + assertThat(firstResponse.thumbnailUrl()).isEqualTo("https://image.com/jectalk3.png"); + assertThat(firstResponse.summary()).isEqualTo("author"); + // 두 번째 페이지 조회 - Page secondPage = jectalkRepository.findJectalks(PageRequest.of(1, 2)); + Page secondPage = jectalkRepository.findJectalks(PageRequest.of(1, 2), null); assertThat(secondPage.getContent()).hasSize(1); // 마지막 페이지는 1개의 데이터만 존재 } + @Test + @DisplayName("젝톡 목록 조회 - 카테고리 필터링") + void findJectalksByCategory() { + // given + Jectalk jectalk1 = createJectalkWithCategory("젝톡 1기-1", Project.Category.SEMESTER_1); + Jectalk jectalk2 = createJectalkWithCategory("젝톡 1기-2", Project.Category.SEMESTER_1); + Jectalk jectalk3 = createJectalkWithCategory("젝톡 2기-1", Project.Category.SEMESTER_2); + Jectalk jectalk4 = createJectalkWithCategory("젝톡 2기-2", Project.Category.SEMESTER_2); + jectalkRepository.saveAll(List.of(jectalk1, jectalk2, jectalk3, jectalk4)); + + // when - 1기 조회 + Page result1 = jectalkRepository.findJectalks(PageRequest.of(0, 10), Project.Category.SEMESTER_1); + + // then + assertThat(result1.getTotalElements()).isEqualTo(2); + + // when - 2기 조회 + Page result2 = jectalkRepository.findJectalks(PageRequest.of(0, 10), Project.Category.SEMESTER_2); + + // then + assertThat(result2.getTotalElements()).isEqualTo(2); + } + + @Test + @DisplayName("젝톡 목록 조회 - 카테고리 미지정 시 전체 조회") + void findJectalksWithoutCategory() { + // given + Jectalk jectalk1 = createJectalkWithCategory("젝톡 1기", Project.Category.SEMESTER_1); + Jectalk jectalk2 = createJectalkWithCategory("젝톡 2기", Project.Category.SEMESTER_2); + Jectalk jectalk3 = createJectalkWithCategory("젝톡 3기", Project.Category.SEMESTER_3); + jectalkRepository.saveAll(List.of(jectalk1, jectalk2, jectalk3)); + + // when - category=null로 전체 조회 + Page result = jectalkRepository.findJectalks(PageRequest.of(0, 10), null); + + // then - 모든 기수의 젝톡이 조회됨 + assertThat(result.getTotalElements()).isEqualTo(3); + } + private Jectalk createJectalk(String name) { String urlSafeName = "jectalk" + name.replaceAll("[젝톡 ]", ""); return Jectalk.builder() - .name(name) - .summary("summary") - .youtubeUrl("https://youtube.com/" + urlSafeName) - .imageUrl("https://image.com/" + urlSafeName + ".png") + .title(name) + .description("description") + .contentType(org.ject.support.domain.jectalk.enums.ContentType.YOUTUBE) + .contentUrl("https://youtube.com/" + urlSafeName) + .thumbnailUrl("https://image.com/" + urlSafeName + ".png") + .author("author") + .category(Project.Category.SEMESTER_1) + .build(); + } + + private Jectalk createJectalkWithCategory(String name, Project.Category category) { + String urlSafeName = "jectalk" + name.replaceAll("[젝톡 기\\-]", ""); + return Jectalk.builder() + .title(name) + .description("description") + .contentType(org.ject.support.domain.jectalk.enums.ContentType.YOUTUBE) + .contentUrl("https://youtube.com/" + urlSafeName) + .thumbnailUrl("https://image.com/" + urlSafeName + ".png") + .author("author") + .category(category) .build(); } } diff --git a/src/test/java/org/ject/support/domain/ministudy/repository/MiniStudyQueryRepositoryTest.java b/src/test/java/org/ject/support/domain/ministudy/repository/MiniStudyQueryRepositoryTest.java index a55ba442..1d6c6093 100644 --- a/src/test/java/org/ject/support/domain/ministudy/repository/MiniStudyQueryRepositoryTest.java +++ b/src/test/java/org/ject/support/domain/ministudy/repository/MiniStudyQueryRepositoryTest.java @@ -47,6 +47,7 @@ void findMiniStudies() { assertThat(firstResponse.id()).isNotNull(); assertThat(firstResponse.name()).isEqualTo("미니 스터디 3"); // ID 내림차순이므로 마지막에 생성된 데이터가 첫 번째 assertThat(firstResponse.summary()).isEqualTo("summary"); + assertThat(firstResponse.tag()).isEqualTo("test-tag"); assertThat(firstResponse.linkUrl()).isEqualTo("https://test.net/ministudy3"); assertThat(firstResponse.imageUrl()).isEqualTo("https://test.net/image3.png"); @@ -60,6 +61,7 @@ private MiniStudy createMiniStudy(String name) { return MiniStudy.builder() .name(name) .summary("summary") + .tag("test-tag") .linkUrl("https://test.net/ministudy" + urlSafeName) .imageUrl("https://test.net/image" + urlSafeName + ".png") .build();