Skip to content

Commit 88e4818

Browse files
authored
feat: n8n에 지원서 제출 이벤트 발행 기능 추가 (#415)
1 parent 4ffa70b commit 88e4818

7 files changed

Lines changed: 187 additions & 19 deletions

File tree

src/main/java/org/ject/support/domain/apply/service/ApplyService.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,15 @@
2727
import org.ject.support.domain.recruit.exception.RecruitErrorCode;
2828
import org.ject.support.domain.recruit.exception.RecruitException;
2929
import org.ject.support.domain.recruit.repository.RecruitRepository;
30+
import org.ject.support.external.n8n.event.ApplicationSubmittedEvent;
31+
import org.springframework.context.ApplicationEventPublisher;
3032
import org.springframework.dao.DataIntegrityViolationException;
3133
import org.springframework.stereotype.Service;
3234
import org.springframework.transaction.annotation.Transactional;
3335

3436
import java.time.LocalDateTime;
3537
import java.util.List;
3638
import java.util.Map;
37-
import java.util.Optional;
3839

3940
import static org.ject.support.domain.apply.domain.Apply.Status.JOINED;
4041
import static org.ject.support.domain.apply.domain.Apply.Status.SUBMITTED;
@@ -53,6 +54,8 @@ public class ApplyService implements ApplyUsecase {
5354
private final Map2JsonSerializer map2JsonSerializer;
5455
private final String2MapSerializer string2MapSerializer;
5556

57+
private final ApplicationEventPublisher applicationEventPublisher;
58+
5659
@Override
5760
@PeriodAccessible(permitAllJob = true)
5861
@Transactional(readOnly = true)
@@ -167,6 +170,10 @@ public void submitApplication(Long memberId,
167170

168171
// 5. Apply 엔티티에 제출 위임 (검증 및 상태 변경 포함)
169172
apply.submit(applicationForm);
173+
174+
// 6. n8n에 지원 완료 이벤트 발행
175+
applicationEventPublisher
176+
.publishEvent(new ApplicationSubmittedEvent(apply.getId()));
170177
}
171178

172179
@Override
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package org.ject.support.external.n8n.client;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.ject.support.domain.admin.dto.SubmittedApplyDetailResponse;
6+
import org.springframework.beans.factory.annotation.Value;
7+
import org.springframework.http.ResponseEntity;
8+
import org.springframework.stereotype.Component;
9+
import org.springframework.web.reactive.function.client.WebClient;
10+
11+
@Slf4j
12+
@Component
13+
@RequiredArgsConstructor
14+
public class N8nClient {
15+
16+
private final WebClient webClient;
17+
18+
@Value("${n8n.secret-key}")
19+
private String secretKey;
20+
21+
@Value("${n8n.webhook.application-submit}")
22+
private String applicationSubmitWebhook;
23+
24+
public void send(SubmittedApplyDetailResponse payload) {
25+
webClient.post()
26+
.uri(applicationSubmitWebhook)
27+
.header("Apply-Webhook-Secret", secretKey)
28+
.bodyValue(payload)
29+
.retrieve()
30+
.toBodilessEntity()
31+
.doOnSuccess(this::logSuccess)
32+
.doOnError(this::logError)
33+
.block();
34+
}
35+
36+
private void logSuccess(ResponseEntity<Void> response) {
37+
log.info("N8n message sent successfully (status={})", response.getStatusCode());
38+
}
39+
40+
private void logError(Throwable e) {
41+
log.error(
42+
"N8n message send failed apply: {} - {}",
43+
e.getClass().getSimpleName(),
44+
e.getMessage()
45+
);
46+
}
47+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package org.ject.support.external.n8n.event;
2+
3+
public record ApplicationSubmittedEvent(
4+
Long applyId
5+
) {}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package org.ject.support.external.n8n.listener;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import org.ject.support.external.n8n.event.ApplicationSubmittedEvent;
5+
import org.ject.support.external.n8n.service.N8nApplyService;
6+
import org.springframework.scheduling.annotation.Async;
7+
import org.springframework.stereotype.Component;
8+
import org.springframework.transaction.event.TransactionPhase;
9+
import org.springframework.transaction.event.TransactionalEventListener;
10+
11+
/**
12+
* Admin Page가 생기기 전 까지 n8n으로 지원서 제출 알림을 보냄
13+
* - Admin Page가 생기면 해당 기능은 제거될 예정
14+
*/
15+
@Component
16+
@RequiredArgsConstructor
17+
public class N8nApplicationSubmittedListener {
18+
19+
private final N8nApplyService n8nApplyService;
20+
21+
@Async
22+
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
23+
public void handle(ApplicationSubmittedEvent event) {
24+
n8nApplyService.sendToN8n(event.applyId());
25+
}
26+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package org.ject.support.external.n8n.service;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.ject.support.common.util.String2MapSerializer;
6+
import org.ject.support.domain.admin.dto.SubmittedApplyDetailResponse;
7+
import org.ject.support.domain.apply.domain.ApplicationForm;
8+
import org.ject.support.domain.apply.domain.Apply;
9+
import org.ject.support.domain.apply.dto.ApplyPortfolioDto;
10+
import org.ject.support.domain.apply.exception.ApplyErrorCode;
11+
import org.ject.support.domain.apply.exception.ApplyException;
12+
import org.ject.support.domain.apply.repository.ApplyRepository;
13+
import org.ject.support.external.n8n.client.N8nClient;
14+
import org.springframework.stereotype.Service;
15+
import org.springframework.transaction.annotation.Transactional;
16+
17+
import java.util.List;
18+
import java.util.Map;
19+
import java.util.Optional;
20+
21+
@Slf4j
22+
@Service
23+
@RequiredArgsConstructor
24+
public class N8nApplyService {
25+
26+
private final ApplyRepository applyRepository;
27+
private final String2MapSerializer string2MapSerializer;
28+
private final N8nClient n8nClient;
29+
30+
@Transactional(readOnly = true)
31+
public void sendToN8n(final Long applyId) {
32+
// SubmittedApplyDetailResponse n8n 전송용으로 임시 재사용
33+
SubmittedApplyDetailResponse response = applyRepository.findByIdAndStatusWithMember(applyId, Apply.Status.SUBMITTED)
34+
.map(this::toSubmittedApplyDetailResponse)
35+
.orElseThrow(() -> new ApplyException(ApplyErrorCode.NOT_FOUND_APPLY));
36+
try {
37+
n8nClient.send(response);
38+
} catch (Exception e) {
39+
log.error("Failed to send apply {} to n8n", applyId, e);
40+
}
41+
42+
}
43+
44+
private SubmittedApplyDetailResponse toSubmittedApplyDetailResponse(final Apply apply) {
45+
ApplicationForm submittedApplicationForm = apply.getApplicationForm();
46+
Map<String, String> content = extractContent(submittedApplicationForm);
47+
List<ApplyPortfolioDto> portfolios = extractPortfolios(submittedApplicationForm);
48+
return SubmittedApplyDetailResponse.from(apply, content, portfolios);
49+
}
50+
51+
private Map<String, String> extractContent(final ApplicationForm applicationForm) {
52+
return Optional.ofNullable(applicationForm)
53+
.map(ApplicationForm::getContent)
54+
.map(string2MapSerializer::serializeAsMap)
55+
.orElse(Map.of());
56+
}
57+
58+
private List<ApplyPortfolioDto> extractPortfolios(final ApplicationForm applicationForm) {
59+
return Optional.ofNullable(applicationForm)
60+
.map(ApplicationForm::getPortfolios)
61+
.orElse(List.of())
62+
.stream()
63+
.map(ApplyPortfolioDto::from)
64+
.toList();
65+
}
66+
}

src/main/resources/application-test.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,8 @@ notification:
6969
webhook:
7070
admin-login: dummy-url-for-test
7171
supporter-token-issue: dummy-url-for-test
72+
73+
n8n:
74+
secret-key: n8n-test-secret-key
75+
webhook:
76+
application-submit: http://localhost:5678/webhook/application-submit

src/test/java/org/ject/support/domain/apply/service/ApplyServiceTest.java

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,5 @@
11
package org.ject.support.domain.apply.service;
22

3-
import static org.assertj.core.api.Assertions.assertThat;
4-
import static org.assertj.core.api.Assertions.assertThatThrownBy;
5-
import static org.ject.support.domain.apply.domain.Apply.Status.JOINED;
6-
import static org.ject.support.domain.apply.domain.Apply.Status.SUBMITTED;
7-
import static org.ject.support.domain.apply.domain.Apply.Status.TEMP_SAVED;
8-
import static org.ject.support.domain.member.JobFamily.BE;
9-
import static org.ject.support.domain.member.JobFamily.PD;
10-
import static org.mockito.ArgumentMatchers.any;
11-
import static org.mockito.ArgumentMatchers.eq;
12-
import static org.mockito.BDDMockito.given;
13-
import static org.mockito.Mockito.never;
14-
import static org.mockito.Mockito.verify;
15-
import static org.mockito.Mockito.when;
16-
17-
import java.time.LocalDateTime;
18-
import java.util.List;
19-
import java.util.Map;
20-
import java.util.Optional;
213
import org.ject.support.base.UnitTestSupport;
224
import org.ject.support.common.util.Map2JsonSerializer;
235
import org.ject.support.common.util.String2MapSerializer;
@@ -45,10 +27,31 @@
4527
import org.ject.support.domain.recruit.domain.Semester;
4628
import org.ject.support.domain.recruit.exception.QuestionException;
4729
import org.ject.support.domain.recruit.repository.RecruitRepository;
30+
import org.ject.support.external.n8n.event.ApplicationSubmittedEvent;
4831
import org.junit.jupiter.api.Test;
4932
import org.mockito.ArgumentCaptor;
5033
import org.mockito.InjectMocks;
5134
import org.mockito.Mock;
35+
import org.springframework.context.ApplicationEventPublisher;
36+
37+
import java.time.LocalDateTime;
38+
import java.util.List;
39+
import java.util.Map;
40+
import java.util.Optional;
41+
42+
import static org.assertj.core.api.Assertions.assertThat;
43+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
44+
import static org.ject.support.domain.apply.domain.Apply.Status.JOINED;
45+
import static org.ject.support.domain.apply.domain.Apply.Status.SUBMITTED;
46+
import static org.ject.support.domain.apply.domain.Apply.Status.TEMP_SAVED;
47+
import static org.ject.support.domain.member.JobFamily.BE;
48+
import static org.ject.support.domain.member.JobFamily.PD;
49+
import static org.mockito.ArgumentMatchers.any;
50+
import static org.mockito.ArgumentMatchers.eq;
51+
import static org.mockito.BDDMockito.given;
52+
import static org.mockito.Mockito.never;
53+
import static org.mockito.Mockito.verify;
54+
import static org.mockito.Mockito.when;
5255

5356
class ApplyServiceTest extends UnitTestSupport {
5457

@@ -73,6 +76,9 @@ class ApplyServiceTest extends UnitTestSupport {
7376
@Mock
7477
String2MapSerializer string2MapSerializer;
7578

79+
@Mock
80+
ApplicationEventPublisher applicationEventPublisher;
81+
7682
@Test
7783
void 지원서_제출_성공() {
7884
// given
@@ -113,6 +119,10 @@ class ApplyServiceTest extends UnitTestSupport {
113119

114120
// 3. apply의 상태가 SUBMITTED로 변경되었는지 확인
115121
assertThat(apply.getStatus()).isEqualTo(SUBMITTED);
122+
123+
// 4. AdminLoginNotificationEvent 이벤트가 발행되었는지 확인
124+
verify(applicationEventPublisher)
125+
.publishEvent(any(ApplicationSubmittedEvent.class));
116126
}
117127

118128
@Test
@@ -164,6 +174,8 @@ class ApplyServiceTest extends UnitTestSupport {
164174
// then
165175
assertThat(apply.getStatus()).isEqualTo(SUBMITTED);
166176
assertThat(applicationForm.getPortfolios()).hasSize(1);
177+
verify(applicationEventPublisher)
178+
.publishEvent(any(ApplicationSubmittedEvent.class));
167179
}
168180

169181
@Test

0 commit comments

Comments
 (0)