Skip to content

Commit 7be8232

Browse files
authored
[Feature] integrate email to invitation requests (#537)
1 parent 44d848d commit 7be8232

9 files changed

Lines changed: 202 additions & 23 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.itasocialacademy.oitassist.participation.dao.dto.event;
2+
3+
import lombok.Builder;
4+
5+
@Builder
6+
public record CompetitionValidatedDataEvent(
7+
String competitionTitle,
8+
String stageTitle) {
9+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.itasocialacademy.oitassist.participation.dao.dto.event;
2+
3+
import com.itasocialacademy.oitassist.user.api.dto.UserProfileDetails;
4+
import java.util.List;
5+
6+
public record InvitationRequestEvent(
7+
String competitionTitle,
8+
String stageTitle,
9+
List<UserProfileDetails> users) {
10+
}

src/main/java/com/itasocialacademy/oitassist/participation/sender/AsyncEmailSender.java

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
import com.itasocialacademy.oitassist.core.properties.WebClientProperties;
44
import com.itasocialacademy.oitassist.core.service.interfaces.EmailService;
55
import com.itasocialacademy.oitassist.participation.dao.dto.event.ApplicationDecisionEvent;
6+
import com.itasocialacademy.oitassist.participation.dao.dto.event.InvitationRequestEvent;
67
import com.itasocialacademy.oitassist.participation.dao.enums.RequestStatus;
8+
import com.itasocialacademy.oitassist.user.api.dto.UserProfileDetails;
79
import lombok.RequiredArgsConstructor;
810
import lombok.extern.slf4j.Slf4j;
911
import org.springframework.scheduling.annotation.Async;
@@ -44,6 +46,8 @@ public class AsyncEmailSender {
4446
public void sendDecisionEmail(ApplicationDecisionEvent event) {
4547
String email = event.email();
4648
RequestStatus status = event.status();
49+
String competitionLink = buildLink(COMPETITION_PATH);
50+
String profileLink = buildLink(PROFILE_PATH);
4751
log.info("Handling ApplicationDecisionEvent for email={}, status={}", email, status);
4852

4953
String template = switch (status) {
@@ -56,19 +60,8 @@ public void sendDecisionEmail(ApplicationDecisionEvent event) {
5660
if (event.rejectionReason() != null && !event.rejectionReason().isBlank()) {
5761
extraParams.put("rejectionReason", event.rejectionReason());
5862
}
59-
String profileLink = UriComponentsBuilder
60-
.fromUriString(webClientProperties.origin())
61-
.path(PROFILE_PATH)
62-
.build()
63-
.toUriString();
6463
extraParams.put("profileLink", profileLink);
6564
}
66-
67-
String competitionLink = UriComponentsBuilder
68-
.fromUriString(webClientProperties.origin())
69-
.path(COMPETITION_PATH)
70-
.build()
71-
.toUriString();
7265
Map<String, String> root = new HashMap<>(Map.of(
7366
"firstName", event.firstName(),
7467
"competitionTitle", event.competitionTitle(),
@@ -82,4 +75,41 @@ public void sendDecisionEmail(ApplicationDecisionEvent event) {
8275
"Статус заявки",
8376
root);
8477
}
78+
79+
/**
80+
* Handles asynchronously an {@link InvitationRequestEvent} after the publishing
81+
* transaction has committed. Builds the URLs and sends an invitation email for
82+
* each student.
83+
*
84+
* @param event the event carrying the titles of competition and stage, the list
85+
* of recipients with emails and first names
86+
*/
87+
@Async
88+
public void sendInvitationEmail(InvitationRequestEvent event) {
89+
log.info("Handling InvitationRequestEvent for {} users", event.users().size());
90+
91+
String competitionLink = buildLink(COMPETITION_PATH);
92+
String profileLink = buildLink(PROFILE_PATH);
93+
for (UserProfileDetails user : event.users()) {
94+
Map<String, String> root = Map.of(
95+
"firstName", user.firstName(),
96+
"competitionTitle", event.competitionTitle(),
97+
"stageTitle", event.stageTitle(),
98+
"competitionLink", competitionLink,
99+
"profileLink", profileLink);
100+
emailService.sendTemplateEmail(
101+
user.email(),
102+
"invitation-request.html",
103+
"Запрошення на олімпіаду",
104+
root);
105+
}
106+
}
107+
108+
private String buildLink(String link) {
109+
return UriComponentsBuilder
110+
.fromUriString(webClientProperties.origin())
111+
.path(link)
112+
.build()
113+
.toUriString();
114+
}
85115
}

src/main/java/com/itasocialacademy/oitassist/participation/service/InvitationServiceImpl.java

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import com.itasocialacademy.oitassist.competition.exceptions.StageNotFoundException;
1010
import com.itasocialacademy.oitassist.core.enums.ErrorCode;
1111
import com.itasocialacademy.oitassist.core.exceptions.AuthorizationException;
12+
import com.itasocialacademy.oitassist.participation.dao.dto.event.CompetitionValidatedDataEvent;
13+
import com.itasocialacademy.oitassist.participation.dao.dto.event.InvitationRequestEvent;
1214
import com.itasocialacademy.oitassist.participation.dao.dto.response.FailedInvitationResponse;
1315
import com.itasocialacademy.oitassist.participation.dao.dto.response.SucceededInvitationResponse;
1416
import com.itasocialacademy.oitassist.participation.dao.model.Participation;
@@ -24,9 +26,11 @@
2426
import com.itasocialacademy.oitassist.participation.exceptions.*;
2527
import com.itasocialacademy.oitassist.participation.mapper.ParticipationMapper;
2628
import com.itasocialacademy.oitassist.participation.mapper.interfaces.ProcessInvitationMapper;
29+
import com.itasocialacademy.oitassist.participation.sender.AsyncEmailSender;
2730
import com.itasocialacademy.oitassist.participation.service.interfaces.InvitationService;
2831
import com.itasocialacademy.oitassist.security.api.interfaces.SecurityFacade;
2932
import com.itasocialacademy.oitassist.user.api.dto.UserAuthDetails;
33+
import com.itasocialacademy.oitassist.user.api.dto.UserProfileDetails;
3034
import com.itasocialacademy.oitassist.user.api.interfaces.UserFacade;
3135
import com.itasocialacademy.oitassist.user.dao.enums.Role;
3236
import jakarta.transaction.Transactional;
@@ -52,10 +56,13 @@ public class InvitationServiceImpl implements InvitationService {
5256
private final ParticipationMapper participationMapper;
5357
private final ProcessInvitationMapper processInvitationMapper;
5458
private final CompetitionFacade competitionFacade;
59+
private final AsyncEmailSender sender;
5560

5661
@Override
5762
public CreateInvitationResponse sendEnrollmentRequest(CreateInvitationRequest request) {
58-
validateCompetitionAndStageInfo(request.getCompetitionId(), request.getStageId());
63+
Long competitionId = request.getCompetitionId();
64+
Long stageId = request.getStageId();
65+
CompetitionValidatedDataEvent competitionData = validateCompetitionAndStageInfo(competitionId, stageId);
5966
List<Long> studentIds = validateNoDuplicatesOrThrow(request.getStudentIds());
6067

6168
List<UserAuthDetails> foundUsers = userFacade.findByIds(studentIds);
@@ -89,14 +96,20 @@ public CreateInvitationResponse sendEnrollmentRequest(CreateInvitationRequest re
8996
}
9097
}
9198
}
92-
return CreateInvitationResponse.builder()
93-
.competitionId(request.getCompetitionId())
94-
.stageId(request.getStageId())
99+
CreateInvitationResponse response = CreateInvitationResponse.builder()
100+
.competitionId(competitionId)
101+
.stageId(stageId)
95102
.succeeded(succeeded)
96103
.failed(failed)
97104
.issuedBy(getCurrentUserIdOrThrow())
98105
.issuedAt(Instant.now())
99106
.build();
107+
108+
if (!succeeded.isEmpty()) {
109+
scheduleInvitationEmail(competitionData, succeeded);
110+
}
111+
112+
return response;
100113
}
101114

102115
@Override
@@ -162,17 +175,19 @@ private List<Long> validateNoDuplicatesOrThrow(List<Long> rawIds) {
162175
return rawIds;
163176
}
164177

165-
private void validateCompetitionAndStageInfo(Long competitionId, Long stageId) {
166-
CompetitionDetail competitionDetail = competitionFacade.findCompetitionById(competitionId)
167-
.orElseThrow(() -> new CompetitionNotFoundException(competitionId));
168-
StageDetail stageDetail = competitionFacade.findStageById(stageId)
169-
.orElseThrow(() -> new StageNotFoundException(stageId));
178+
private CompetitionValidatedDataEvent validateCompetitionAndStageInfo(Long competitionId, Long stageId) {
179+
CompetitionDetail competitionDetail = getCompetitionInfoOrThrow(competitionId);
180+
StageDetail stageDetail = getStageInfoOrThrow(stageId);
170181
if (competitionDetail.competitionStatus() != CompetitionStatus.ENROLLMENT) {
171182
throw new UserInvitationRequestException("The competition cannot be enrolled");
172183
}
173184
if (!stageDetail.competitionId().equals(competitionId)) {
174185
throw new CompetitionHierarchyValidationException("Specified stage does not belong to this competition");
175186
}
187+
return CompetitionValidatedDataEvent.builder()
188+
.competitionTitle(competitionDetail.title())
189+
.stageTitle(stageDetail.title())
190+
.build();
176191
}
177192

178193
private Invitation getPendingInvitationOrThrow(Long applicationId) {
@@ -225,4 +240,29 @@ private boolean isPendingInvitationConstraintViolation(DataIntegrityViolationExc
225240
}
226241
return false;
227242
}
243+
244+
private void scheduleInvitationEmail(
245+
CompetitionValidatedDataEvent competitionData,
246+
List<SucceededInvitationResponse> succeeded) {
247+
List<UserProfileDetails> users = getUserProfiles(succeeded);
248+
InvitationRequestEvent event = new InvitationRequestEvent(
249+
competitionData.competitionTitle(), competitionData.stageTitle(), users);
250+
sender.sendInvitationEmail(event);
251+
}
252+
253+
private CompetitionDetail getCompetitionInfoOrThrow(Long competitionId) {
254+
return competitionFacade.findCompetitionById(competitionId)
255+
.orElseThrow(() -> new CompetitionNotFoundException(competitionId));
256+
}
257+
258+
private StageDetail getStageInfoOrThrow(Long stageId) {
259+
return competitionFacade.findStageById(stageId)
260+
.orElseThrow(() -> new StageNotFoundException(stageId));
261+
}
262+
263+
private List<UserProfileDetails> getUserProfiles(List<SucceededInvitationResponse> succeeded) {
264+
List<Long> userIds = succeeded.stream()
265+
.map(SucceededInvitationResponse::studentId).toList();
266+
return userFacade.findProfilesByIds(userIds);
267+
}
228268
}

src/main/resources/templates/application-accepted.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<meta charset="UTF-8">
55
<title>Сповіщення про підтверджену заявку</title>
66
<style>
7-
<#include "fragments/application-status-style.ftl">
7+
<#include "fragments/participation-template-style.ftl">
88
</style>
99
</head>
1010
<body>

src/main/resources/templates/application-rejected.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<meta charset="UTF-8">
55
<title>Сповіщення про відхилену заявку</title>
66
<style>
7-
<#include "fragments/application-status-style.ftl">
7+
<#include "fragments/participation-template-style.ftl">
88
</style>
99
</head>
1010
<body>

src/main/resources/templates/fragments/application-status-style.ftl renamed to src/main/resources/templates/fragments/participation-template-style.ftl

File renamed without changes.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<!DOCTYPE html>
2+
<html lang="uk">
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>Сповіщення про підтверджену заявку</title>
6+
<style>
7+
<#include "fragments/participation-template-style.ftl">
8+
</style>
9+
</head>
10+
<body>
11+
<div class="container">
12+
<h1>Привіт, ${firstName}!</h1>
13+
<p>Вас було запрошено для участі в олімпіаді <strong>"${competitionTitle}"</strong>,
14+
етапі <strong>"${stageTitle}"</strong>!🎉<br>
15+
Вам потрібно підтвердити вашу участь в особистому кабінеті.
16+
Щоб перейти в кабінет, натисніть на кнопку нижче:</p>
17+
<a href="${profileLink}" class="button">Особистий кабінет ↗</a>
18+
<p>Або можете переглянути деталі олімпіади:</p>
19+
<a href="${competitionLink}" class="button">Переглянути деталі ↗</a>
20+
<h3>Бажаємо успіхів!</h3>
21+
22+
<p>Якщо вам цей лист було надіслано помилково — проігноруйте його.</p>
23+
24+
<div class="footer">
25+
© 2026 OIT Assist. Усі права захищені.
26+
</div>
27+
</div>
28+
</body>
29+
</html>

src/test/java/com/itasocialacademy/oitassist/participation/service/InvitationServiceTest.java

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import com.itasocialacademy.oitassist.competition.exceptions.CompetitionNotFoundException;
1010
import com.itasocialacademy.oitassist.competition.exceptions.StageNotFoundException;
1111
import com.itasocialacademy.oitassist.core.exceptions.AuthorizationException;
12+
import com.itasocialacademy.oitassist.core.service.interfaces.EmailService;
13+
import com.itasocialacademy.oitassist.participation.dao.dto.event.InvitationRequestEvent;
1214
import com.itasocialacademy.oitassist.participation.dao.dto.request.CreateInvitationRequest;
1315
import com.itasocialacademy.oitassist.participation.dao.dto.request.RejectEnrollmentRequest;
1416
import com.itasocialacademy.oitassist.participation.dao.dto.response.CreateInvitationResponse;
@@ -25,14 +27,17 @@
2527
import com.itasocialacademy.oitassist.participation.mapper.ParticipationMapper;
2628
import com.itasocialacademy.oitassist.participation.mapper.interfaces.ProcessInvitationMapper;
2729
import com.itasocialacademy.oitassist.participation.saver.InvitationRequestsSaver;
30+
import com.itasocialacademy.oitassist.participation.sender.AsyncEmailSender;
2831
import com.itasocialacademy.oitassist.security.api.interfaces.SecurityFacade;
2932
import com.itasocialacademy.oitassist.user.api.dto.UserAuthDetails;
33+
import com.itasocialacademy.oitassist.user.api.dto.UserProfileDetails;
3034
import com.itasocialacademy.oitassist.user.api.interfaces.UserFacade;
3135
import com.itasocialacademy.oitassist.user.dao.enums.Role;
3236
import org.hibernate.exception.ConstraintViolationException;
3337
import org.junit.jupiter.api.BeforeEach;
3438
import org.junit.jupiter.api.Test;
3539
import org.junit.jupiter.api.extension.ExtendWith;
40+
import org.mockito.ArgumentCaptor;
3641
import org.mockito.InjectMocks;
3742
import org.mockito.Mock;
3843
import org.mockito.junit.jupiter.MockitoExtension;
@@ -46,7 +51,7 @@
4651
import static org.mockito.Mockito.*;
4752

4853
@ExtendWith(MockitoExtension.class)
49-
public class InvitationServiceTest {
54+
class InvitationServiceTest {
5055
@Mock
5156
private ParticipationRepository participationRepository;
5257
@Mock
@@ -63,6 +68,10 @@ public class InvitationServiceTest {
6368
private ProcessInvitationMapper processInvitationMapper;
6469
@Mock
6570
private CompetitionFacade competitionFacade;
71+
@Mock
72+
private AsyncEmailSender sender;
73+
@Mock
74+
private EmailService emailService;
6675

6776
@InjectMocks
6877
private InvitationServiceImpl invitationService;
@@ -91,11 +100,13 @@ void setUp() {
91100

92101
competitionDetail = CompetitionDetail.builder()
93102
.id(2L)
103+
.title("Olympiad")
94104
.competitionStatus(CompetitionStatus.ENROLLMENT)
95105
.build();
96106

97107
stageDetail = StageDetail.builder()
98108
.id(3L)
109+
.title("First stage")
99110
.competitionId(2L)
100111
.scope(StageScope.DISTRICT)
101112
.build();
@@ -498,4 +509,54 @@ private static ProcessInvitationResponse getProcessInvitationResponse(RequestSta
498509
.status(status)
499510
.build();
500511
}
512+
513+
// ---- emailSending part ----
514+
515+
@Test
516+
void sendEnrollmentRequest_withSucceededInvitations_shouldScheduleInvitationEmail() {
517+
when(securityFacade.getCurrentUserId()).thenReturn(Optional.of(4L));
518+
when(invitationRepository.findByStudentIdInAndCompetitionIdAndStageIdAndStatus(
519+
List.of(10L), 2L, 3L, RequestStatus.PENDING)).thenReturn(List.of());
520+
when(participationRepository.findAllByUserIdInAndCompetitionIdAndStageId(List.of(10L), 2L, 3L))
521+
.thenReturn(List.of());
522+
when(invitationRequestsSaver.saveSingleInvitation(10L, createInvitationRequest)).thenReturn(invitation);
523+
when(userFacade.findByIds(List.of(10L))).thenReturn(List.of(validUser));
524+
when(competitionFacade.findCompetitionById(2L)).thenReturn(Optional.of(competitionDetail));
525+
when(competitionFacade.findStageById(3L)).thenReturn(Optional.of(stageDetail));
526+
when(userFacade.findProfilesByIds(List.of(10L))).thenReturn(
527+
List.of(new UserProfileDetails(10L, "Test", "test@mail.com")));
528+
when(invitationRequestsSaver.saveSingleInvitation(10L, createInvitationRequest)).thenReturn(invitation);
529+
invitationService.sendEnrollmentRequest(createInvitationRequest);
530+
531+
ArgumentCaptor<InvitationRequestEvent> eventCaptor = ArgumentCaptor.forClass(InvitationRequestEvent.class);
532+
verify(sender).sendInvitationEmail(eventCaptor.capture());
533+
534+
InvitationRequestEvent event = eventCaptor.getValue();
535+
assertEquals("Olympiad", event.competitionTitle());
536+
assertEquals("First stage", event.stageTitle());
537+
assertEquals(1, event.users().size());
538+
assertEquals("test@mail.com", event.users().getFirst().email());
539+
}
540+
541+
@Test
542+
void sendEnrollmentRequest_whenAllStudentsFail_shouldNotScheduleInvitationEmail() {
543+
UserAuthDetails orgUser = new UserAuthDetails(10L, "email1@mail.com", "password1", Role.ORG);
544+
when(securityFacade.getCurrentUserId()).thenReturn(Optional.of(4L));
545+
when(competitionFacade.findCompetitionById(2L)).thenReturn(Optional.of(competitionDetail));
546+
when(competitionFacade.findStageById(3L)).thenReturn(Optional.of(stageDetail));
547+
when(userFacade.findByIds(List.of(10L))).thenReturn(List.of(orgUser));
548+
549+
invitationService.sendEnrollmentRequest(createInvitationRequest);
550+
551+
verify(sender, never()).sendInvitationEmail(any());
552+
}
553+
554+
@Test
555+
void sendInvitationEmail_noRecipients_shouldSendNothing() {
556+
InvitationRequestEvent event = new InvitationRequestEvent("Olympiad", "District Stage", List.of());
557+
558+
sender.sendInvitationEmail(event);
559+
560+
verifyNoInteractions(emailService);
561+
}
501562
}

0 commit comments

Comments
 (0)