Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.itasocialacademy.oitassist.participation.dao.dto.event;

import lombok.Builder;

@Builder
public record CompetitionValidatedDataEvent(
String competitionTitle,
String stageTitle) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.itasocialacademy.oitassist.participation.dao.dto.event;

import com.itasocialacademy.oitassist.user.api.dto.UserProfileDetails;
import java.util.List;

public record InvitationRequestEvent(
String competitionTitle,
String stageTitle,
List<UserProfileDetails> users) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
import com.itasocialacademy.oitassist.core.properties.WebClientProperties;
import com.itasocialacademy.oitassist.core.service.interfaces.EmailService;
import com.itasocialacademy.oitassist.participation.dao.dto.event.ApplicationDecisionEvent;
import com.itasocialacademy.oitassist.participation.dao.dto.event.InvitationRequestEvent;
import com.itasocialacademy.oitassist.participation.dao.enums.RequestStatus;
import com.itasocialacademy.oitassist.user.api.dto.UserProfileDetails;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
Expand Down Expand Up @@ -44,6 +46,8 @@ public class AsyncEmailSender {
public void sendDecisionEmail(ApplicationDecisionEvent event) {
String email = event.email();
RequestStatus status = event.status();
String competitionLink = buildLink(COMPETITION_PATH);
String profileLink = buildLink(PROFILE_PATH);
log.info("Handling ApplicationDecisionEvent for email={}, status={}", email, status);

String template = switch (status) {
Expand All @@ -56,19 +60,8 @@ public void sendDecisionEmail(ApplicationDecisionEvent event) {
if (event.rejectionReason() != null && !event.rejectionReason().isBlank()) {
extraParams.put("rejectionReason", event.rejectionReason());
}
String profileLink = UriComponentsBuilder
.fromUriString(webClientProperties.origin())
.path(PROFILE_PATH)
.build()
.toUriString();
extraParams.put("profileLink", profileLink);
}

String competitionLink = UriComponentsBuilder
.fromUriString(webClientProperties.origin())
.path(COMPETITION_PATH)
.build()
.toUriString();
Map<String, String> root = new HashMap<>(Map.of(
"firstName", event.firstName(),
"competitionTitle", event.competitionTitle(),
Expand All @@ -82,4 +75,41 @@ public void sendDecisionEmail(ApplicationDecisionEvent event) {
"Статус заявки",
root);
}

/**
* Handles asynchronously an {@link InvitationRequestEvent} after the publishing
* transaction has committed. Builds the URLs and sends an invitation email for
* each student.
*
* @param event the event carrying the titles of competition and stage, the list
* of recipients with emails and first names
*/
@Async
public void sendInvitationEmail(InvitationRequestEvent event) {
log.info("Handling InvitationRequestEvent for {} users", event.users().size());

String competitionLink = buildLink(COMPETITION_PATH);
String profileLink = buildLink(PROFILE_PATH);
for (UserProfileDetails user : event.users()) {
Map<String, String> root = Map.of(
"firstName", user.firstName(),
"competitionTitle", event.competitionTitle(),
"stageTitle", event.stageTitle(),
"competitionLink", competitionLink,
"profileLink", profileLink);
emailService.sendTemplateEmail(
user.email(),
"invitation-request.html",
"Запрошення на олімпіаду",
root);
}
}

private String buildLink(String link) {
return UriComponentsBuilder
.fromUriString(webClientProperties.origin())
.path(link)
.build()
.toUriString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import com.itasocialacademy.oitassist.competition.exceptions.StageNotFoundException;
import com.itasocialacademy.oitassist.core.enums.ErrorCode;
import com.itasocialacademy.oitassist.core.exceptions.AuthorizationException;
import com.itasocialacademy.oitassist.participation.dao.dto.event.CompetitionValidatedDataEvent;
import com.itasocialacademy.oitassist.participation.dao.dto.event.InvitationRequestEvent;
import com.itasocialacademy.oitassist.participation.dao.dto.response.FailedInvitationResponse;
import com.itasocialacademy.oitassist.participation.dao.dto.response.SucceededInvitationResponse;
import com.itasocialacademy.oitassist.participation.dao.model.Participation;
Expand All @@ -24,9 +26,11 @@
import com.itasocialacademy.oitassist.participation.exceptions.*;
import com.itasocialacademy.oitassist.participation.mapper.ParticipationMapper;
import com.itasocialacademy.oitassist.participation.mapper.interfaces.ProcessInvitationMapper;
import com.itasocialacademy.oitassist.participation.sender.AsyncEmailSender;
import com.itasocialacademy.oitassist.participation.service.interfaces.InvitationService;
import com.itasocialacademy.oitassist.security.api.interfaces.SecurityFacade;
import com.itasocialacademy.oitassist.user.api.dto.UserAuthDetails;
import com.itasocialacademy.oitassist.user.api.dto.UserProfileDetails;
import com.itasocialacademy.oitassist.user.api.interfaces.UserFacade;
import com.itasocialacademy.oitassist.user.dao.enums.Role;
import jakarta.transaction.Transactional;
Expand All @@ -52,10 +56,13 @@ public class InvitationServiceImpl implements InvitationService {
private final ParticipationMapper participationMapper;
private final ProcessInvitationMapper processInvitationMapper;
private final CompetitionFacade competitionFacade;
private final AsyncEmailSender sender;

@Override
public CreateInvitationResponse sendEnrollmentRequest(CreateInvitationRequest request) {
validateCompetitionAndStageInfo(request.getCompetitionId(), request.getStageId());
Long competitionId = request.getCompetitionId();
Long stageId = request.getStageId();
CompetitionValidatedDataEvent competitionData = validateCompetitionAndStageInfo(competitionId, stageId);
List<Long> studentIds = validateNoDuplicatesOrThrow(request.getStudentIds());

List<UserAuthDetails> foundUsers = userFacade.findByIds(studentIds);
Expand Down Expand Up @@ -89,14 +96,20 @@ public CreateInvitationResponse sendEnrollmentRequest(CreateInvitationRequest re
}
}
}
return CreateInvitationResponse.builder()
.competitionId(request.getCompetitionId())
.stageId(request.getStageId())
CreateInvitationResponse response = CreateInvitationResponse.builder()
.competitionId(competitionId)
.stageId(stageId)
.succeeded(succeeded)
.failed(failed)
.issuedBy(getCurrentUserIdOrThrow())
.issuedAt(Instant.now())
.build();

if (!succeeded.isEmpty()) {
scheduleInvitationEmail(competitionData, succeeded);
}

return response;
}

@Override
Expand Down Expand Up @@ -162,17 +175,19 @@ private List<Long> validateNoDuplicatesOrThrow(List<Long> rawIds) {
return rawIds;
}

private void validateCompetitionAndStageInfo(Long competitionId, Long stageId) {
CompetitionDetail competitionDetail = competitionFacade.findCompetitionById(competitionId)
.orElseThrow(() -> new CompetitionNotFoundException(competitionId));
StageDetail stageDetail = competitionFacade.findStageById(stageId)
.orElseThrow(() -> new StageNotFoundException(stageId));
private CompetitionValidatedDataEvent validateCompetitionAndStageInfo(Long competitionId, Long stageId) {
CompetitionDetail competitionDetail = getCompetitionInfoOrThrow(competitionId);
StageDetail stageDetail = getStageInfoOrThrow(stageId);
if (competitionDetail.competitionStatus() != CompetitionStatus.ENROLLMENT) {
throw new UserInvitationRequestException("The competition cannot be enrolled");
}
if (!stageDetail.competitionId().equals(competitionId)) {
throw new CompetitionHierarchyValidationException("Specified stage does not belong to this competition");
}
return CompetitionValidatedDataEvent.builder()
.competitionTitle(competitionDetail.title())
.stageTitle(stageDetail.title())
.build();
}

private Invitation getPendingInvitationOrThrow(Long applicationId) {
Expand Down Expand Up @@ -225,4 +240,29 @@ private boolean isPendingInvitationConstraintViolation(DataIntegrityViolationExc
}
return false;
}

private void scheduleInvitationEmail(
CompetitionValidatedDataEvent competitionData,
List<SucceededInvitationResponse> succeeded) {
List<UserProfileDetails> users = getUserProfiles(succeeded);
InvitationRequestEvent event = new InvitationRequestEvent(
competitionData.competitionTitle(), competitionData.stageTitle(), users);
sender.sendInvitationEmail(event);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private CompetitionDetail getCompetitionInfoOrThrow(Long competitionId) {
return competitionFacade.findCompetitionById(competitionId)
.orElseThrow(() -> new CompetitionNotFoundException(competitionId));
}

private StageDetail getStageInfoOrThrow(Long stageId) {
return competitionFacade.findStageById(stageId)
.orElseThrow(() -> new StageNotFoundException(stageId));
}

private List<UserProfileDetails> getUserProfiles(List<SucceededInvitationResponse> succeeded) {
List<Long> userIds = succeeded.stream()
.map(SucceededInvitationResponse::studentId).toList();
return userFacade.findProfilesByIds(userIds);
}
}
2 changes: 1 addition & 1 deletion src/main/resources/templates/application-accepted.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8">
<title>Сповіщення про підтверджену заявку</title>
<style>
<#include "fragments/application-status-style.ftl">
<#include "fragments/participation-template-style.ftl">
</style>
</head>
<body>
Expand Down
2 changes: 1 addition & 1 deletion src/main/resources/templates/application-rejected.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8">
<title>Сповіщення про відхилену заявку</title>
<style>
<#include "fragments/application-status-style.ftl">
<#include "fragments/participation-template-style.ftl">
</style>
</head>
<body>
Expand Down
29 changes: 29 additions & 0 deletions src/main/resources/templates/invitation-request.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="uk">
<head>
<meta charset="UTF-8">
<title>Сповіщення про підтверджену заявку</title>
<style>
<#include "fragments/participation-template-style.ftl">
</style>
</head>
<body>
<div class="container">
<h1>Привіт, ${firstName}!</h1>
<p>Вас було запрошено для участі в олімпіаді <strong>"${competitionTitle}"</strong>,
етапі <strong>"${stageTitle}"</strong>!🎉<br>
Вам потрібно підтвердити вашу участь в особистому кабінеті.
Щоб перейти в кабінет, натисніть на кнопку нижче:</p>
<a href="${profileLink}" class="button">Особистий кабінет ↗</a>
<p>Або можете переглянути деталі олімпіади:</p>
<a href="${competitionLink}" class="button">Переглянути деталі ↗</a>
<h3>Бажаємо успіхів!</h3>

<p>Якщо вам цей лист було надіслано помилково — проігноруйте його.</p>

<div class="footer">
© 2026 OIT Assist. Усі права захищені.
</div>
</div>
</body>
</html>
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import com.itasocialacademy.oitassist.competition.exceptions.CompetitionNotFoundException;
import com.itasocialacademy.oitassist.competition.exceptions.StageNotFoundException;
import com.itasocialacademy.oitassist.core.exceptions.AuthorizationException;
import com.itasocialacademy.oitassist.core.service.interfaces.EmailService;
import com.itasocialacademy.oitassist.participation.dao.dto.event.InvitationRequestEvent;
import com.itasocialacademy.oitassist.participation.dao.dto.request.CreateInvitationRequest;
import com.itasocialacademy.oitassist.participation.dao.dto.request.RejectEnrollmentRequest;
import com.itasocialacademy.oitassist.participation.dao.dto.response.CreateInvitationResponse;
Expand All @@ -25,14 +27,17 @@
import com.itasocialacademy.oitassist.participation.mapper.ParticipationMapper;
import com.itasocialacademy.oitassist.participation.mapper.interfaces.ProcessInvitationMapper;
import com.itasocialacademy.oitassist.participation.saver.InvitationRequestsSaver;
import com.itasocialacademy.oitassist.participation.sender.AsyncEmailSender;
import com.itasocialacademy.oitassist.security.api.interfaces.SecurityFacade;
import com.itasocialacademy.oitassist.user.api.dto.UserAuthDetails;
import com.itasocialacademy.oitassist.user.api.dto.UserProfileDetails;
import com.itasocialacademy.oitassist.user.api.interfaces.UserFacade;
import com.itasocialacademy.oitassist.user.dao.enums.Role;
import org.hibernate.exception.ConstraintViolationException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
Expand All @@ -46,7 +51,7 @@
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
public class InvitationServiceTest {
class InvitationServiceTest {
@Mock
private ParticipationRepository participationRepository;
@Mock
Expand All @@ -63,6 +68,10 @@ public class InvitationServiceTest {
private ProcessInvitationMapper processInvitationMapper;
@Mock
private CompetitionFacade competitionFacade;
@Mock
private AsyncEmailSender sender;
@Mock
private EmailService emailService;

@InjectMocks
private InvitationServiceImpl invitationService;
Expand Down Expand Up @@ -91,11 +100,13 @@ void setUp() {

competitionDetail = CompetitionDetail.builder()
.id(2L)
.title("Olympiad")
.competitionStatus(CompetitionStatus.ENROLLMENT)
.build();

stageDetail = StageDetail.builder()
.id(3L)
.title("First stage")
.competitionId(2L)
.scope(StageScope.DISTRICT)
.build();
Expand Down Expand Up @@ -498,4 +509,54 @@ private static ProcessInvitationResponse getProcessInvitationResponse(RequestSta
.status(status)
.build();
}

// ---- emailSending part ----

@Test
void sendEnrollmentRequest_withSucceededInvitations_shouldScheduleInvitationEmail() {
when(securityFacade.getCurrentUserId()).thenReturn(Optional.of(4L));
when(invitationRepository.findByStudentIdInAndCompetitionIdAndStageIdAndStatus(
List.of(10L), 2L, 3L, RequestStatus.PENDING)).thenReturn(List.of());
when(participationRepository.findAllByUserIdInAndCompetitionIdAndStageId(List.of(10L), 2L, 3L))
.thenReturn(List.of());
when(invitationRequestsSaver.saveSingleInvitation(10L, createInvitationRequest)).thenReturn(invitation);
when(userFacade.findByIds(List.of(10L))).thenReturn(List.of(validUser));
when(competitionFacade.findCompetitionById(2L)).thenReturn(Optional.of(competitionDetail));
when(competitionFacade.findStageById(3L)).thenReturn(Optional.of(stageDetail));
when(userFacade.findProfilesByIds(List.of(10L))).thenReturn(
List.of(new UserProfileDetails(10L, "Test", "test@mail.com")));
when(invitationRequestsSaver.saveSingleInvitation(10L, createInvitationRequest)).thenReturn(invitation);
invitationService.sendEnrollmentRequest(createInvitationRequest);

ArgumentCaptor<InvitationRequestEvent> eventCaptor = ArgumentCaptor.forClass(InvitationRequestEvent.class);
verify(sender).sendInvitationEmail(eventCaptor.capture());

InvitationRequestEvent event = eventCaptor.getValue();
assertEquals("Olympiad", event.competitionTitle());
assertEquals("First stage", event.stageTitle());
assertEquals(1, event.users().size());
assertEquals("test@mail.com", event.users().getFirst().email());
}

@Test
void sendEnrollmentRequest_whenAllStudentsFail_shouldNotScheduleInvitationEmail() {
UserAuthDetails orgUser = new UserAuthDetails(10L, "email1@mail.com", "password1", Role.ORG);
when(securityFacade.getCurrentUserId()).thenReturn(Optional.of(4L));
when(competitionFacade.findCompetitionById(2L)).thenReturn(Optional.of(competitionDetail));
when(competitionFacade.findStageById(3L)).thenReturn(Optional.of(stageDetail));
when(userFacade.findByIds(List.of(10L))).thenReturn(List.of(orgUser));

invitationService.sendEnrollmentRequest(createInvitationRequest);

verify(sender, never()).sendInvitationEmail(any());
}

@Test
void sendInvitationEmail_noRecipients_shouldSendNothing() {
InvitationRequestEvent event = new InvitationRequestEvent("Olympiad", "District Stage", List.of());

sender.sendInvitationEmail(event);

verifyNoInteractions(emailService);
Comment thread
serggalel marked this conversation as resolved.
}
}
Loading