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/infra/docker-compose.yml b/infra/docker-compose.yml index 178c724..21367d3 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: 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-letter/build.gradle b/services/spring-letter/build.gradle index 5726a04..6cc9a62 100644 --- a/services/spring-letter/build.gradle +++ b/services/spring-letter/build.gradle @@ -48,7 +48,10 @@ 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' + 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/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/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 42d2c2e..41a0ebb 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..8a405d4 --- /dev/null +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/controller/LetterController.java @@ -0,0 +1,39 @@ +package tum.devoops.letterservice.controller; + +import org.springframework.core.io.Resource; +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; + +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 + public ResponseEntity sendMail(MailRequest mailRequest) { + letterService.sendMail(mailRequest); + return ResponseEntity.noContent().build(); + } + + @Override + public ResponseEntity getPdf(PdfRequest pdfRequest) { + Resource pdf = letterService.getPdf(pdfRequest); + 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/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/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/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 new file mode 100644 index 0000000..c520915 --- /dev/null +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/service/LetterService.java @@ -0,0 +1,266 @@ +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; +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.exception.PdfGenerationException; +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.io.ByteArrayOutputStream; +import java.io.IOException; +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 { + + // 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; + 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, + 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 sendMail(MailRequest mailRequest) { + String subject = mailRequest.getSubject(); + String template = mailRequest.getTemplate(); + + for (MemberEntity receiver : resolveReceivers()) { + Map tokens = tokensFor(receiver); + String personalizedSubject = replaceTags(subject, tokens); + String html = replaceTags(template, tokens); + try { + sendHtml(receiver.getEmail(), personalizedSubject, html); + } catch (MessagingException e) { + throw new MailDeliveryException("Failed to send mail to " + receiver.getEmail(), e); + } + } + } + + public Resource getPdf(PdfRequest pdfRequest) { + 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 { + 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); + mailSender.send(message); + } + + 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); + } + + 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/main/resources/application.properties b/services/spring-letter/src/main/resources/application.properties index a374c20..750aea4 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 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/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 353f40f..b23610f 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()); + } +} 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..a4b540a --- /dev/null +++ b/services/spring-letter/src/test/java/tum/devoops/letterservice/service/LetterServiceTest.java @@ -0,0 +1,446 @@ +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.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.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 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"); + + 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 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}}

")); + + 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)); + } + + // 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; + } +} 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)) {