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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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: |
Expand Down
2 changes: 2 additions & 0 deletions infra/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion infra/helm/team-devoops/files/realm-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@
"composite": true,
"composites": {
"client": {
"devops-client": ["Admin"]
"devops-client": ["Admin"],
"realm-management": ["manage-users"]
}
}
},
Expand Down
3 changes: 2 additions & 1 deletion infra/keycloak/realm-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@
"composite": true,
"composites": {
"client": {
"devops-client": ["Admin"]
"devops-client": ["Admin"],
"realm-management": ["manage-users"]
}
}
},
Expand Down
3 changes: 3 additions & 0 deletions services/spring-letter/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ default ResponseEntity<org.springframework.core.io.Resource> getPdf(

/**
* POST /letters/mail : Send mail
* Sends a personalized mass email. The body carries a &#x60;subject&#x60; and an HTML &#x60;template&#x60;; the template&#39;s placeholder tokens are replaced with each receiver&#39;s data, and one email is sent per receiver. Receivers are determined from the caller&#39;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 (&#x60;403&#x60;). Assumes a director directs exactly one sport, a trainer trains exactly one team, and a trainee belongs to exactly one team. Supported placeholder tokens (&#x60;{{snake_case}}&#x60;; an unknown or empty value resolves to an empty string): - Member: &#x60;{{first_name}}&#x60;, &#x60;{{last_name}}&#x60;, &#x60;{{full_name}}&#x60;, &#x60;{{email}}&#x60;, &#x60;{{address}}&#x60;, &#x60;{{phone_number}}&#x60;, &#x60;{{birthday}}&#x60;, &#x60;{{joining_date}}&#x60;. - Organization: &#x60;{{team_name}}&#x60;, &#x60;{{sport_name}}&#x60; — the receiver&#39;s team/sport, blank if none. - Finance: &#x60;{{balance}}&#x60; — the receiver&#39;s current balance, formatted (e.g. &#x60;€12.50&#x60;).
* Sends a personalized mass email. The body carries a &#x60;subject&#x60; and an HTML &#x60;template&#x60;; placeholder tokens in both the subject and the template are replaced with each receiver&#39;s data, and one email is sent per receiver. Receivers are determined from the caller&#39;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 (&#x60;403&#x60;). Assumes a director directs exactly one sport, a trainer trains exactly one team, and a trainee belongs to exactly one team. Supported placeholder tokens (&#x60;{{snake_case}}&#x60;; an unknown or empty value resolves to an empty string): - Member: &#x60;{{first_name}}&#x60;, &#x60;{{last_name}}&#x60;, &#x60;{{full_name}}&#x60;, &#x60;{{email}}&#x60;, &#x60;{{address}}&#x60;, &#x60;{{phone_number}}&#x60;, &#x60;{{birthday}}&#x60;, &#x60;{{joining_date}}&#x60;. - Organization: &#x60;{{team_name}}&#x60;, &#x60;{{sport_name}}&#x60; — the receiver&#39;s team/sport, blank if none. - Finance: &#x60;{{balance}}&#x60; — the receiver&#39;s current balance, formatted (e.g. &#x60;€12.50&#x60;).
*
* @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)
Expand All @@ -141,7 +141,7 @@ default ResponseEntity<org.springframework.core.io.Resource> 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."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@EnableMethodSecurity(proxyTargetClass = true)
public class SecurityConfig {

@Bean
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Void> sendMail(MailRequest mailRequest) {
letterService.sendMail(mailRequest);
return ResponseEntity.noContent().build();
}

@Override
public ResponseEntity<Resource> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading