From 6d3a1709087705fb2c77c484e29bd7db26c3780d Mon Sep 17 00:00:00 2001 From: f-s-h Date: Tue, 30 Jun 2026 14:44:01 +0200 Subject: [PATCH 01/10] Implemented basic structure --- .../{ => controller}/HelloController.java | 2 +- .../controller/LetterController.java | 45 +++++ .../letterservice/service/LetterService.java | 22 +++ .../{ => controller}/HelloControllerTest.java | 2 +- .../controller/LetterControllerTest.java | 185 ++++++++++++++++++ 5 files changed, 254 insertions(+), 2 deletions(-) rename services/spring-letter/src/main/java/tum/devoops/letterservice/{ => controller}/HelloController.java (86%) create mode 100644 services/spring-letter/src/main/java/tum/devoops/letterservice/controller/LetterController.java create mode 100644 services/spring-letter/src/main/java/tum/devoops/letterservice/service/LetterService.java rename services/spring-letter/src/test/java/tum/devoops/letterservice/{ => controller}/HelloControllerTest.java (97%) create mode 100644 services/spring-letter/src/test/java/tum/devoops/letterservice/controller/LetterControllerTest.java diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/HelloController.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/controller/HelloController.java similarity index 86% rename from services/spring-letter/src/main/java/tum/devoops/letterservice/HelloController.java rename to services/spring-letter/src/main/java/tum/devoops/letterservice/controller/HelloController.java index 3214701..5393d80 100644 --- a/services/spring-letter/src/main/java/tum/devoops/letterservice/HelloController.java +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/controller/HelloController.java @@ -1,4 +1,4 @@ -package tum.devoops.letterservice; +package tum.devoops.letterservice.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/controller/LetterController.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/controller/LetterController.java new file mode 100644 index 0000000..247759e --- /dev/null +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/controller/LetterController.java @@ -0,0 +1,45 @@ +package tum.devoops.letterservice.controller; + +import jakarta.validation.Valid; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import tum.devoops.letterservice.api.LettersApi; +import tum.devoops.letterservice.model.MailRequest; +import tum.devoops.letterservice.model.PdfRequest; +import tum.devoops.letterservice.service.LetterService; + +@RestController +@PreAuthorize("hasAnyRole('admin', 'director', 'trainer')") +public class LetterController implements LettersApi { + + private final LetterService letterService; + + public LetterController(LetterService letterService) { + this.letterService = letterService; + } + + @Override + @PostMapping(value = "/letters/mail", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity sendMail(@Valid @RequestBody MailRequest mailRequest) { + letterService.sendMail(mailRequest); + return ResponseEntity.noContent().build(); + } + + @Override + @PostMapping( + value = "/letters/pdf", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = { "application/pdf", MediaType.APPLICATION_JSON_VALUE } + ) + public ResponseEntity getPdf(@Valid @RequestBody PdfRequest pdfRequest) { + Resource pdf = letterService.getPdf(pdfRequest); + return ResponseEntity.status(HttpStatus.OK).body(pdf); + } +} diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/service/LetterService.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/service/LetterService.java new file mode 100644 index 0000000..212aba4 --- /dev/null +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/service/LetterService.java @@ -0,0 +1,22 @@ +package tum.devoops.letterservice.service; + +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Service; + +import tum.devoops.letterservice.model.MailRequest; +import tum.devoops.letterservice.model.PdfRequest; + +@Service +public class LetterService { + + private static final byte[] DUMMY_PDF = "%PDF-1.4 dummy".getBytes(); + + public void sendMail(MailRequest mailRequest) { + // stub: no-op + } + + public Resource getPdf(PdfRequest pdfRequest) { + return new ByteArrayResource(DUMMY_PDF); + } +} diff --git a/services/spring-letter/src/test/java/tum/devoops/letterservice/HelloControllerTest.java b/services/spring-letter/src/test/java/tum/devoops/letterservice/controller/HelloControllerTest.java similarity index 97% rename from services/spring-letter/src/test/java/tum/devoops/letterservice/HelloControllerTest.java rename to services/spring-letter/src/test/java/tum/devoops/letterservice/controller/HelloControllerTest.java index 50da366..11c6a44 100644 --- a/services/spring-letter/src/test/java/tum/devoops/letterservice/HelloControllerTest.java +++ b/services/spring-letter/src/test/java/tum/devoops/letterservice/controller/HelloControllerTest.java @@ -1,4 +1,4 @@ -package tum.devoops.letterservice; +package tum.devoops.letterservice.controller; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; diff --git a/services/spring-letter/src/test/java/tum/devoops/letterservice/controller/LetterControllerTest.java b/services/spring-letter/src/test/java/tum/devoops/letterservice/controller/LetterControllerTest.java new file mode 100644 index 0000000..2a32fd1 --- /dev/null +++ b/services/spring-letter/src/test/java/tum/devoops/letterservice/controller/LetterControllerTest.java @@ -0,0 +1,185 @@ +package tum.devoops.letterservice.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.MediaType; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import tum.devoops.letterservice.config.SecurityConfig; +import tum.devoops.letterservice.model.MailRequest; +import tum.devoops.letterservice.model.PdfRequest; +import tum.devoops.letterservice.service.LetterService; + +@WebMvcTest(LetterController.class) +@Import(SecurityConfig.class) +class LetterControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private LetterService letterService; + + private static final String MAIL_JSON = """ + {"subject":"Hello","template":"

Hi {{first_name}}

"} + """; + + private static final String PDF_JSON = """ + {"template":"

Hi {{first_name}}

"} + """; + + private static final ByteArrayResource DUMMY_PDF = + new ByteArrayResource("%PDF-1.4 dummy".getBytes()); + + // --- sendMail --- + + @Test + void sendMailWithAdminReturns204() throws Exception { + mockMvc.perform(post("/letters/mail") + .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_admin"))) + .contentType(MediaType.APPLICATION_JSON) + .content(MAIL_JSON)) + .andExpect(status().isNoContent()); + } + + @Test + void sendMailWithDirectorReturns204() throws Exception { + mockMvc.perform(post("/letters/mail") + .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_director"))) + .contentType(MediaType.APPLICATION_JSON) + .content(MAIL_JSON)) + .andExpect(status().isNoContent()); + } + + @Test + void sendMailWithTrainerReturns204() throws Exception { + mockMvc.perform(post("/letters/mail") + .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_trainer"))) + .contentType(MediaType.APPLICATION_JSON) + .content(MAIL_JSON)) + .andExpect(status().isNoContent()); + } + + @Test + void sendMailDelegatesToService() throws Exception { + mockMvc.perform(post("/letters/mail") + .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_admin"))) + .contentType(MediaType.APPLICATION_JSON) + .content(MAIL_JSON)) + .andExpect(status().isNoContent()); + + verify(letterService).sendMail(any(MailRequest.class)); + } + + @Test + void sendMailWithTraineeReturns403() throws Exception { + mockMvc.perform(post("/letters/mail") + .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_trainee"))) + .contentType(MediaType.APPLICATION_JSON) + .content(MAIL_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + void sendMailWithMemberReturns403() throws Exception { + mockMvc.perform(post("/letters/mail") + .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_member"))) + .contentType(MediaType.APPLICATION_JSON) + .content(MAIL_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + void sendMailWithoutAuthReturns401() throws Exception { + mockMvc.perform(post("/letters/mail") + .contentType(MediaType.APPLICATION_JSON) + .content(MAIL_JSON)) + .andExpect(status().isUnauthorized()); + } + + // --- getPdf --- + + @Test + void getPdfWithAdminReturns200() throws Exception { + when(letterService.getPdf(any())).thenReturn(DUMMY_PDF); + + mockMvc.perform(post("/letters/pdf") + .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_admin"))) + .contentType(MediaType.APPLICATION_JSON) + .content(PDF_JSON)) + .andExpect(status().isOk()); + } + + @Test + void getPdfWithDirectorReturns200() throws Exception { + when(letterService.getPdf(any())).thenReturn(DUMMY_PDF); + + mockMvc.perform(post("/letters/pdf") + .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_director"))) + .contentType(MediaType.APPLICATION_JSON) + .content(PDF_JSON)) + .andExpect(status().isOk()); + } + + @Test + void getPdfWithTrainerReturns200() throws Exception { + when(letterService.getPdf(any())).thenReturn(DUMMY_PDF); + + mockMvc.perform(post("/letters/pdf") + .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_trainer"))) + .contentType(MediaType.APPLICATION_JSON) + .content(PDF_JSON)) + .andExpect(status().isOk()); + } + + @Test + void getPdfDelegatesToService() throws Exception { + when(letterService.getPdf(any())).thenReturn(DUMMY_PDF); + + mockMvc.perform(post("/letters/pdf") + .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_admin"))) + .contentType(MediaType.APPLICATION_JSON) + .content(PDF_JSON)) + .andExpect(status().isOk()); + + verify(letterService).getPdf(any(PdfRequest.class)); + } + + @Test + void getPdfWithTraineeReturns403() throws Exception { + mockMvc.perform(post("/letters/pdf") + .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_trainee"))) + .contentType(MediaType.APPLICATION_JSON) + .content(PDF_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + void getPdfWithMemberReturns403() throws Exception { + mockMvc.perform(post("/letters/pdf") + .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_member"))) + .contentType(MediaType.APPLICATION_JSON) + .content(PDF_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + void getPdfWithoutAuthReturns401() throws Exception { + mockMvc.perform(post("/letters/pdf") + .contentType(MediaType.APPLICATION_JSON) + .content(PDF_JSON)) + .andExpect(status().isUnauthorized()); + } +} From 4712fdf9fb20310eed052ba081ad9e18c407699c Mon Sep 17 00:00:00 2001 From: f-s-h Date: Tue, 30 Jun 2026 14:50:36 +0200 Subject: [PATCH 02/10] Fix --- .../letterservice/config/SecurityConfig.java | 2 +- .../letterservice/controller/LetterController.java | 14 ++------------ 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/config/SecurityConfig.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/config/SecurityConfig.java index b4a032a..425dcd5 100644 --- a/services/spring-letter/src/main/java/tum/devoops/letterservice/config/SecurityConfig.java +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/config/SecurityConfig.java @@ -17,7 +17,7 @@ @Configuration @EnableWebSecurity -@EnableMethodSecurity +@EnableMethodSecurity(proxyTargetClass = true) public class SecurityConfig { @Bean diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/controller/LetterController.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/controller/LetterController.java index 247759e..5021d30 100644 --- a/services/spring-letter/src/main/java/tum/devoops/letterservice/controller/LetterController.java +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/controller/LetterController.java @@ -1,13 +1,9 @@ package tum.devoops.letterservice.controller; -import jakarta.validation.Valid; import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import tum.devoops.letterservice.api.LettersApi; @@ -26,19 +22,13 @@ public LetterController(LetterService letterService) { } @Override - @PostMapping(value = "/letters/mail", consumes = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity sendMail(@Valid @RequestBody MailRequest mailRequest) { + public ResponseEntity sendMail(MailRequest mailRequest) { letterService.sendMail(mailRequest); return ResponseEntity.noContent().build(); } @Override - @PostMapping( - value = "/letters/pdf", - consumes = MediaType.APPLICATION_JSON_VALUE, - produces = { "application/pdf", MediaType.APPLICATION_JSON_VALUE } - ) - public ResponseEntity getPdf(@Valid @RequestBody PdfRequest pdfRequest) { + public ResponseEntity getPdf(PdfRequest pdfRequest) { Resource pdf = letterService.getPdf(pdfRequest); return ResponseEntity.status(HttpStatus.OK).body(pdf); } From da4cc8e9efdb14121590cc074136992c575aefe2 Mon Sep 17 00:00:00 2001 From: f-s-h Date: Tue, 30 Jun 2026 15:36:45 +0200 Subject: [PATCH 03/10] Basic letter service --- services/spring-letter/build.gradle | 1 + .../letterservice/service/LetterService.java | 37 ++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/services/spring-letter/build.gradle b/services/spring-letter/build.gradle index 5726a04..0be3a6f 100644 --- a/services/spring-letter/build.gradle +++ b/services/spring-letter/build.gradle @@ -48,6 +48,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.0' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/service/LetterService.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/service/LetterService.java index 212aba4..f967a19 100644 --- a/services/spring-letter/src/main/java/tum/devoops/letterservice/service/LetterService.java +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/service/LetterService.java @@ -1,7 +1,13 @@ package tum.devoops.letterservice.service; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.stereotype.Service; import tum.devoops.letterservice.model.MailRequest; @@ -12,8 +18,37 @@ public class LetterService { private static final byte[] DUMMY_PDF = "%PDF-1.4 dummy".getBytes(); + private JavaMailSender mailSender; + private final String from; + + public LetterService(JavaMailSender mailSender, + @Value("${spring.mail.username}") String from) { + this.mailSender = mailSender; + this.from = from; + } + + public void sendText(String to, String subject, String body) { + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(from); + message.setTo(to); + message.setSubject(subject); + message.setText(body); + mailSender.send(message); + } + + /** HTML email. */ + public void sendHtml(String to, String subject, String html) throws MessagingException { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + helper.setFrom(from); + helper.setTo(to); + helper.setSubject(subject); + helper.setText(html, true); // true = treat body as HTML + mailSender.send(message); + } + public void sendMail(MailRequest mailRequest) { - // stub: no-op + sendText("fabianheinrich02@gmail.com", "Erster Testdurchlauf", "Burschen Heraus!"); } public Resource getPdf(PdfRequest pdfRequest) { From f28c74a4f265f6fed8e3163cc31c8476f519450d Mon Sep 17 00:00:00 2001 From: f-s-h Date: Tue, 30 Jun 2026 15:45:32 +0200 Subject: [PATCH 04/10] Added properties for mail --- .../src/main/resources/application.properties | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/services/spring-letter/src/main/resources/application.properties b/services/spring-letter/src/main/resources/application.properties index a374c20..d9c92a6 100644 --- a/services/spring-letter/src/main/resources/application.properties +++ b/services/spring-letter/src/main/resources/application.properties @@ -5,3 +5,10 @@ spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://keycloak:8080/auth/ spring.flyway.enabled=false spring.jpa.hibernate.ddl-auto=none + +spring.mail.host=smtp.gmail.com +spring.mail.port=587 +spring.mail.username=${MAIL_USERNAME} +spring.mail.password=${MAIL_PASSWORD} +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true From 84ea00b83ebb5a9b238d75d12f7c72736c24b4de Mon Sep 17 00:00:00 2001 From: f-s-h Date: Tue, 30 Jun 2026 17:26:16 +0200 Subject: [PATCH 05/10] Added env file --- .gitignore | 1 + infra/docker-compose.yml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index e38da20..4d5e121 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ settings.json +**/.env diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 28b6bd3..34fdc9f 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -170,6 +170,8 @@ services: build: ../services/spring-letter container_name: letter-service restart: on-failure + env_file: + - ../services/spring-letter/.env expose: - 8080 depends_on: From 541f8edaf56dc92807475b414fc9b2c1b399c684 Mon Sep 17 00:00:00 2001 From: f-s-h Date: Wed, 1 Jul 2026 09:14:32 +0200 Subject: [PATCH 06/10] Implemented Mail Handling --- .../letterservice/entity/DirectorEntity.java | 40 +++ .../letterservice/entity/MemberEntity.java | 47 +++ .../letterservice/entity/SportEntity.java | 27 ++ .../letterservice/entity/TeamEntity.java | 30 ++ .../letterservice/entity/TraineeEntity.java | 40 +++ .../letterservice/entity/TrainerEntity.java | 40 +++ .../entity/TransactionEntity.java | 31 ++ .../exception/MailDeliveryException.java | 8 + .../repository/DirectorRepository.java | 19 + .../repository/MemberRepository.java | 10 + .../repository/SportRepository.java | 10 + .../repository/TeamRepository.java | 13 + .../repository/TraineeRepository.java | 19 + .../repository/TrainerRepository.java | 19 + .../repository/TransactionRepository.java | 13 + .../letterservice/service/LetterService.java | 181 +++++++++- .../LetterServiceApplicationTests.java | 17 + .../service/LetterServiceTest.java | 338 ++++++++++++++++++ 18 files changed, 885 insertions(+), 17 deletions(-) create mode 100644 services/spring-letter/src/main/java/tum/devoops/letterservice/entity/DirectorEntity.java create mode 100644 services/spring-letter/src/main/java/tum/devoops/letterservice/entity/MemberEntity.java create mode 100644 services/spring-letter/src/main/java/tum/devoops/letterservice/entity/SportEntity.java create mode 100644 services/spring-letter/src/main/java/tum/devoops/letterservice/entity/TeamEntity.java create mode 100644 services/spring-letter/src/main/java/tum/devoops/letterservice/entity/TraineeEntity.java create mode 100644 services/spring-letter/src/main/java/tum/devoops/letterservice/entity/TrainerEntity.java create mode 100644 services/spring-letter/src/main/java/tum/devoops/letterservice/entity/TransactionEntity.java create mode 100644 services/spring-letter/src/main/java/tum/devoops/letterservice/exception/MailDeliveryException.java create mode 100644 services/spring-letter/src/main/java/tum/devoops/letterservice/repository/DirectorRepository.java create mode 100644 services/spring-letter/src/main/java/tum/devoops/letterservice/repository/MemberRepository.java create mode 100644 services/spring-letter/src/main/java/tum/devoops/letterservice/repository/SportRepository.java create mode 100644 services/spring-letter/src/main/java/tum/devoops/letterservice/repository/TeamRepository.java create mode 100644 services/spring-letter/src/main/java/tum/devoops/letterservice/repository/TraineeRepository.java create mode 100644 services/spring-letter/src/main/java/tum/devoops/letterservice/repository/TrainerRepository.java create mode 100644 services/spring-letter/src/main/java/tum/devoops/letterservice/repository/TransactionRepository.java create mode 100644 services/spring-letter/src/test/java/tum/devoops/letterservice/service/LetterServiceTest.java diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/entity/DirectorEntity.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/entity/DirectorEntity.java new file mode 100644 index 0000000..c03534e --- /dev/null +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/entity/DirectorEntity.java @@ -0,0 +1,40 @@ +package tum.devoops.letterservice.entity; + +import java.io.Serializable; +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * Read-only shadow of {@code organization.directors}, owned by the organization service. + */ +@Entity +@Table(schema = "organization", name = "directors") +@Getter +@NoArgsConstructor +public class DirectorEntity { + + // Composite PK: (sport_id, member_id). + @EmbeddedId + private Id id; + + @Embeddable + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class Id implements Serializable { + @Column(name = "sport_id", nullable = false) + private UUID sportId; + + @Column(name = "member_id", nullable = false) + private UUID memberId; + } +} diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/entity/MemberEntity.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/entity/MemberEntity.java new file mode 100644 index 0000000..d4a866e --- /dev/null +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/entity/MemberEntity.java @@ -0,0 +1,47 @@ +package tum.devoops.letterservice.entity; + +import java.time.LocalDate; +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * Read-only shadow of {@code member.members}, owned by the member service. Used to resolve + * receiver data for personalized letters; this service never writes to it. + */ +@Entity +@Table(schema = "member", name = "members") +@Getter +@NoArgsConstructor +public class MemberEntity { + + @Id + @Column(name = "id", nullable = false, updatable = false) + private UUID id; + + @Column(name = "first_name", insertable = false, updatable = false) + private String firstName; + + @Column(name = "last_name", insertable = false, updatable = false) + private String lastName; + + @Column(name = "email", insertable = false, updatable = false) + private String email; + + @Column(name = "address", insertable = false, updatable = false) + private String address; + + @Column(name = "phone_number", insertable = false, updatable = false) + private String phoneNumber; + + @Column(name = "birthday", insertable = false, updatable = false) + private LocalDate birthday; + + @Column(name = "joining_date", insertable = false, updatable = false) + private LocalDate joiningDate; +} diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/entity/SportEntity.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/entity/SportEntity.java new file mode 100644 index 0000000..52c05ff --- /dev/null +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/entity/SportEntity.java @@ -0,0 +1,27 @@ +package tum.devoops.letterservice.entity; + +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * Read-only shadow of {@code organization.sports}, owned by the organization service. + */ +@Entity +@Table(schema = "organization", name = "sports") +@Getter +@NoArgsConstructor +public class SportEntity { + + @Id + @Column(name = "id", nullable = false, updatable = false) + private UUID id; + + @Column(name = "name", insertable = false, updatable = false) + private String name; +} diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/entity/TeamEntity.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/entity/TeamEntity.java new file mode 100644 index 0000000..826f70f --- /dev/null +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/entity/TeamEntity.java @@ -0,0 +1,30 @@ +package tum.devoops.letterservice.entity; + +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * Read-only shadow of {@code organization.teams}, owned by the organization service. + */ +@Entity +@Table(schema = "organization", name = "teams") +@Getter +@NoArgsConstructor +public class TeamEntity { + + @Id + @Column(name = "id", nullable = false, updatable = false) + private UUID id; + + @Column(name = "name", insertable = false, updatable = false) + private String name; + + @Column(name = "sport_id", insertable = false, updatable = false) + private UUID sportId; +} diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/entity/TraineeEntity.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/entity/TraineeEntity.java new file mode 100644 index 0000000..6552932 --- /dev/null +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/entity/TraineeEntity.java @@ -0,0 +1,40 @@ +package tum.devoops.letterservice.entity; + +import java.io.Serializable; +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * Read-only shadow of {@code organization.trainees}, owned by the organization service. + */ +@Entity +@Table(schema = "organization", name = "trainees") +@Getter +@NoArgsConstructor +public class TraineeEntity { + + // Composite PK: (team_id, member_id). + @EmbeddedId + private Id id; + + @Embeddable + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class Id implements Serializable { + @Column(name = "team_id", nullable = false) + private UUID teamId; + + @Column(name = "member_id", nullable = false) + private UUID memberId; + } +} diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/entity/TrainerEntity.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/entity/TrainerEntity.java new file mode 100644 index 0000000..a8b3bd1 --- /dev/null +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/entity/TrainerEntity.java @@ -0,0 +1,40 @@ +package tum.devoops.letterservice.entity; + +import java.io.Serializable; +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * Read-only shadow of {@code organization.trainers}, owned by the organization service. + */ +@Entity +@Table(schema = "organization", name = "trainers") +@Getter +@NoArgsConstructor +public class TrainerEntity { + + // Composite PK: (team_id, member_id). + @EmbeddedId + private Id id; + + @Embeddable + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class Id implements Serializable { + @Column(name = "team_id", nullable = false) + private UUID teamId; + + @Column(name = "member_id", nullable = false) + private UUID memberId; + } +} diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/entity/TransactionEntity.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/entity/TransactionEntity.java new file mode 100644 index 0000000..dc3c06b --- /dev/null +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/entity/TransactionEntity.java @@ -0,0 +1,31 @@ +package tum.devoops.letterservice.entity; + +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * Read-only shadow of {@code finance.transactions}, owned by the finance service. Used to + * compute a receiver's current balance for the {@code {{balance}}} placeholder. + */ +@Entity +@Table(schema = "finance", name = "transactions") +@Getter +@NoArgsConstructor +public class TransactionEntity { + + @Id + @Column(name = "id", nullable = false, updatable = false) + private UUID id; + + @Column(name = "member_id", insertable = false, updatable = false) + private UUID memberId; + + @Column(name = "amount_cents", insertable = false, updatable = false) + private int amountCents; +} diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/exception/MailDeliveryException.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/exception/MailDeliveryException.java new file mode 100644 index 0000000..420bc97 --- /dev/null +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/exception/MailDeliveryException.java @@ -0,0 +1,8 @@ +package tum.devoops.letterservice.exception; + +public class MailDeliveryException extends RuntimeException { + + public MailDeliveryException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/repository/DirectorRepository.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/repository/DirectorRepository.java new file mode 100644 index 0000000..a2d2036 --- /dev/null +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/repository/DirectorRepository.java @@ -0,0 +1,19 @@ +package tum.devoops.letterservice.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import tum.devoops.letterservice.entity.DirectorEntity; + +import java.util.List; +import java.util.UUID; + +public interface DirectorRepository extends JpaRepository { + + @Query("SELECT d.id.sportId FROM DirectorEntity d WHERE d.id.memberId = :memberId") + List findSportIdsByMemberId(@Param("memberId") UUID memberId); + + @Query("SELECT d.id.memberId FROM DirectorEntity d WHERE d.id.sportId = :sportId") + List findMemberIdsBySportId(@Param("sportId") UUID sportId); +} diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/repository/MemberRepository.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/repository/MemberRepository.java new file mode 100644 index 0000000..83a24be --- /dev/null +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/repository/MemberRepository.java @@ -0,0 +1,10 @@ +package tum.devoops.letterservice.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import tum.devoops.letterservice.entity.MemberEntity; + +import java.util.UUID; + +public interface MemberRepository extends JpaRepository { +} diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/repository/SportRepository.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/repository/SportRepository.java new file mode 100644 index 0000000..e78d7f6 --- /dev/null +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/repository/SportRepository.java @@ -0,0 +1,10 @@ +package tum.devoops.letterservice.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import tum.devoops.letterservice.entity.SportEntity; + +import java.util.UUID; + +public interface SportRepository extends JpaRepository { +} diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/repository/TeamRepository.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/repository/TeamRepository.java new file mode 100644 index 0000000..dd40275 --- /dev/null +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/repository/TeamRepository.java @@ -0,0 +1,13 @@ +package tum.devoops.letterservice.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import tum.devoops.letterservice.entity.TeamEntity; + +import java.util.List; +import java.util.UUID; + +public interface TeamRepository extends JpaRepository { + + List findAllBySportId(UUID sportId); +} diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/repository/TraineeRepository.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/repository/TraineeRepository.java new file mode 100644 index 0000000..3364ecd --- /dev/null +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/repository/TraineeRepository.java @@ -0,0 +1,19 @@ +package tum.devoops.letterservice.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import tum.devoops.letterservice.entity.TraineeEntity; + +import java.util.List; +import java.util.UUID; + +public interface TraineeRepository extends JpaRepository { + + @Query("SELECT t.id.teamId FROM TraineeEntity t WHERE t.id.memberId = :memberId") + List findTeamIdsByMemberId(@Param("memberId") UUID memberId); + + @Query("SELECT t.id.memberId FROM TraineeEntity t WHERE t.id.teamId = :teamId") + List findMemberIdsByTeamId(@Param("teamId") UUID teamId); +} diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/repository/TrainerRepository.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/repository/TrainerRepository.java new file mode 100644 index 0000000..4096c9f --- /dev/null +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/repository/TrainerRepository.java @@ -0,0 +1,19 @@ +package tum.devoops.letterservice.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import tum.devoops.letterservice.entity.TrainerEntity; + +import java.util.List; +import java.util.UUID; + +public interface TrainerRepository extends JpaRepository { + + @Query("SELECT t.id.teamId FROM TrainerEntity t WHERE t.id.memberId = :memberId") + List findTeamIdsByMemberId(@Param("memberId") UUID memberId); + + @Query("SELECT t.id.memberId FROM TrainerEntity t WHERE t.id.teamId = :teamId") + List findMemberIdsByTeamId(@Param("teamId") UUID teamId); +} diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/repository/TransactionRepository.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/repository/TransactionRepository.java new file mode 100644 index 0000000..886c42c --- /dev/null +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/repository/TransactionRepository.java @@ -0,0 +1,13 @@ +package tum.devoops.letterservice.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import tum.devoops.letterservice.entity.TransactionEntity; + +import java.util.List; +import java.util.UUID; + +public interface TransactionRepository extends JpaRepository { + + List findAllByMemberId(UUID memberId); +} diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/service/LetterService.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/service/LetterService.java index f967a19..542f953 100644 --- a/services/spring-letter/src/main/java/tum/devoops/letterservice/service/LetterService.java +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/service/LetterService.java @@ -5,53 +5,200 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; -import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.stereotype.Service; +import tum.devoops.letterservice.entity.MemberEntity; +import tum.devoops.letterservice.entity.TeamEntity; +import tum.devoops.letterservice.exception.MailDeliveryException; import tum.devoops.letterservice.model.MailRequest; import tum.devoops.letterservice.model.PdfRequest; +import tum.devoops.letterservice.repository.DirectorRepository; +import tum.devoops.letterservice.repository.MemberRepository; +import tum.devoops.letterservice.repository.SportRepository; +import tum.devoops.letterservice.repository.TeamRepository; +import tum.devoops.letterservice.repository.TraineeRepository; +import tum.devoops.letterservice.repository.TrainerRepository; +import tum.devoops.letterservice.repository.TransactionRepository; + +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; @Service public class LetterService { private static final byte[] DUMMY_PDF = "%PDF-1.4 dummy".getBytes(); - private JavaMailSender mailSender; + private static final Pattern TAG_PATTERN = Pattern.compile("\\{\\{\\s*([\\w.]+)\\s*\\}\\}"); + + private final JavaMailSender mailSender; private final String from; + private final MemberRepository memberRepository; + private final SportRepository sportRepository; + private final TeamRepository teamRepository; + private final DirectorRepository directorRepository; + private final TrainerRepository trainerRepository; + private final TraineeRepository traineeRepository; + private final TransactionRepository transactionRepository; public LetterService(JavaMailSender mailSender, - @Value("${spring.mail.username}") String from) { + @Value("${spring.mail.username}") String from, + MemberRepository memberRepository, + SportRepository sportRepository, + TeamRepository teamRepository, + DirectorRepository directorRepository, + TrainerRepository trainerRepository, + TraineeRepository traineeRepository, + TransactionRepository transactionRepository) { this.mailSender = mailSender; this.from = from; + this.memberRepository = memberRepository; + this.sportRepository = sportRepository; + this.teamRepository = teamRepository; + this.directorRepository = directorRepository; + this.trainerRepository = trainerRepository; + this.traineeRepository = traineeRepository; + this.transactionRepository = transactionRepository; } - public void sendText(String to, String subject, String body) { - SimpleMailMessage message = new SimpleMailMessage(); - message.setFrom(from); - message.setTo(to); - message.setSubject(subject); - message.setText(body); - mailSender.send(message); + public void sendMail(MailRequest mailRequest) { + String subject = mailRequest.getSubject(); + String template = mailRequest.getTemplate(); + + for (MemberEntity receiver : resolveReceivers()) { + String html = replaceTags(template, tokensFor(receiver)); + try { + sendHtml(receiver.getEmail(), subject, html); + } catch (MessagingException e) { + throw new MailDeliveryException("Failed to send mail to " + receiver.getEmail(), e); + } + } } - /** HTML email. */ - public void sendHtml(String to, String subject, String html) throws MessagingException { + public Resource getPdf(PdfRequest pdfRequest) { + return new ByteArrayResource(DUMMY_PDF); + } + + private void sendHtml(String to, String subject, String html) throws MessagingException { MimeMessage message = mailSender.createMimeMessage(); MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); helper.setFrom(from); helper.setTo(to); helper.setSubject(subject); - helper.setText(html, true); // true = treat body as HTML + helper.setText(html, true); mailSender.send(message); } - public void sendMail(MailRequest mailRequest) { - sendText("fabianheinrich02@gmail.com", "Erster Testdurchlauf", "Burschen Heraus!"); + private List resolveReceivers() { + if (hasRole("admin")) { + return memberRepository.findAll(); + } + + UUID senderId = currentMemberId(); + Set receiverIds = new LinkedHashSet<>(); + if (hasRole("director")) { + for (UUID sportId : directorRepository.findSportIdsByMemberId(senderId)) { + receiverIds.addAll(directorRepository.findMemberIdsBySportId(sportId)); + for (TeamEntity team : teamRepository.findAllBySportId(sportId)) { + receiverIds.addAll(trainerRepository.findMemberIdsByTeamId(team.getId())); + receiverIds.addAll(traineeRepository.findMemberIdsByTeamId(team.getId())); + } + } + } else if (hasRole("trainer")) { + for (UUID teamId : trainerRepository.findTeamIdsByMemberId(senderId)) { + receiverIds.addAll(trainerRepository.findMemberIdsByTeamId(teamId)); + receiverIds.addAll(traineeRepository.findMemberIdsByTeamId(teamId)); + } + } + return memberRepository.findAllById(receiverIds); } - public Resource getPdf(PdfRequest pdfRequest) { - return new ByteArrayResource(DUMMY_PDF); + private Map tokensFor(MemberEntity member) { + Map tokens = new HashMap<>(); + tokens.put("first_name", nullToEmpty(member.getFirstName())); + tokens.put("last_name", nullToEmpty(member.getLastName())); + tokens.put("full_name", (nullToEmpty(member.getFirstName()) + " " + nullToEmpty(member.getLastName())).trim()); + tokens.put("email", nullToEmpty(member.getEmail())); + tokens.put("address", nullToEmpty(member.getAddress())); + tokens.put("phone_number", nullToEmpty(member.getPhoneNumber())); + tokens.put("birthday", member.getBirthday() != null ? member.getBirthday().toString() : ""); + tokens.put("joining_date", member.getJoiningDate() != null ? member.getJoiningDate().toString() : ""); + + TeamEntity team = teamOf(member.getId()); + tokens.put("team_name", team != null ? nullToEmpty(team.getName()) : ""); + tokens.put("sport_name", nullToEmpty(sportNameOf(member.getId(), team))); + tokens.put("balance", formatBalance(balanceOf(member.getId()))); + return tokens; + } + + // A member belongs to at most one team, either as a trainer or as a trainee. + private TeamEntity teamOf(UUID memberId) { + List teamIds = trainerRepository.findTeamIdsByMemberId(memberId); + if (teamIds.isEmpty()) { + teamIds = traineeRepository.findTeamIdsByMemberId(memberId); + } + return teamIds.isEmpty() ? null : teamRepository.findById(teamIds.get(0)).orElse(null); + } + + private String sportNameOf(UUID memberId, TeamEntity team) { + UUID sportId; + if (team != null) { + sportId = team.getSportId(); + } else { + List sportIds = directorRepository.findSportIdsByMemberId(memberId); + if (sportIds.isEmpty()) { + return ""; + } + sportId = sportIds.get(0); + } + return sportRepository.findById(sportId).map(s -> s.getName()).orElse(""); + } + + private int balanceOf(UUID memberId) { + return transactionRepository.findAllByMemberId(memberId).stream() + .mapToInt(t -> t.getAmountCents()) + .sum(); + } + + private static String formatBalance(int amountCents) { + return String.format(Locale.US, "€%.2f", amountCents / 100.0); + } + + private static String nullToEmpty(String value) { + return value != null ? value : ""; + } + + private static String replaceTags(String text, Map tokens) { + Matcher matcher = TAG_PATTERN.matcher(text); + StringBuilder result = new StringBuilder(); + while (matcher.find()) { + String value = tokens.getOrDefault(matcher.group(1), ""); + matcher.appendReplacement(result, Matcher.quoteReplacement(value)); + } + matcher.appendTail(result); + return result.toString(); + } + + private static UUID currentMemberId() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + Jwt jwt = (Jwt) auth.getPrincipal(); + return UUID.fromString(jwt.getSubject()); + } + + private static boolean hasRole(String role) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + return auth.getAuthorities().stream() + .anyMatch(a -> ("ROLE_" + role).equals(a.getAuthority())); } } diff --git a/services/spring-letter/src/test/java/tum/devoops/letterservice/LetterServiceApplicationTests.java b/services/spring-letter/src/test/java/tum/devoops/letterservice/LetterServiceApplicationTests.java index 7135ff2..4f45bad 100644 --- a/services/spring-letter/src/test/java/tum/devoops/letterservice/LetterServiceApplicationTests.java +++ b/services/spring-letter/src/test/java/tum/devoops/letterservice/LetterServiceApplicationTests.java @@ -3,6 +3,15 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import tum.devoops.letterservice.repository.DirectorRepository; +import tum.devoops.letterservice.repository.MemberRepository; +import tum.devoops.letterservice.repository.SportRepository; +import tum.devoops.letterservice.repository.TeamRepository; +import tum.devoops.letterservice.repository.TraineeRepository; +import tum.devoops.letterservice.repository.TrainerRepository; +import tum.devoops.letterservice.repository.TransactionRepository; /** * Context-load smoke test. @@ -20,6 +29,14 @@ }) class LetterServiceApplicationTests { + @MockitoBean MemberRepository memberRepository; + @MockitoBean SportRepository sportRepository; + @MockitoBean TeamRepository teamRepository; + @MockitoBean DirectorRepository directorRepository; + @MockitoBean TrainerRepository trainerRepository; + @MockitoBean TraineeRepository traineeRepository; + @MockitoBean TransactionRepository transactionRepository; + @Test void contextLoads() { } diff --git a/services/spring-letter/src/test/java/tum/devoops/letterservice/service/LetterServiceTest.java b/services/spring-letter/src/test/java/tum/devoops/letterservice/service/LetterServiceTest.java new file mode 100644 index 0000000..6e991bd --- /dev/null +++ b/services/spring-letter/src/test/java/tum/devoops/letterservice/service/LetterServiceTest.java @@ -0,0 +1,338 @@ +package tum.devoops.letterservice.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +import jakarta.mail.Session; +import jakarta.mail.internet.MimeMessage; +import jakarta.mail.internet.MimeMultipart; + +import org.junit.jupiter.api.AfterEach; +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.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.Resource; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.test.util.ReflectionTestUtils; + +import tum.devoops.letterservice.entity.MemberEntity; +import tum.devoops.letterservice.entity.SportEntity; +import tum.devoops.letterservice.entity.TeamEntity; +import tum.devoops.letterservice.entity.TransactionEntity; +import tum.devoops.letterservice.exception.MailDeliveryException; +import tum.devoops.letterservice.model.MailRequest; +import tum.devoops.letterservice.model.PdfRequest; +import tum.devoops.letterservice.repository.DirectorRepository; +import tum.devoops.letterservice.repository.MemberRepository; +import tum.devoops.letterservice.repository.SportRepository; +import tum.devoops.letterservice.repository.TeamRepository; +import tum.devoops.letterservice.repository.TraineeRepository; +import tum.devoops.letterservice.repository.TrainerRepository; +import tum.devoops.letterservice.repository.TransactionRepository; + +@ExtendWith(MockitoExtension.class) +class LetterServiceTest { + + private static final String FROM = "noreply@example.com"; + + @Mock + private JavaMailSender mailSender; + @Mock + private MemberRepository memberRepository; + @Mock + private SportRepository sportRepository; + @Mock + private TeamRepository teamRepository; + @Mock + private DirectorRepository directorRepository; + @Mock + private TrainerRepository trainerRepository; + @Mock + private TraineeRepository traineeRepository; + @Mock + private TransactionRepository transactionRepository; + + private LetterService letterService; + + @BeforeEach + void setUp() { + letterService = new LetterService(mailSender, FROM, memberRepository, sportRepository, + teamRepository, directorRepository, trainerRepository, traineeRepository, transactionRepository); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + // --- sendMail: role-based receiver resolution --- + + @Test + void sendMailAsAdminSendsPersonalizedMailToAllMembers() throws Exception { + authenticateAs(UUID.randomUUID(), "admin"); + + MemberEntity alice = member("Alice", "Anderson", "alice@example.com"); + MemberEntity bob = member("Bob", "Brown", "bob@example.com"); + when(memberRepository.findAll()).thenReturn(List.of(alice, bob)); + stubMimeMessages(); + + letterService.sendMail(new MailRequest("Welcome", "

Hi {{first_name}}

")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MimeMessage.class); + verify(mailSender, times(2)).send(captor.capture()); + List sent = captor.getAllValues(); + assertThat(sent.get(0).getAllRecipients()[0].toString()).isEqualTo("alice@example.com"); + assertThat(extractHtml(sent.get(0))).isEqualTo("

Hi Alice

"); + assertThat(sent.get(1).getAllRecipients()[0].toString()).isEqualTo("bob@example.com"); + assertThat(extractHtml(sent.get(1))).isEqualTo("

Hi Bob

"); + } + + @Test + void sendMailAsDirectorResolvesReceiversViaSportsTeamsAndRoles() { + UUID senderId = UUID.randomUUID(); + authenticateAs(senderId, "director"); + + UUID sportId = UUID.randomUUID(); + UUID teamId = UUID.randomUUID(); + UUID coDirectorId = UUID.randomUUID(); + UUID trainerId = UUID.randomUUID(); + UUID traineeId = UUID.randomUUID(); + + when(directorRepository.findSportIdsByMemberId(senderId)).thenReturn(List.of(sportId)); + when(directorRepository.findMemberIdsBySportId(sportId)).thenReturn(List.of(coDirectorId)); + when(teamRepository.findAllBySportId(sportId)).thenReturn(List.of(team(teamId, "Team A", sportId))); + when(trainerRepository.findMemberIdsByTeamId(teamId)).thenReturn(List.of(trainerId)); + when(traineeRepository.findMemberIdsByTeamId(teamId)).thenReturn(List.of(traineeId)); + when(memberRepository.findAllById(any())).thenReturn(List.of()); + + letterService.sendMail(new MailRequest("Subject", "Body")); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(Iterable.class); + verify(memberRepository).findAllById(captor.capture()); + assertThat(captor.getValue()).containsExactlyInAnyOrder(coDirectorId, trainerId, traineeId); + } + + @Test + void sendMailAsTrainerResolvesReceiversViaTeams() { + UUID senderId = UUID.randomUUID(); + authenticateAs(senderId, "trainer"); + + UUID teamId = UUID.randomUUID(); + UUID coTrainerId = UUID.randomUUID(); + UUID traineeId = UUID.randomUUID(); + + when(trainerRepository.findTeamIdsByMemberId(senderId)).thenReturn(List.of(teamId)); + when(trainerRepository.findMemberIdsByTeamId(teamId)).thenReturn(List.of(coTrainerId)); + when(traineeRepository.findMemberIdsByTeamId(teamId)).thenReturn(List.of(traineeId)); + when(memberRepository.findAllById(any())).thenReturn(List.of()); + + letterService.sendMail(new MailRequest("Subject", "Body")); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(Iterable.class); + verify(memberRepository).findAllById(captor.capture()); + assertThat(captor.getValue()).containsExactlyInAnyOrder(coTrainerId, traineeId); + } + + @Test + void sendMailAsTraineeSendsToNoOne() { + authenticateAs(UUID.randomUUID(), "trainee"); + when(memberRepository.findAllById(any())).thenReturn(List.of()); + + letterService.sendMail(new MailRequest("Subject", "Body")); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(Iterable.class); + verify(memberRepository).findAllById(captor.capture()); + assertThat(captor.getValue()).isEmpty(); + verify(mailSender, never()).send(any(MimeMessage.class)); + } + + // --- sendMail: token replacement --- + + @Test + void sendMailReplacesAllKnownTokensAndUnknownTokensWithEmptyString() throws Exception { + authenticateAs(UUID.randomUUID(), "admin"); + + UUID memberId = UUID.randomUUID(); + MemberEntity carol = member(memberId, "Carol", "Clark", "carol@example.com", + "123 Main St", "+49 170 0000000", LocalDate.of(1990, 5, 1), LocalDate.of(2020, 1, 15)); + when(memberRepository.findAll()).thenReturn(List.of(carol)); + + UUID teamId = UUID.randomUUID(); + UUID sportId = UUID.randomUUID(); + when(trainerRepository.findTeamIdsByMemberId(memberId)).thenReturn(List.of(teamId)); + when(teamRepository.findById(teamId)).thenReturn(java.util.Optional.of(team(teamId, "Falcons", sportId))); + when(sportRepository.findById(sportId)).thenReturn(java.util.Optional.of(sport(sportId, "Basketball"))); + when(transactionRepository.findAllByMemberId(memberId)) + .thenReturn(List.of(transaction(memberId, 5000), transaction(memberId, -2500))); + stubMimeMessages(); + + String template = "{{first_name}} {{last_name}} {{full_name}} {{email}} {{address}} " + + "{{phone_number}} {{birthday}} {{joining_date}} {{team_name}} {{sport_name}} " + + "{{balance}} {{unknown_token}}"; + letterService.sendMail(new MailRequest("Subject", template)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MimeMessage.class); + verify(mailSender).send(captor.capture()); + String content = extractHtml(captor.getValue()); + + assertThat(content).isEqualTo("Carol Clark Carol Clark carol@example.com 123 Main St " + + "+49 170 0000000 1990-05-01 2020-01-15 Falcons Basketball €25.00 "); + } + + @Test + void sendMailWithMemberWithoutTeamOrSportLeavesTokensEmpty() throws Exception { + authenticateAs(UUID.randomUUID(), "admin"); + + UUID memberId = UUID.randomUUID(); + MemberEntity dan = member(memberId, "Dan", "Doe", "dan@example.com"); + when(memberRepository.findAll()).thenReturn(List.of(dan)); + when(transactionRepository.findAllByMemberId(memberId)).thenReturn(List.of()); + stubMimeMessages(); + + letterService.sendMail(new MailRequest("Subject", "[{{team_name}}][{{sport_name}}][{{balance}}]")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MimeMessage.class); + verify(mailSender).send(captor.capture()); + assertThat(extractHtml(captor.getValue())).isEqualTo("[][][€0.00]"); + } + + @Test + void sendMailWithDirectorWithoutTeamShowsSportNameFromDirectorRole() throws Exception { + authenticateAs(UUID.randomUUID(), "admin"); + + UUID memberId = UUID.randomUUID(); + MemberEntity eve = member(memberId, "Eve", "Evans", "eve@example.com"); + when(memberRepository.findAll()).thenReturn(List.of(eve)); + when(transactionRepository.findAllByMemberId(memberId)).thenReturn(List.of()); + + UUID sportId = UUID.randomUUID(); + when(directorRepository.findSportIdsByMemberId(memberId)).thenReturn(List.of(sportId)); + when(sportRepository.findById(sportId)).thenReturn(java.util.Optional.of(sport(sportId, "Swimming"))); + stubMimeMessages(); + + letterService.sendMail(new MailRequest("Subject", "[{{team_name}}][{{sport_name}}]")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MimeMessage.class); + verify(mailSender).send(captor.capture()); + assertThat(extractHtml(captor.getValue())).isEqualTo("[][Swimming]"); + } + + // --- sendMail: error handling --- + + @Test + void sendMailWrapsMessagingExceptionInMailDeliveryException() { + LetterService brokenFromService = new LetterService(mailSender, "not a valid from address", + memberRepository, sportRepository, teamRepository, directorRepository, + trainerRepository, traineeRepository, transactionRepository); + authenticateAs(UUID.randomUUID(), "admin"); + + MemberEntity frank = member("Frank", "Foster", "frank@example.com"); + when(memberRepository.findAll()).thenReturn(List.of(frank)); + stubMimeMessages(); + + assertThatThrownBy(() -> brokenFromService.sendMail(new MailRequest("Subject", "Body"))) + .isInstanceOf(MailDeliveryException.class) + .hasMessageContaining("frank@example.com") + .hasCauseInstanceOf(jakarta.mail.MessagingException.class); + } + + // --- getPdf --- + + @Test + void getPdfReturnsDummyPdfResource() throws Exception { + Resource pdf = letterService.getPdf(new PdfRequest("

Hi {{first_name}}

")); + + assertThat(pdf.getContentAsByteArray()).isEqualTo("%PDF-1.4 dummy".getBytes()); + } + + // --- helpers --- + + private void stubMimeMessages() { + when(mailSender.createMimeMessage()).thenAnswer(invocation -> new MimeMessage((Session) null)); + } + + // resolveReceivers() only reads the JWT subject for director/trainer roles, so this stub is + // unused for admin/trainee auth and must be lenient to avoid Mockito's strict-stubs failure. + private static void authenticateAs(UUID memberId, String role) { + Jwt jwt = mock(Jwt.class); + Mockito.lenient().when(jwt.getSubject()).thenReturn(memberId.toString()); + Authentication authentication = new TestingAuthenticationToken(jwt, null, + List.of(new SimpleGrantedAuthority("ROLE_" + role))); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + private static String extractHtml(MimeMessage message) throws Exception { + Object content = message.getContent(); + while (content instanceof MimeMultipart multipart) { + content = multipart.getBodyPart(0).getContent(); + } + return (String) content; + } + + private static MemberEntity member(String firstName, String lastName, String email) { + return member(UUID.randomUUID(), firstName, lastName, email, null, null, null, null); + } + + private static MemberEntity member(UUID id, String firstName, String lastName, String email) { + return member(id, firstName, lastName, email, null, null, null, null); + } + + private static MemberEntity member(UUID id, String firstName, String lastName, String email, + String address, String phoneNumber, LocalDate birthday, LocalDate joiningDate) { + MemberEntity member = new MemberEntity(); + ReflectionTestUtils.setField(member, "id", id); + ReflectionTestUtils.setField(member, "firstName", firstName); + ReflectionTestUtils.setField(member, "lastName", lastName); + ReflectionTestUtils.setField(member, "email", email); + ReflectionTestUtils.setField(member, "address", address); + ReflectionTestUtils.setField(member, "phoneNumber", phoneNumber); + ReflectionTestUtils.setField(member, "birthday", birthday); + ReflectionTestUtils.setField(member, "joiningDate", joiningDate); + return member; + } + + private static TeamEntity team(UUID id, String name, UUID sportId) { + TeamEntity team = new TeamEntity(); + ReflectionTestUtils.setField(team, "id", id); + ReflectionTestUtils.setField(team, "name", name); + ReflectionTestUtils.setField(team, "sportId", sportId); + return team; + } + + private static SportEntity sport(UUID id, String name) { + SportEntity sport = new SportEntity(); + ReflectionTestUtils.setField(sport, "id", id); + ReflectionTestUtils.setField(sport, "name", name); + return sport; + } + + private static TransactionEntity transaction(UUID memberId, int amountCents) { + TransactionEntity transaction = new TransactionEntity(); + ReflectionTestUtils.setField(transaction, "id", UUID.randomUUID()); + ReflectionTestUtils.setField(transaction, "memberId", memberId); + ReflectionTestUtils.setField(transaction, "amountCents", amountCents); + return transaction; + } +} From 5b2694399e57d9c9fb38d58a4133e7ce45273994 Mon Sep 17 00:00:00 2001 From: f-s-h Date: Thu, 2 Jul 2026 14:06:40 +0200 Subject: [PATCH 07/10] Fix member error --- .../helm/team-devoops/files/realm-config.json | 3 +- infra/keycloak/realm-config.json | 3 +- .../memberservice/entity/MemberEntity.java | 42 ++++++++++++++++--- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/infra/helm/team-devoops/files/realm-config.json b/infra/helm/team-devoops/files/realm-config.json index 5a9fbe6..90e6e80 100644 --- a/infra/helm/team-devoops/files/realm-config.json +++ b/infra/helm/team-devoops/files/realm-config.json @@ -44,7 +44,8 @@ "composite": true, "composites": { "client": { - "devops-client": ["Admin"] + "devops-client": ["Admin"], + "realm-management": ["manage-users"] } } }, diff --git a/infra/keycloak/realm-config.json b/infra/keycloak/realm-config.json index 5a9fbe6..90e6e80 100644 --- a/infra/keycloak/realm-config.json +++ b/infra/keycloak/realm-config.json @@ -44,7 +44,8 @@ "composite": true, "composites": { "client": { - "devops-client": ["Admin"] + "devops-client": ["Admin"], + "realm-management": ["manage-users"] } } }, diff --git a/services/spring-member/src/main/java/tum/devoops/memberservice/entity/MemberEntity.java b/services/spring-member/src/main/java/tum/devoops/memberservice/entity/MemberEntity.java index f38475f..37261d4 100644 --- a/services/spring-member/src/main/java/tum/devoops/memberservice/entity/MemberEntity.java +++ b/services/spring-member/src/main/java/tum/devoops/memberservice/entity/MemberEntity.java @@ -7,22 +7,23 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.PostLoad; +import jakarta.persistence.PostPersist; import jakarta.persistence.Table; -import lombok.AllArgsConstructor; +import jakarta.persistence.Transient; +import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.springframework.data.domain.Persistable; @Entity @Table(schema = "member", name = "members") -@Getter @Setter @NoArgsConstructor @AllArgsConstructor -public class MemberEntity { +@Getter @Setter @NoArgsConstructor +public class MemberEntity implements Persistable { @Id - @GeneratedValue(strategy = GenerationType.UUID) @Column(name = "id", nullable = false, updatable = false) private UUID id; @@ -50,6 +51,35 @@ public class MemberEntity { @Column(name = "information", nullable = true, columnDefinition = "TEXT") private String information; + @Transient + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private boolean isNew = true; + + public MemberEntity(UUID id, String firstName, String lastName, String email, LocalDate birthday, + String phoneNumber, String address, LocalDate joiningDate, String information) { + this.id = id; + this.firstName = firstName; + this.lastName = lastName; + this.email = email; + this.birthday = birthday; + this.phoneNumber = phoneNumber; + this.address = address; + this.joiningDate = joiningDate; + this.information = information; + } + + @Override + public boolean isNew() { + return isNew; + } + + @PostLoad + @PostPersist + void markNotNew() { + this.isNew = false; + } + @Override public boolean equals(Object o) { if (!(o instanceof MemberEntity other)) { From dfefb9c0037c30ba74096ed6628b3575607acd1f Mon Sep 17 00:00:00 2001 From: f-s-h Date: Thu, 2 Jul 2026 14:06:53 +0200 Subject: [PATCH 08/10] Implemented letter service --- api/openapi.yaml | 10 +- services/spring-letter/build.gradle | 2 + .../devoops/letterservice/api/LettersApi.java | 4 +- .../letterservice/model/MailRequest.java | 4 +- .../controller/LetterController.java | 8 +- .../exception/PdfGenerationException.java | 8 ++ .../letterservice/service/LetterService.java | 74 +++++++++++- .../service/LetterServiceTest.java | 112 +++++++++++++++++- 8 files changed, 204 insertions(+), 18 deletions(-) create mode 100644 services/spring-letter/src/main/java/tum/devoops/letterservice/exception/PdfGenerationException.java diff --git a/api/openapi.yaml b/api/openapi.yaml index 0f1d490..5e71f4f 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1044,9 +1044,9 @@ paths: - letters summary: Send mail description: | - Sends a personalized mass email. The body carries a `subject` and an HTML `template`; the - template's placeholder tokens are replaced with each receiver's data, and one email is sent - per receiver. + Sends a personalized mass email. The body carries a `subject` and an HTML `template`; + placeholder tokens in both the subject and the template are replaced with each receiver's + data, and one email is sent per receiver. Receivers are determined from the caller's highest role: - **Admin**: all members. @@ -1885,7 +1885,9 @@ components: properties: subject: type: string - description: Subject line of the email. + description: | + Subject line of the email. Supports the same per-receiver placeholder tokens as the + template; each token is replaced with that receiver's data before the email is sent. template: type: string description: | diff --git a/services/spring-letter/build.gradle b/services/spring-letter/build.gradle index 0be3a6f..6cc9a62 100644 --- a/services/spring-letter/build.gradle +++ b/services/spring-letter/build.gradle @@ -50,6 +50,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.0' + implementation 'com.openhtmltopdf:openhtmltopdf-pdfbox:1.0.10' + implementation 'org.jsoup:jsoup:1.18.3' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/services/spring-letter/src/generated/java/tum/devoops/letterservice/api/LettersApi.java b/services/spring-letter/src/generated/java/tum/devoops/letterservice/api/LettersApi.java index 8d0ada7..70d4e60 100644 --- a/services/spring-letter/src/generated/java/tum/devoops/letterservice/api/LettersApi.java +++ b/services/spring-letter/src/generated/java/tum/devoops/letterservice/api/LettersApi.java @@ -129,7 +129,7 @@ default ResponseEntity getPdf( /** * POST /letters/mail : Send mail - * Sends a personalized mass email. The body carries a `subject` and an HTML `template`; the template's placeholder tokens are replaced with each receiver's data, and one email is sent per receiver. Receivers are determined from the caller's highest role: - **Admin**: all members. - **Director**: all directors, trainers, and trainees in their sport. - **Trainer**: all trainers and trainees of their team. - **Trainee / member-only**: forbidden — cannot use the letter service (`403`). Assumes a director directs exactly one sport, a trainer trains exactly one team, and a trainee belongs to exactly one team. Supported placeholder tokens (`{{snake_case}}`; an unknown or empty value resolves to an empty string): - Member: `{{first_name}}`, `{{last_name}}`, `{{full_name}}`, `{{email}}`, `{{address}}`, `{{phone_number}}`, `{{birthday}}`, `{{joining_date}}`. - Organization: `{{team_name}}`, `{{sport_name}}` — the receiver's team/sport, blank if none. - Finance: `{{balance}}` — the receiver's current balance, formatted (e.g. `€12.50`). + * Sends a personalized mass email. The body carries a `subject` and an HTML `template`; placeholder tokens in both the subject and the template are replaced with each receiver's data, and one email is sent per receiver. Receivers are determined from the caller's highest role: - **Admin**: all members. - **Director**: all directors, trainers, and trainees in their sport. - **Trainer**: all trainers and trainees of their team. - **Trainee / member-only**: forbidden — cannot use the letter service (`403`). Assumes a director directs exactly one sport, a trainer trains exactly one team, and a trainee belongs to exactly one team. Supported placeholder tokens (`{{snake_case}}`; an unknown or empty value resolves to an empty string): - Member: `{{first_name}}`, `{{last_name}}`, `{{full_name}}`, `{{email}}`, `{{address}}`, `{{phone_number}}`, `{{birthday}}`, `{{joining_date}}`. - Organization: `{{team_name}}`, `{{sport_name}}` — the receiver's team/sport, blank if none. - Finance: `{{balance}}` — the receiver's current balance, formatted (e.g. `€12.50`). * * @param mailRequest The subject and HTML template for the personalized mass email. (required) * @return The request was successful, but there is no content to return in the response. (status code 204) @@ -141,7 +141,7 @@ default ResponseEntity getPdf( @Operation( operationId = "sendMail", summary = "Send mail", - description = "Sends a personalized mass email. The body carries a `subject` and an HTML `template`; the template's placeholder tokens are replaced with each receiver's data, and one email is sent per receiver. Receivers are determined from the caller's highest role: - **Admin**: all members. - **Director**: all directors, trainers, and trainees in their sport. - **Trainer**: all trainers and trainees of their team. - **Trainee / member-only**: forbidden — cannot use the letter service (`403`). Assumes a director directs exactly one sport, a trainer trains exactly one team, and a trainee belongs to exactly one team. Supported placeholder tokens (`{{snake_case}}`; an unknown or empty value resolves to an empty string): - Member: `{{first_name}}`, `{{last_name}}`, `{{full_name}}`, `{{email}}`, `{{address}}`, `{{phone_number}}`, `{{birthday}}`, `{{joining_date}}`. - Organization: `{{team_name}}`, `{{sport_name}}` — the receiver's team/sport, blank if none. - Finance: `{{balance}}` — the receiver's current balance, formatted (e.g. `€12.50`). ", + description = "Sends a personalized mass email. The body carries a `subject` and an HTML `template`; placeholder tokens in both the subject and the template are replaced with each receiver's data, and one email is sent per receiver. Receivers are determined from the caller's highest role: - **Admin**: all members. - **Director**: all directors, trainers, and trainees in their sport. - **Trainer**: all trainers and trainees of their team. - **Trainee / member-only**: forbidden — cannot use the letter service (`403`). Assumes a director directs exactly one sport, a trainer trains exactly one team, and a trainee belongs to exactly one team. Supported placeholder tokens (`{{snake_case}}`; an unknown or empty value resolves to an empty string): - Member: `{{first_name}}`, `{{last_name}}`, `{{full_name}}`, `{{email}}`, `{{address}}`, `{{phone_number}}`, `{{birthday}}`, `{{joining_date}}`. - Organization: `{{team_name}}`, `{{sport_name}}` — the receiver's team/sport, blank if none. - Finance: `{{balance}}` — the receiver's current balance, formatted (e.g. `€12.50`). ", tags = { "letters" }, responses = { @ApiResponse(responseCode = "204", description = "The request was successful, but there is no content to return in the response."), diff --git a/services/spring-letter/src/generated/java/tum/devoops/letterservice/model/MailRequest.java b/services/spring-letter/src/generated/java/tum/devoops/letterservice/model/MailRequest.java index e0ceb34..3591aa7 100644 --- a/services/spring-letter/src/generated/java/tum/devoops/letterservice/model/MailRequest.java +++ b/services/spring-letter/src/generated/java/tum/devoops/letterservice/model/MailRequest.java @@ -44,11 +44,11 @@ public MailRequest subject(String subject) { } /** - * Subject line of the email. + * Subject line of the email. Supports the same per-receiver placeholder tokens as the template; each token is replaced with that receiver's data before the email is sent. * @return subject */ @NotNull - @Schema(name = "subject", description = "Subject line of the email.", requiredMode = Schema.RequiredMode.REQUIRED) + @Schema(name = "subject", description = "Subject line of the email. Supports the same per-receiver placeholder tokens as the template; each token is replaced with that receiver's data before the email is sent. ", requiredMode = Schema.RequiredMode.REQUIRED) @JsonProperty("subject") public String getSubject() { return subject; diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/controller/LetterController.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/controller/LetterController.java index 5021d30..8a405d4 100644 --- a/services/spring-letter/src/main/java/tum/devoops/letterservice/controller/LetterController.java +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/controller/LetterController.java @@ -1,7 +1,8 @@ package tum.devoops.letterservice.controller; import org.springframework.core.io.Resource; -import org.springframework.http.HttpStatus; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.RestController; @@ -30,6 +31,9 @@ public ResponseEntity sendMail(MailRequest mailRequest) { @Override public ResponseEntity getPdf(PdfRequest pdfRequest) { Resource pdf = letterService.getPdf(pdfRequest); - return ResponseEntity.status(HttpStatus.OK).body(pdf); + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_PDF) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"letters.pdf\"") + .body(pdf); } } diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/exception/PdfGenerationException.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/exception/PdfGenerationException.java new file mode 100644 index 0000000..7e0d34f --- /dev/null +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/exception/PdfGenerationException.java @@ -0,0 +1,8 @@ +package tum.devoops.letterservice.exception; + +public class PdfGenerationException extends RuntimeException { + + public PdfGenerationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/service/LetterService.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/service/LetterService.java index 542f953..c520915 100644 --- a/services/spring-letter/src/main/java/tum/devoops/letterservice/service/LetterService.java +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/service/LetterService.java @@ -1,7 +1,11 @@ package tum.devoops.letterservice.service; +import com.openhtmltopdf.pdfboxout.PdfRendererBuilder; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Entities; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; @@ -15,6 +19,7 @@ import tum.devoops.letterservice.entity.MemberEntity; import tum.devoops.letterservice.entity.TeamEntity; import tum.devoops.letterservice.exception.MailDeliveryException; +import tum.devoops.letterservice.exception.PdfGenerationException; import tum.devoops.letterservice.model.MailRequest; import tum.devoops.letterservice.model.PdfRequest; import tum.devoops.letterservice.repository.DirectorRepository; @@ -25,6 +30,8 @@ import tum.devoops.letterservice.repository.TrainerRepository; import tum.devoops.letterservice.repository.TransactionRepository; +import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; @@ -38,9 +45,8 @@ @Service public class LetterService { - private static final byte[] DUMMY_PDF = "%PDF-1.4 dummy".getBytes(); - - private static final Pattern TAG_PATTERN = Pattern.compile("\\{\\{\\s*([\\w.]+)\\s*\\}\\}"); + // Tokens are {{snake_case}} per the API description; anything else is left as literal text. + private static final Pattern TAG_PATTERN = Pattern.compile("\\{\\{([a-z0-9_]+)\\}\\}"); private final JavaMailSender mailSender; private final String from; @@ -77,9 +83,11 @@ public void sendMail(MailRequest mailRequest) { String template = mailRequest.getTemplate(); for (MemberEntity receiver : resolveReceivers()) { - String html = replaceTags(template, tokensFor(receiver)); + Map tokens = tokensFor(receiver); + String personalizedSubject = replaceTags(subject, tokens); + String html = replaceTags(template, tokens); try { - sendHtml(receiver.getEmail(), subject, html); + sendHtml(receiver.getEmail(), personalizedSubject, html); } catch (MessagingException e) { throw new MailDeliveryException("Failed to send mail to " + receiver.getEmail(), e); } @@ -87,7 +95,61 @@ public void sendMail(MailRequest mailRequest) { } public Resource getPdf(PdfRequest pdfRequest) { - return new ByteArrayResource(DUMMY_PDF); + String template = pdfRequest.getTemplate(); + + StringBuilder letters = new StringBuilder(); + for (MemberEntity receiver : resolveReceivers()) { + Map tokens = tokensFor(receiver); + letters.append(renderLetter(tokens.get("full_name"), receiver.getAddress(), replaceTags(template, tokens))); + } + + String html = """ + + + + + %s + + """.formatted(letters); + return new ByteArrayResource(renderPdf(html)); + } + + private static String renderLetter(String fullName, String address, String body) { + return """ +
+
+
%s
+
%s
+
+
%s
+
+ """.formatted(escapeHtml(fullName), escapeHtml(nullToEmpty(address)), body); + } + + private static byte[] renderPdf(String html) { + // openhtmltopdf needs well-formed XHTML; jsoup tolerantly parses whatever the template is. + Document document = Jsoup.parse(html); + document.outputSettings() + .syntax(Document.OutputSettings.Syntax.xml) + .escapeMode(Entities.EscapeMode.xhtml); + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + new PdfRendererBuilder() + .withHtmlContent(document.html(), null) + .toStream(out) + .run(); + return out.toByteArray(); + } catch (IOException e) { + throw new PdfGenerationException("Failed to generate PDF", e); + } + } + + private static String escapeHtml(String value) { + return value.replace("&", "&").replace("<", "<").replace(">", ">"); } private void sendHtml(String to, String subject, String html) throws MessagingException { diff --git a/services/spring-letter/src/test/java/tum/devoops/letterservice/service/LetterServiceTest.java b/services/spring-letter/src/test/java/tum/devoops/letterservice/service/LetterServiceTest.java index 6e991bd..a4b540a 100644 --- a/services/spring-letter/src/test/java/tum/devoops/letterservice/service/LetterServiceTest.java +++ b/services/spring-letter/src/test/java/tum/devoops/letterservice/service/LetterServiceTest.java @@ -17,6 +17,8 @@ import jakarta.mail.internet.MimeMessage; import jakarta.mail.internet.MimeMultipart; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -201,6 +203,42 @@ void sendMailReplacesAllKnownTokensAndUnknownTokensWithEmptyString() throws Exce + "+49 170 0000000 1990-05-01 2020-01-15 Falcons Basketball €25.00 "); } + @Test + void sendMailReplacesTokensInSubject() throws Exception { + authenticateAs(UUID.randomUUID(), "admin"); + + UUID memberId = UUID.randomUUID(); + MemberEntity carol = member(memberId, "Carol", "Clark", "carol@example.com"); + when(memberRepository.findAll()).thenReturn(List.of(carol)); + when(transactionRepository.findAllByMemberId(memberId)).thenReturn(List.of()); + stubMimeMessages(); + + letterService.sendMail(new MailRequest("Hello {{first_name}}, your balance is {{balance}}", "Body")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MimeMessage.class); + verify(mailSender).send(captor.capture()); + assertThat(captor.getValue().getSubject()).isEqualTo("Hello Carol, your balance is €0.00"); + } + + @Test + void sendMailLeavesNonSnakeCaseTagsAsLiteralText() throws Exception { + authenticateAs(UUID.randomUUID(), "admin"); + + UUID memberId = UUID.randomUUID(); + MemberEntity carol = member(memberId, "Carol", "Clark", "carol@example.com"); + when(memberRepository.findAll()).thenReturn(List.of(carol)); + when(transactionRepository.findAllByMemberId(memberId)).thenReturn(List.of()); + stubMimeMessages(); + + letterService.sendMail(new MailRequest("Subject", + "{{ first_name }} {{FIRST_NAME}} {{first.name}} {{first_name}}")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MimeMessage.class); + verify(mailSender).send(captor.capture()); + assertThat(extractHtml(captor.getValue())) + .isEqualTo("{{ first_name }} {{FIRST_NAME}} {{first.name}} Carol"); + } + @Test void sendMailWithMemberWithoutTeamOrSportLeavesTokensEmpty() throws Exception { authenticateAs(UUID.randomUUID(), "admin"); @@ -261,14 +299,84 @@ void sendMailWrapsMessagingExceptionInMailDeliveryException() { // --- getPdf --- @Test - void getPdfReturnsDummyPdfResource() throws Exception { + void getPdfRendersOneLetterPagePerReceiverInSinglePdf() throws Exception { + authenticateAs(UUID.randomUUID(), "admin"); + + MemberEntity alice = member(UUID.randomUUID(), "Alice", "Anderson", "alice@example.com", + "1 Apple Ave", null, null, null); + MemberEntity bob = member(UUID.randomUUID(), "Bob", "Brown", "bob@example.com", + "2 Berry Blvd", null, null, null); + when(memberRepository.findAll()).thenReturn(List.of(alice, bob)); + Resource pdf = letterService.getPdf(new PdfRequest("

Hi {{first_name}}

")); - assertThat(pdf.getContentAsByteArray()).isEqualTo("%PDF-1.4 dummy".getBytes()); + byte[] bytes = pdf.getContentAsByteArray(); + assertThat(new String(bytes, 0, 5)).isEqualTo("%PDF-"); + try (PDDocument document = PDDocument.load(bytes)) { + assertThat(document.getNumberOfPages()).isEqualTo(2); + assertThat(pageText(document, 1)) + .contains("Alice Anderson", "1 Apple Ave", "Hi Alice") + .doesNotContain("Bob"); + assertThat(pageText(document, 2)) + .contains("Bob Brown", "2 Berry Blvd", "Hi Bob") + .doesNotContain("Alice"); + } + } + + @Test + void getPdfReplacesTokensAndHandlesMissingAddress() throws Exception { + authenticateAs(UUID.randomUUID(), "admin"); + + UUID memberId = UUID.randomUUID(); + MemberEntity carol = member(memberId, "Carol", "Clark", "carol@example.com"); + when(memberRepository.findAll()).thenReturn(List.of(carol)); + when(transactionRepository.findAllByMemberId(memberId)) + .thenReturn(List.of(transaction(memberId, 5000))); + + Resource pdf = letterService.getPdf( + new PdfRequest("

Balance of {{full_name}}: {{balance}}

[{{unknown_token}}]

")); + + try (PDDocument document = PDDocument.load(pdf.getContentAsByteArray())) { + String text = pageText(document, 1); + assertThat(text).contains("Balance of Carol Clark: €50.00", "[]"); + } + } + + @Test + void getPdfWithMalformedTemplateHtmlStillProducesPdf() throws Exception { + authenticateAs(UUID.randomUUID(), "admin"); + + MemberEntity dan = member(UUID.randomUUID(), "Dan", "Doe", "dan@example.com"); + when(memberRepository.findAll()).thenReturn(List.of(dan)); + + Resource pdf = letterService.getPdf(new PdfRequest("

Hi {{first_name}}

unclosed")); + + try (PDDocument document = PDDocument.load(pdf.getContentAsByteArray())) { + assertThat(pageText(document, 1)).contains("Hi Dan", "unclosed"); + } + } + + @Test + void getPdfWithNoReceiversReturnsValidEmptyPdf() throws Exception { + authenticateAs(UUID.randomUUID(), "trainee"); + when(memberRepository.findAllById(any())).thenReturn(List.of()); + + Resource pdf = letterService.getPdf(new PdfRequest("

Hi

")); + + try (PDDocument document = PDDocument.load(pdf.getContentAsByteArray())) { + assertThat(new PDFTextStripper().getText(document).trim()).isEmpty(); + } } // --- helpers --- + private static String pageText(PDDocument document, int page) throws Exception { + PDFTextStripper stripper = new PDFTextStripper(); + stripper.setStartPage(page); + stripper.setEndPage(page); + return stripper.getText(document); + } + private void stubMimeMessages() { when(mailSender.createMimeMessage()).thenAnswer(invocation -> new MimeMessage((Session) null)); } From 0b6922fe2450e813c3bf353dfb59fbcfda92b179 Mon Sep 17 00:00:00 2001 From: f-s-h Date: Thu, 2 Jul 2026 14:26:28 +0200 Subject: [PATCH 09/10] Added default mail credentials --- .../spring-letter/src/main/resources/application.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/spring-letter/src/main/resources/application.properties b/services/spring-letter/src/main/resources/application.properties index d9c92a6..750aea4 100644 --- a/services/spring-letter/src/main/resources/application.properties +++ b/services/spring-letter/src/main/resources/application.properties @@ -8,7 +8,7 @@ spring.jpa.hibernate.ddl-auto=none spring.mail.host=smtp.gmail.com spring.mail.port=587 -spring.mail.username=${MAIL_USERNAME} -spring.mail.password=${MAIL_PASSWORD} +spring.mail.username=${MAIL_USERNAME:} +spring.mail.password=${MAIL_PASSWORD:} spring.mail.properties.mail.smtp.auth=true spring.mail.properties.mail.smtp.starttls.enable=true From b6e4a6037ec8b4f1ad49b1b2e7448d0a430eb824 Mon Sep 17 00:00:00 2001 From: Raphael Frank <04.raphael.frank@gmail.com> Date: Fri, 3 Jul 2026 10:50:08 +0200 Subject: [PATCH 10/10] fix security layer for letter service --- api/openapi.yaml | 6 +- services/py-genai-helper/generated/models.py | 10 +- .../letterservice/model/MailRequest.java | 6 +- .../controller/LetterController.java | 29 +++- .../exception/ForbiddenException.java | 8 ++ .../exception/GlobalExceptionHandler.java | 45 ++++++ .../letterservice/service/LetterService.java | 51 ++++--- .../controller/LetterControllerTest.java | 130 ++++++++++++------ .../service/LetterServiceTest.java | 105 +++++--------- web-client/src/api.ts | 12 +- 10 files changed, 245 insertions(+), 157 deletions(-) create mode 100644 services/spring-letter/src/main/java/tum/devoops/letterservice/exception/ForbiddenException.java create mode 100644 services/spring-letter/src/main/java/tum/devoops/letterservice/exception/GlobalExceptionHandler.java diff --git a/api/openapi.yaml b/api/openapi.yaml index 5e71f4f..4d33a62 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1885,9 +1885,11 @@ components: properties: subject: type: string + minLength: 1 description: | - Subject line of the email. Supports the same per-receiver placeholder tokens as the - template; each token is replaced with that receiver's data before the email is sent. + Subject line of the email. Must not be empty. Supports the same per-receiver + placeholder tokens as the template; each token is replaced with that receiver's data + before the email is sent. template: type: string description: | diff --git a/services/py-genai-helper/generated/models.py b/services/py-genai-helper/generated/models.py index f3eb323..6889dd4 100644 --- a/services/py-genai-helper/generated/models.py +++ b/services/py-genai-helper/generated/models.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: openapi.yaml -# timestamp: 2026-06-30T09:51:20+00:00 +# timestamp: 2026-07-03T08:50:25+00:00 from __future__ import annotations from pydantic import AwareDatetime, BaseModel, Field, RootModel, SecretStr @@ -168,7 +168,13 @@ class PdfRequest(BaseModel): class MailRequest(BaseModel): - subject: Annotated[str, Field(description='Subject line of the email.')] + subject: Annotated[ + str, + Field( + description="Subject line of the email. Must not be empty. Supports the same per-receiver\nplaceholder tokens as the template; each token is replaced with that receiver's data\nbefore the email is sent.\n", + min_length=1, + ), + ] template: Annotated[ str, Field( diff --git a/services/spring-letter/src/generated/java/tum/devoops/letterservice/model/MailRequest.java b/services/spring-letter/src/generated/java/tum/devoops/letterservice/model/MailRequest.java index 3591aa7..77bb3c7 100644 --- a/services/spring-letter/src/generated/java/tum/devoops/letterservice/model/MailRequest.java +++ b/services/spring-letter/src/generated/java/tum/devoops/letterservice/model/MailRequest.java @@ -44,11 +44,11 @@ public MailRequest subject(String subject) { } /** - * Subject line of the email. Supports the same per-receiver placeholder tokens as the template; each token is replaced with that receiver's data before the email is sent. + * Subject line of the email. Must not be empty. Supports the same per-receiver placeholder tokens as the template; each token is replaced with that receiver's data before the email is sent. * @return subject */ - @NotNull - @Schema(name = "subject", description = "Subject line of the email. Supports the same per-receiver placeholder tokens as the template; each token is replaced with that receiver's data before the email is sent. ", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull @Size(min = 1) + @Schema(name = "subject", description = "Subject line of the email. Must not be empty. Supports the same per-receiver placeholder tokens as the template; each token is replaced with that receiver's data before the email is sent. ", requiredMode = Schema.RequiredMode.REQUIRED) @JsonProperty("subject") public String getSubject() { return subject; diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/controller/LetterController.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/controller/LetterController.java index 8a405d4..250495a 100644 --- a/services/spring-letter/src/main/java/tum/devoops/letterservice/controller/LetterController.java +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/controller/LetterController.java @@ -5,6 +5,9 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.web.bind.annotation.RestController; import tum.devoops.letterservice.api.LettersApi; @@ -12,8 +15,16 @@ import tum.devoops.letterservice.model.PdfRequest; import tum.devoops.letterservice.service.LetterService; +import java.util.UUID; + +/** + * Membership roles (director/trainer/trainee) aren't Spring Security authorities in this + * codebase — they only exist as rows in the organization schema. This controller only gates on + * the realm roles (admin/member); {@link LetterService} does the director/trainer check itself + * against those rows, same as e.g. TransactionService/FeedbackService. + */ @RestController -@PreAuthorize("hasAnyRole('admin', 'director', 'trainer')") +@PreAuthorize("hasAnyRole('admin', 'member')") public class LetterController implements LettersApi { private final LetterService letterService; @@ -24,16 +35,28 @@ public LetterController(LetterService letterService) { @Override public ResponseEntity sendMail(MailRequest mailRequest) { - letterService.sendMail(mailRequest); + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + letterService.sendMail(mailRequest, extractRequesterId(auth), extractIsAdmin(auth)); return ResponseEntity.noContent().build(); } @Override public ResponseEntity getPdf(PdfRequest pdfRequest) { - Resource pdf = letterService.getPdf(pdfRequest); + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + Resource pdf = letterService.getPdf(pdfRequest, extractRequesterId(auth), extractIsAdmin(auth)); return ResponseEntity.ok() .contentType(MediaType.APPLICATION_PDF) .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"letters.pdf\"") .body(pdf); } + + private UUID extractRequesterId(Authentication auth) { + Jwt jwt = (Jwt) auth.getPrincipal(); + return UUID.fromString(jwt.getSubject()); + } + + private boolean extractIsAdmin(Authentication auth) { + return auth.getAuthorities().stream() + .anyMatch(a -> "ROLE_admin".equals(a.getAuthority())); + } } diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/exception/ForbiddenException.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/exception/ForbiddenException.java new file mode 100644 index 0000000..b7a8183 --- /dev/null +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/exception/ForbiddenException.java @@ -0,0 +1,8 @@ +package tum.devoops.letterservice.exception; + +public class ForbiddenException extends RuntimeException { + + public ForbiddenException(String message) { + super(message); + } +} diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/exception/GlobalExceptionHandler.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..698cfd8 --- /dev/null +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/exception/GlobalExceptionHandler.java @@ -0,0 +1,45 @@ +package tum.devoops.letterservice.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import tum.devoops.letterservice.model.BadRequestResponse; +import tum.devoops.letterservice.model.ErrorResponse; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + // Without this handler, a failed @Valid check on a request body (e.g. an empty subject) + // falls through to Spring's default resolver, which sets the 400 status but doesn't shape + // the body to the OpenAPI-documented BadRequestResponse. + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException ex) { + BadRequestResponse response = new BadRequestResponse().message("Validation failed"); + for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) { + response.addErrorsItem(new ErrorResponse(fieldError.getField() + ": " + fieldError.getDefaultMessage())); + } + return ResponseEntity.badRequest().body(response); + } + + @ExceptionHandler(ForbiddenException.class) + public ResponseEntity handleForbidden(ForbiddenException ex) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(new ErrorResponse().message(ex.getMessage())); + } + + @ExceptionHandler(MailDeliveryException.class) + public ResponseEntity handleMailDelivery(MailDeliveryException ex) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse().message(ex.getMessage())); + } + + @ExceptionHandler(PdfGenerationException.class) + public ResponseEntity handlePdfGeneration(PdfGenerationException ex) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse().message(ex.getMessage())); + } +} diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/service/LetterService.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/service/LetterService.java index c520915..1e6b18d 100644 --- a/services/spring-letter/src/main/java/tum/devoops/letterservice/service/LetterService.java +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/service/LetterService.java @@ -11,13 +11,11 @@ import org.springframework.core.io.Resource; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.stereotype.Service; import tum.devoops.letterservice.entity.MemberEntity; import tum.devoops.letterservice.entity.TeamEntity; +import tum.devoops.letterservice.exception.ForbiddenException; import tum.devoops.letterservice.exception.MailDeliveryException; import tum.devoops.letterservice.exception.PdfGenerationException; import tum.devoops.letterservice.model.MailRequest; @@ -78,11 +76,11 @@ public LetterService(JavaMailSender mailSender, this.transactionRepository = transactionRepository; } - public void sendMail(MailRequest mailRequest) { + public void sendMail(MailRequest mailRequest, UUID requesterId, boolean isAdmin) { String subject = mailRequest.getSubject(); String template = mailRequest.getTemplate(); - for (MemberEntity receiver : resolveReceivers()) { + for (MemberEntity receiver : resolveReceivers(requesterId, isAdmin)) { Map tokens = tokensFor(receiver); String personalizedSubject = replaceTags(subject, tokens); String html = replaceTags(template, tokens); @@ -94,11 +92,11 @@ public void sendMail(MailRequest mailRequest) { } } - public Resource getPdf(PdfRequest pdfRequest) { + public Resource getPdf(PdfRequest pdfRequest, UUID requesterId, boolean isAdmin) { String template = pdfRequest.getTemplate(); StringBuilder letters = new StringBuilder(); - for (MemberEntity receiver : resolveReceivers()) { + for (MemberEntity receiver : resolveReceivers(requesterId, isAdmin)) { Map tokens = tokensFor(receiver); letters.append(renderLetter(tokens.get("full_name"), receiver.getAddress(), replaceTags(template, tokens))); } @@ -162,28 +160,38 @@ private void sendHtml(String to, String subject, String html) throws MessagingEx mailSender.send(message); } - private List resolveReceivers() { - if (hasRole("admin")) { + // Director/trainer/trainee aren't Spring Security roles here (see LetterController); membership + // is looked up directly against the organization-schema rows, same pattern as + // TransactionService.isDirectorOfMember/isTrainerOfMember and FeedbackService.assertTrainerOfMember. + private List resolveReceivers(UUID requesterId, boolean isAdmin) { + if (isAdmin) { return memberRepository.findAll(); } - UUID senderId = currentMemberId(); - Set receiverIds = new LinkedHashSet<>(); - if (hasRole("director")) { - for (UUID sportId : directorRepository.findSportIdsByMemberId(senderId)) { + List directorSportIds = directorRepository.findSportIdsByMemberId(requesterId); + if (!directorSportIds.isEmpty()) { + Set receiverIds = new LinkedHashSet<>(); + for (UUID sportId : directorSportIds) { receiverIds.addAll(directorRepository.findMemberIdsBySportId(sportId)); for (TeamEntity team : teamRepository.findAllBySportId(sportId)) { receiverIds.addAll(trainerRepository.findMemberIdsByTeamId(team.getId())); receiverIds.addAll(traineeRepository.findMemberIdsByTeamId(team.getId())); } } - } else if (hasRole("trainer")) { - for (UUID teamId : trainerRepository.findTeamIdsByMemberId(senderId)) { + return memberRepository.findAllById(receiverIds); + } + + List trainerTeamIds = trainerRepository.findTeamIdsByMemberId(requesterId); + if (!trainerTeamIds.isEmpty()) { + Set receiverIds = new LinkedHashSet<>(); + for (UUID teamId : trainerTeamIds) { receiverIds.addAll(trainerRepository.findMemberIdsByTeamId(teamId)); receiverIds.addAll(traineeRepository.findMemberIdsByTeamId(teamId)); } + return memberRepository.findAllById(receiverIds); } - return memberRepository.findAllById(receiverIds); + + throw new ForbiddenException("Only admins, directors, or trainers can use the letter service."); } private Map tokensFor(MemberEntity member) { @@ -252,15 +260,4 @@ private static String replaceTags(String text, Map tokens) { return result.toString(); } - private static UUID currentMemberId() { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - Jwt jwt = (Jwt) auth.getPrincipal(); - return UUID.fromString(jwt.getSubject()); - } - - private static boolean hasRole(String role) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - return auth.getAuthorities().stream() - .anyMatch(a -> ("ROLE_" + role).equals(a.getAuthority())); - } } diff --git a/services/spring-letter/src/test/java/tum/devoops/letterservice/controller/LetterControllerTest.java b/services/spring-letter/src/test/java/tum/devoops/letterservice/controller/LetterControllerTest.java index 2a32fd1..168ca54 100644 --- a/services/spring-letter/src/test/java/tum/devoops/letterservice/controller/LetterControllerTest.java +++ b/services/spring-letter/src/test/java/tum/devoops/letterservice/controller/LetterControllerTest.java @@ -1,12 +1,18 @@ package tum.devoops.letterservice.controller; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.util.UUID; + import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; @@ -14,10 +20,14 @@ import org.springframework.core.io.ByteArrayResource; import org.springframework.http.MediaType; import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import tum.devoops.letterservice.config.SecurityConfig; +import tum.devoops.letterservice.exception.ForbiddenException; +import tum.devoops.letterservice.exception.MailDeliveryException; +import tum.devoops.letterservice.exception.PdfGenerationException; import tum.devoops.letterservice.model.MailRequest; import tum.devoops.letterservice.model.PdfRequest; import tum.devoops.letterservice.service.LetterService; @@ -32,6 +42,8 @@ class LetterControllerTest { @MockitoBean private LetterService letterService; + private static final UUID REQUESTER_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + private static final String MAIL_JSON = """ {"subject":"Hello","template":"

Hi {{first_name}}

"} """; @@ -43,62 +55,98 @@ class LetterControllerTest { private static final ByteArrayResource DUMMY_PDF = new ByteArrayResource("%PDF-1.4 dummy".getBytes()); + private JwtRequestPostProcessor adminJwt() { + return jwt().jwt(j -> j.subject(REQUESTER_ID.toString())) + .authorities(new SimpleGrantedAuthority("ROLE_admin")); + } + + private JwtRequestPostProcessor memberJwt() { + return jwt().jwt(j -> j.subject(REQUESTER_ID.toString())) + .authorities(new SimpleGrantedAuthority("ROLE_member")); + } + // --- sendMail --- @Test void sendMailWithAdminReturns204() throws Exception { mockMvc.perform(post("/letters/mail") - .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_admin"))) + .with(adminJwt()) .contentType(MediaType.APPLICATION_JSON) .content(MAIL_JSON)) .andExpect(status().isNoContent()); } @Test - void sendMailWithDirectorReturns204() throws Exception { + void sendMailWithMemberReturns204() throws Exception { + // The controller only gates on realm roles (admin/member); LetterService decides + // director/trainer/trainee access itself, so any authenticated member reaches it. mockMvc.perform(post("/letters/mail") - .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_director"))) + .with(memberJwt()) .contentType(MediaType.APPLICATION_JSON) .content(MAIL_JSON)) .andExpect(status().isNoContent()); } @Test - void sendMailWithTrainerReturns204() throws Exception { + void sendMailDelegatesToService() throws Exception { mockMvc.perform(post("/letters/mail") - .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_trainer"))) + .with(adminJwt()) .contentType(MediaType.APPLICATION_JSON) .content(MAIL_JSON)) .andExpect(status().isNoContent()); + + verify(letterService).sendMail(any(MailRequest.class), eq(REQUESTER_ID), eq(true)); } @Test - void sendMailDelegatesToService() throws Exception { + void sendMailWhenServiceRejectsReturns403WithMessage() throws Exception { + doThrow(new ForbiddenException("Only admins, directors, or trainers can use the letter service.")) + .when(letterService).sendMail(any(MailRequest.class), eq(REQUESTER_ID), eq(false)); + mockMvc.perform(post("/letters/mail") - .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_admin"))) + .with(memberJwt()) .contentType(MediaType.APPLICATION_JSON) .content(MAIL_JSON)) - .andExpect(status().isNoContent()); - - verify(letterService).sendMail(any(MailRequest.class)); + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.message") + .value("Only admins, directors, or trainers can use the letter service.")); } @Test - void sendMailWithTraineeReturns403() throws Exception { + void sendMailWhenServiceThrowsMailDeliveryReturns500WithMessage() throws Exception { + doThrow(new MailDeliveryException("Failed to send mail to a@example.com", new Exception("boom"))) + .when(letterService).sendMail(any(MailRequest.class), eq(REQUESTER_ID), eq(true)); + mockMvc.perform(post("/letters/mail") - .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_trainee"))) + .with(adminJwt()) .contentType(MediaType.APPLICATION_JSON) .content(MAIL_JSON)) - .andExpect(status().isForbidden()); + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.message").value("Failed to send mail to a@example.com")); } @Test - void sendMailWithMemberReturns403() throws Exception { + void sendMailWithEmptySubjectReturns400() throws Exception { mockMvc.perform(post("/letters/mail") - .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_member"))) + .with(adminJwt()) .contentType(MediaType.APPLICATION_JSON) - .content(MAIL_JSON)) - .andExpect(status().isForbidden()); + .content(""" + {"subject":"","template":"

Hi

"} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errors[0].message").value("subject: size must be between 1 and 2147483647")); + } + + @Test + void sendMailWithEmptyTemplateReturns204() throws Exception { + // Only the subject must be non-empty; an empty template is a valid (if pointless) letter body. + mockMvc.perform(post("/letters/mail") + .with(adminJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"subject":"Hello","template":""} + """)) + .andExpect(status().isNoContent()); } @Test @@ -113,32 +161,21 @@ void sendMailWithoutAuthReturns401() throws Exception { @Test void getPdfWithAdminReturns200() throws Exception { - when(letterService.getPdf(any())).thenReturn(DUMMY_PDF); + when(letterService.getPdf(any(), any(), anyBoolean())).thenReturn(DUMMY_PDF); mockMvc.perform(post("/letters/pdf") - .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_admin"))) + .with(adminJwt()) .contentType(MediaType.APPLICATION_JSON) .content(PDF_JSON)) .andExpect(status().isOk()); } @Test - void getPdfWithDirectorReturns200() throws Exception { - when(letterService.getPdf(any())).thenReturn(DUMMY_PDF); + void getPdfWithMemberReturns200() throws Exception { + when(letterService.getPdf(any(), any(), anyBoolean())).thenReturn(DUMMY_PDF); mockMvc.perform(post("/letters/pdf") - .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_director"))) - .contentType(MediaType.APPLICATION_JSON) - .content(PDF_JSON)) - .andExpect(status().isOk()); - } - - @Test - void getPdfWithTrainerReturns200() throws Exception { - when(letterService.getPdf(any())).thenReturn(DUMMY_PDF); - - mockMvc.perform(post("/letters/pdf") - .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_trainer"))) + .with(memberJwt()) .contentType(MediaType.APPLICATION_JSON) .content(PDF_JSON)) .andExpect(status().isOk()); @@ -146,33 +183,42 @@ void getPdfWithTrainerReturns200() throws Exception { @Test void getPdfDelegatesToService() throws Exception { - when(letterService.getPdf(any())).thenReturn(DUMMY_PDF); + when(letterService.getPdf(any(), any(), anyBoolean())).thenReturn(DUMMY_PDF); mockMvc.perform(post("/letters/pdf") - .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_admin"))) + .with(adminJwt()) .contentType(MediaType.APPLICATION_JSON) .content(PDF_JSON)) .andExpect(status().isOk()); - verify(letterService).getPdf(any(PdfRequest.class)); + verify(letterService).getPdf(any(PdfRequest.class), eq(REQUESTER_ID), eq(true)); } @Test - void getPdfWithTraineeReturns403() throws Exception { + void getPdfWhenServiceRejectsReturns403WithMessage() throws Exception { + when(letterService.getPdf(any(), eq(REQUESTER_ID), eq(false))) + .thenThrow(new ForbiddenException("Only admins, directors, or trainers can use the letter service.")); + mockMvc.perform(post("/letters/pdf") - .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_trainee"))) + .with(memberJwt()) .contentType(MediaType.APPLICATION_JSON) .content(PDF_JSON)) - .andExpect(status().isForbidden()); + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.message") + .value("Only admins, directors, or trainers can use the letter service.")); } @Test - void getPdfWithMemberReturns403() throws Exception { + void getPdfWhenServiceThrowsPdfGenerationReturns500WithMessage() throws Exception { + when(letterService.getPdf(any(), eq(REQUESTER_ID), eq(true))) + .thenThrow(new PdfGenerationException("Failed to generate PDF", new Exception("boom"))); + mockMvc.perform(post("/letters/pdf") - .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_member"))) + .with(adminJwt()) .contentType(MediaType.APPLICATION_JSON) .content(PDF_JSON)) - .andExpect(status().isForbidden()); + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.message").value("Failed to generate PDF")); } @Test diff --git a/services/spring-letter/src/test/java/tum/devoops/letterservice/service/LetterServiceTest.java b/services/spring-letter/src/test/java/tum/devoops/letterservice/service/LetterServiceTest.java index a4b540a..83af6ae 100644 --- a/services/spring-letter/src/test/java/tum/devoops/letterservice/service/LetterServiceTest.java +++ b/services/spring-letter/src/test/java/tum/devoops/letterservice/service/LetterServiceTest.java @@ -3,10 +3,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import java.time.LocalDate; @@ -19,27 +18,21 @@ import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.text.PDFTextStripper; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; -import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.core.io.Resource; import org.springframework.mail.javamail.JavaMailSender; -import org.springframework.security.authentication.TestingAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.test.util.ReflectionTestUtils; import tum.devoops.letterservice.entity.MemberEntity; import tum.devoops.letterservice.entity.SportEntity; import tum.devoops.letterservice.entity.TeamEntity; import tum.devoops.letterservice.entity.TransactionEntity; +import tum.devoops.letterservice.exception.ForbiddenException; import tum.devoops.letterservice.exception.MailDeliveryException; import tum.devoops.letterservice.model.MailRequest; import tum.devoops.letterservice.model.PdfRequest; @@ -81,23 +74,16 @@ void setUp() { teamRepository, directorRepository, trainerRepository, traineeRepository, transactionRepository); } - @AfterEach - void tearDown() { - SecurityContextHolder.clearContext(); - } - // --- sendMail: role-based receiver resolution --- @Test void sendMailAsAdminSendsPersonalizedMailToAllMembers() throws Exception { - authenticateAs(UUID.randomUUID(), "admin"); - MemberEntity alice = member("Alice", "Anderson", "alice@example.com"); MemberEntity bob = member("Bob", "Brown", "bob@example.com"); when(memberRepository.findAll()).thenReturn(List.of(alice, bob)); stubMimeMessages(); - letterService.sendMail(new MailRequest("Welcome", "

Hi {{first_name}}

")); + letterService.sendMail(new MailRequest("Welcome", "

Hi {{first_name}}

"), UUID.randomUUID(), true); ArgumentCaptor captor = ArgumentCaptor.forClass(MimeMessage.class); verify(mailSender, times(2)).send(captor.capture()); @@ -111,7 +97,6 @@ void sendMailAsAdminSendsPersonalizedMailToAllMembers() throws Exception { @Test void sendMailAsDirectorResolvesReceiversViaSportsTeamsAndRoles() { UUID senderId = UUID.randomUUID(); - authenticateAs(senderId, "director"); UUID sportId = UUID.randomUUID(); UUID teamId = UUID.randomUUID(); @@ -126,7 +111,7 @@ void sendMailAsDirectorResolvesReceiversViaSportsTeamsAndRoles() { when(traineeRepository.findMemberIdsByTeamId(teamId)).thenReturn(List.of(traineeId)); when(memberRepository.findAllById(any())).thenReturn(List.of()); - letterService.sendMail(new MailRequest("Subject", "Body")); + letterService.sendMail(new MailRequest("Subject", "Body"), senderId, false); @SuppressWarnings("unchecked") ArgumentCaptor> captor = ArgumentCaptor.forClass(Iterable.class); @@ -137,7 +122,6 @@ void sendMailAsDirectorResolvesReceiversViaSportsTeamsAndRoles() { @Test void sendMailAsTrainerResolvesReceiversViaTeams() { UUID senderId = UUID.randomUUID(); - authenticateAs(senderId, "trainer"); UUID teamId = UUID.randomUUID(); UUID coTrainerId = UUID.randomUUID(); @@ -148,7 +132,7 @@ void sendMailAsTrainerResolvesReceiversViaTeams() { when(traineeRepository.findMemberIdsByTeamId(teamId)).thenReturn(List.of(traineeId)); when(memberRepository.findAllById(any())).thenReturn(List.of()); - letterService.sendMail(new MailRequest("Subject", "Body")); + letterService.sendMail(new MailRequest("Subject", "Body"), senderId, false); @SuppressWarnings("unchecked") ArgumentCaptor> captor = ArgumentCaptor.forClass(Iterable.class); @@ -157,25 +141,19 @@ void sendMailAsTrainerResolvesReceiversViaTeams() { } @Test - void sendMailAsTraineeSendsToNoOne() { - authenticateAs(UUID.randomUUID(), "trainee"); - when(memberRepository.findAllById(any())).thenReturn(List.of()); + void sendMailAsPlainMemberWithNoOrgRoleThrowsForbidden() { + UUID senderId = UUID.randomUUID(); - letterService.sendMail(new MailRequest("Subject", "Body")); + assertThatThrownBy(() -> letterService.sendMail(new MailRequest("Subject", "Body"), senderId, false)) + .isInstanceOf(ForbiddenException.class); - @SuppressWarnings("unchecked") - ArgumentCaptor> captor = ArgumentCaptor.forClass(Iterable.class); - verify(memberRepository).findAllById(captor.capture()); - assertThat(captor.getValue()).isEmpty(); - verify(mailSender, never()).send(any(MimeMessage.class)); + verifyNoInteractions(memberRepository, mailSender); } // --- sendMail: token replacement --- @Test void sendMailReplacesAllKnownTokensAndUnknownTokensWithEmptyString() throws Exception { - authenticateAs(UUID.randomUUID(), "admin"); - UUID memberId = UUID.randomUUID(); MemberEntity carol = member(memberId, "Carol", "Clark", "carol@example.com", "123 Main St", "+49 170 0000000", LocalDate.of(1990, 5, 1), LocalDate.of(2020, 1, 15)); @@ -193,7 +171,7 @@ void sendMailReplacesAllKnownTokensAndUnknownTokensWithEmptyString() throws Exce String template = "{{first_name}} {{last_name}} {{full_name}} {{email}} {{address}} " + "{{phone_number}} {{birthday}} {{joining_date}} {{team_name}} {{sport_name}} " + "{{balance}} {{unknown_token}}"; - letterService.sendMail(new MailRequest("Subject", template)); + letterService.sendMail(new MailRequest("Subject", template), UUID.randomUUID(), true); ArgumentCaptor captor = ArgumentCaptor.forClass(MimeMessage.class); verify(mailSender).send(captor.capture()); @@ -205,15 +183,14 @@ void sendMailReplacesAllKnownTokensAndUnknownTokensWithEmptyString() throws Exce @Test void sendMailReplacesTokensInSubject() throws Exception { - authenticateAs(UUID.randomUUID(), "admin"); - UUID memberId = UUID.randomUUID(); MemberEntity carol = member(memberId, "Carol", "Clark", "carol@example.com"); when(memberRepository.findAll()).thenReturn(List.of(carol)); when(transactionRepository.findAllByMemberId(memberId)).thenReturn(List.of()); stubMimeMessages(); - letterService.sendMail(new MailRequest("Hello {{first_name}}, your balance is {{balance}}", "Body")); + letterService.sendMail(new MailRequest("Hello {{first_name}}, your balance is {{balance}}", "Body"), + UUID.randomUUID(), true); ArgumentCaptor captor = ArgumentCaptor.forClass(MimeMessage.class); verify(mailSender).send(captor.capture()); @@ -222,8 +199,6 @@ void sendMailReplacesTokensInSubject() throws Exception { @Test void sendMailLeavesNonSnakeCaseTagsAsLiteralText() throws Exception { - authenticateAs(UUID.randomUUID(), "admin"); - UUID memberId = UUID.randomUUID(); MemberEntity carol = member(memberId, "Carol", "Clark", "carol@example.com"); when(memberRepository.findAll()).thenReturn(List.of(carol)); @@ -231,7 +206,7 @@ void sendMailLeavesNonSnakeCaseTagsAsLiteralText() throws Exception { stubMimeMessages(); letterService.sendMail(new MailRequest("Subject", - "{{ first_name }} {{FIRST_NAME}} {{first.name}} {{first_name}}")); + "{{ first_name }} {{FIRST_NAME}} {{first.name}} {{first_name}}"), UUID.randomUUID(), true); ArgumentCaptor captor = ArgumentCaptor.forClass(MimeMessage.class); verify(mailSender).send(captor.capture()); @@ -241,15 +216,14 @@ void sendMailLeavesNonSnakeCaseTagsAsLiteralText() throws Exception { @Test void sendMailWithMemberWithoutTeamOrSportLeavesTokensEmpty() throws Exception { - authenticateAs(UUID.randomUUID(), "admin"); - UUID memberId = UUID.randomUUID(); MemberEntity dan = member(memberId, "Dan", "Doe", "dan@example.com"); when(memberRepository.findAll()).thenReturn(List.of(dan)); when(transactionRepository.findAllByMemberId(memberId)).thenReturn(List.of()); stubMimeMessages(); - letterService.sendMail(new MailRequest("Subject", "[{{team_name}}][{{sport_name}}][{{balance}}]")); + letterService.sendMail(new MailRequest("Subject", "[{{team_name}}][{{sport_name}}][{{balance}}]"), + UUID.randomUUID(), true); ArgumentCaptor captor = ArgumentCaptor.forClass(MimeMessage.class); verify(mailSender).send(captor.capture()); @@ -258,8 +232,6 @@ void sendMailWithMemberWithoutTeamOrSportLeavesTokensEmpty() throws Exception { @Test void sendMailWithDirectorWithoutTeamShowsSportNameFromDirectorRole() throws Exception { - authenticateAs(UUID.randomUUID(), "admin"); - UUID memberId = UUID.randomUUID(); MemberEntity eve = member(memberId, "Eve", "Evans", "eve@example.com"); when(memberRepository.findAll()).thenReturn(List.of(eve)); @@ -270,7 +242,8 @@ void sendMailWithDirectorWithoutTeamShowsSportNameFromDirectorRole() throws Exce when(sportRepository.findById(sportId)).thenReturn(java.util.Optional.of(sport(sportId, "Swimming"))); stubMimeMessages(); - letterService.sendMail(new MailRequest("Subject", "[{{team_name}}][{{sport_name}}]")); + letterService.sendMail(new MailRequest("Subject", "[{{team_name}}][{{sport_name}}]"), + UUID.randomUUID(), true); ArgumentCaptor captor = ArgumentCaptor.forClass(MimeMessage.class); verify(mailSender).send(captor.capture()); @@ -284,13 +257,13 @@ void sendMailWrapsMessagingExceptionInMailDeliveryException() { LetterService brokenFromService = new LetterService(mailSender, "not a valid from address", memberRepository, sportRepository, teamRepository, directorRepository, trainerRepository, traineeRepository, transactionRepository); - authenticateAs(UUID.randomUUID(), "admin"); MemberEntity frank = member("Frank", "Foster", "frank@example.com"); when(memberRepository.findAll()).thenReturn(List.of(frank)); stubMimeMessages(); - assertThatThrownBy(() -> brokenFromService.sendMail(new MailRequest("Subject", "Body"))) + assertThatThrownBy(() -> brokenFromService.sendMail(new MailRequest("Subject", "Body"), + UUID.randomUUID(), true)) .isInstanceOf(MailDeliveryException.class) .hasMessageContaining("frank@example.com") .hasCauseInstanceOf(jakarta.mail.MessagingException.class); @@ -300,15 +273,13 @@ void sendMailWrapsMessagingExceptionInMailDeliveryException() { @Test void getPdfRendersOneLetterPagePerReceiverInSinglePdf() throws Exception { - authenticateAs(UUID.randomUUID(), "admin"); - MemberEntity alice = member(UUID.randomUUID(), "Alice", "Anderson", "alice@example.com", "1 Apple Ave", null, null, null); MemberEntity bob = member(UUID.randomUUID(), "Bob", "Brown", "bob@example.com", "2 Berry Blvd", null, null, null); when(memberRepository.findAll()).thenReturn(List.of(alice, bob)); - Resource pdf = letterService.getPdf(new PdfRequest("

Hi {{first_name}}

")); + Resource pdf = letterService.getPdf(new PdfRequest("

Hi {{first_name}}

"), UUID.randomUUID(), true); byte[] bytes = pdf.getContentAsByteArray(); assertThat(new String(bytes, 0, 5)).isEqualTo("%PDF-"); @@ -325,8 +296,6 @@ void getPdfRendersOneLetterPagePerReceiverInSinglePdf() throws Exception { @Test void getPdfReplacesTokensAndHandlesMissingAddress() throws Exception { - authenticateAs(UUID.randomUUID(), "admin"); - UUID memberId = UUID.randomUUID(); MemberEntity carol = member(memberId, "Carol", "Clark", "carol@example.com"); when(memberRepository.findAll()).thenReturn(List.of(carol)); @@ -334,7 +303,8 @@ void getPdfReplacesTokensAndHandlesMissingAddress() throws Exception { .thenReturn(List.of(transaction(memberId, 5000))); Resource pdf = letterService.getPdf( - new PdfRequest("

Balance of {{full_name}}: {{balance}}

[{{unknown_token}}]

")); + new PdfRequest("

Balance of {{full_name}}: {{balance}}

[{{unknown_token}}]

"), + UUID.randomUUID(), true); try (PDDocument document = PDDocument.load(pdf.getContentAsByteArray())) { String text = pageText(document, 1); @@ -344,12 +314,11 @@ void getPdfReplacesTokensAndHandlesMissingAddress() throws Exception { @Test void getPdfWithMalformedTemplateHtmlStillProducesPdf() throws Exception { - authenticateAs(UUID.randomUUID(), "admin"); - MemberEntity dan = member(UUID.randomUUID(), "Dan", "Doe", "dan@example.com"); when(memberRepository.findAll()).thenReturn(List.of(dan)); - Resource pdf = letterService.getPdf(new PdfRequest("

Hi {{first_name}}

unclosed")); + Resource pdf = letterService.getPdf(new PdfRequest("

Hi {{first_name}}

unclosed"), + UUID.randomUUID(), true); try (PDDocument document = PDDocument.load(pdf.getContentAsByteArray())) { assertThat(pageText(document, 1)).contains("Hi Dan", "unclosed"); @@ -357,15 +326,13 @@ void getPdfWithMalformedTemplateHtmlStillProducesPdf() throws Exception { } @Test - void getPdfWithNoReceiversReturnsValidEmptyPdf() throws Exception { - authenticateAs(UUID.randomUUID(), "trainee"); - when(memberRepository.findAllById(any())).thenReturn(List.of()); + void getPdfAsPlainMemberWithNoOrgRoleThrowsForbidden() { + UUID senderId = UUID.randomUUID(); - Resource pdf = letterService.getPdf(new PdfRequest("

Hi

")); + assertThatThrownBy(() -> letterService.getPdf(new PdfRequest("

Hi

"), senderId, false)) + .isInstanceOf(ForbiddenException.class); - try (PDDocument document = PDDocument.load(pdf.getContentAsByteArray())) { - assertThat(new PDFTextStripper().getText(document).trim()).isEmpty(); - } + verifyNoInteractions(memberRepository); } // --- helpers --- @@ -381,16 +348,6 @@ private void stubMimeMessages() { when(mailSender.createMimeMessage()).thenAnswer(invocation -> new MimeMessage((Session) null)); } - // resolveReceivers() only reads the JWT subject for director/trainer roles, so this stub is - // unused for admin/trainee auth and must be lenient to avoid Mockito's strict-stubs failure. - private static void authenticateAs(UUID memberId, String role) { - Jwt jwt = mock(Jwt.class); - Mockito.lenient().when(jwt.getSubject()).thenReturn(memberId.toString()); - Authentication authentication = new TestingAuthenticationToken(jwt, null, - List.of(new SimpleGrantedAuthority("ROLE_" + role))); - SecurityContextHolder.getContext().setAuthentication(authentication); - } - private static String extractHtml(MimeMessage message) throws Exception { Object content = message.getContent(); while (content instanceof MimeMultipart multipart) { diff --git a/web-client/src/api.ts b/web-client/src/api.ts index f470139..51ff12b 100644 --- a/web-client/src/api.ts +++ b/web-client/src/api.ts @@ -461,9 +461,9 @@ export interface paths { put?: never; /** * Send mail - * @description Sends a personalized mass email. The body carries a `subject` and an HTML `template`; the - * template's placeholder tokens are replaced with each receiver's data, and one email is sent - * per receiver. + * @description Sends a personalized mass email. The body carries a `subject` and an HTML `template`; + * placeholder tokens in both the subject and the template are replaced with each receiver's + * data, and one email is sent per receiver. * * Receivers are determined from the caller's highest role: * - **Admin**: all members. @@ -847,7 +847,11 @@ export interface components { }; /** @description Request body for sending a personalized mass email to the caller's receivers. */ MailRequest: { - /** @description Subject line of the email. */ + /** + * @description Subject line of the email. Must not be empty. Supports the same per-receiver + * placeholder tokens as the template; each token is replaced with that receiver's data + * before the email is sent. + */ subject: string; /** * @description HTML email body. Supports per-receiver placeholder tokens (see the operation