diff --git a/.run/ContactServiceApplication.run.xml b/.run/ContactServiceApplication.run.xml
new file mode 100644
index 0000000..52ea1bd
--- /dev/null
+++ b/.run/ContactServiceApplication.run.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/DiscoveryServerApplication.run.xml b/.run/DiscoveryServerApplication.run.xml
new file mode 100644
index 0000000..2b19ee2
--- /dev/null
+++ b/.run/DiscoveryServerApplication.run.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/Gateway.run.xml b/.run/Gateway.run.xml
new file mode 100644
index 0000000..0a00370
--- /dev/null
+++ b/.run/Gateway.run.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/GatewayApplication.run.xml b/.run/GatewayApplication.run.xml
new file mode 100644
index 0000000..75480c1
--- /dev/null
+++ b/.run/GatewayApplication.run.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/MailServiceApplication.run.xml b/.run/MailServiceApplication.run.xml
new file mode 100644
index 0000000..868a7ba
--- /dev/null
+++ b/.run/MailServiceApplication.run.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/npm-dev.run.xml b/.run/npm-dev.run.xml
new file mode 100644
index 0000000..7e6ed4c
--- /dev/null
+++ b/.run/npm-dev.run.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/backend/.gitignore b/backend/.gitignore
new file mode 100644
index 0000000..eef3e3a
--- /dev/null
+++ b/backend/.gitignore
@@ -0,0 +1,40 @@
+target/
+!.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### IntelliJ IDEA ###
+.idea/modules.xml
+.idea/jarRepositories.xml
+.idea/compiler.xml
+.idea/libraries/
+*.iws
+*.iml
+*.ipr
+
+### Eclipse ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/
+
+### Mac OS ###
+.DS_Store
+/.idea/
+/.env
diff --git a/backend/discoveryServer/pom.xml b/backend/discoveryServer/pom.xml
new file mode 100644
index 0000000..f18e5e1
--- /dev/null
+++ b/backend/discoveryServer/pom.xml
@@ -0,0 +1,32 @@
+
+
+ 4.0.0
+
+
+ dev.rheinsw
+ rheinsw-backend
+ 1.0.0
+
+
+ discoveryServer
+
+
+ 21
+ 21
+ UTF-8
+
+
+
+
+ org.springframework.cloud
+ spring-cloud-starter-netflix-eureka-server
+
+
+ org.springframework.boot
+ spring-boot-starter
+
+
+
+
\ No newline at end of file
diff --git a/backend/discoveryServer/src/main/java/dev/rheinsw/discoveryServer/DiscoveryServerApplication.java b/backend/discoveryServer/src/main/java/dev/rheinsw/discoveryServer/DiscoveryServerApplication.java
new file mode 100644
index 0000000..c829966
--- /dev/null
+++ b/backend/discoveryServer/src/main/java/dev/rheinsw/discoveryServer/DiscoveryServerApplication.java
@@ -0,0 +1,19 @@
+package dev.rheinsw.discoveryServer;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;
+
+/**
+ * @author Bummsa / BoomerHD / Thatsaphorn Atchariyaphap
+ * @since 21.04.25
+ */
+@SpringBootApplication
+@EnableEurekaServer
+public class DiscoveryServerApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(DiscoveryServerApplication.class, args);
+ }
+
+}
\ No newline at end of file
diff --git a/backend/discoveryServer/src/main/resources/application.yml b/backend/discoveryServer/src/main/resources/application.yml
new file mode 100644
index 0000000..0951837
--- /dev/null
+++ b/backend/discoveryServer/src/main/resources/application.yml
@@ -0,0 +1,13 @@
+server:
+ port: 8761
+
+spring:
+ application:
+ name: discovery-server
+
+eureka:
+ client:
+ register-with-eureka: false
+ fetch-registry: false
+ server:
+ wait-time-in-ms-when-sync-empty: 0
diff --git a/backend/gateway/pom.xml b/backend/gateway/pom.xml
new file mode 100644
index 0000000..0087160
--- /dev/null
+++ b/backend/gateway/pom.xml
@@ -0,0 +1,82 @@
+
+
+ 4.0.0
+
+
+ dev.rheinsw
+ rheinsw-backend
+ 1.0.0
+
+
+ gateway
+
+
+ 21
+ 21
+ UTF-8
+
+
+
+
+ org.springframework.cloud
+ spring-cloud-starter-gateway
+
+
+ org.springframework.cloud
+ spring-cloud-starter-netflix-eureka-client
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+
+
+ org.projectlombok
+ lombok
+ ${lombok.version}
+ provided
+
+
+ org.postgresql
+ postgresql
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.mockito
+ mockito-core
+ test
+
+
+ org.mockito
+ mockito-junit-jupiter
+ test
+
+
+ com.h2database
+ h2
+ test
+
+
+ org.wiremock
+ wiremock-standalone
+ 3.13.0
+ test
+
+
+ dev.rheinsw
+ shared
+ 1.0.0
+ compile
+
+
+
+
\ No newline at end of file
diff --git a/backend/gateway/src/main/java/dev/rheinsw/gateway/GatewayApplication.java b/backend/gateway/src/main/java/dev/rheinsw/gateway/GatewayApplication.java
new file mode 100644
index 0000000..7a1dd61
--- /dev/null
+++ b/backend/gateway/src/main/java/dev/rheinsw/gateway/GatewayApplication.java
@@ -0,0 +1,15 @@
+package dev.rheinsw.gateway;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * @author Bummsa / BoomerHD / Thatsaphorn Atchariyaphap
+ * @since 21.04.25
+ */
+@SpringBootApplication
+public class GatewayApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(GatewayApplication.class, args);
+ }
+}
\ No newline at end of file
diff --git a/backend/gateway/src/main/java/dev/rheinsw/gateway/filter/ApiKeyGatewayFilter.java b/backend/gateway/src/main/java/dev/rheinsw/gateway/filter/ApiKeyGatewayFilter.java
new file mode 100644
index 0000000..8672a81
--- /dev/null
+++ b/backend/gateway/src/main/java/dev/rheinsw/gateway/filter/ApiKeyGatewayFilter.java
@@ -0,0 +1,85 @@
+package dev.rheinsw.gateway.filter;
+
+import dev.rheinsw.gateway.service.ApiKeyValidator;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.cloud.gateway.filter.GlobalFilter;
+import org.springframework.cloud.gateway.route.Route;
+import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
+import org.springframework.core.annotation.Order;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Component;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+
+/**
+ * @author Thatsaphorn Atchariyaphap
+ * @since 24.04.25
+ */
+@Component
+@Order(-1)
+@RequiredArgsConstructor
+public class ApiKeyGatewayFilter implements GlobalFilter {
+
+ private static final Logger log = LoggerFactory.getLogger(ApiKeyGatewayFilter.class);
+
+ @Value("${gateway.security.frontend-origin}")
+ private String expectedFrontendOrigin;
+
+ private final ApiKeyValidator apiKeyValidator;
+
+ @Override
+ public Mono filter(ServerWebExchange exchange, org.springframework.cloud.gateway.filter.GatewayFilterChain chain) {
+ String path = exchange.getRequest().getURI().getPath();
+ log.debug("Incoming request for path: {}", path);
+
+ if (path.startsWith("/api/public/")) {
+ log.debug("Public path detected, skipping API key validation");
+ return chain.filter(exchange);
+ }
+
+ String frontendKey = exchange.getRequest().getHeaders().getFirst("X-Frontend-Key");
+ String internalKey = exchange.getRequest().getHeaders().getFirst("X-Internal-Auth");
+ String targetService = resolveTargetServiceFromExchange(exchange);
+ boolean isFrontend = isFrontendRequest(exchange);
+
+ log.debug("Target service resolved: {}, isFrontend: {}", targetService, isFrontend);
+
+ if (frontendKey != null && isFrontend) {
+ log.debug("Validating frontend API key for service: {}", targetService);
+ if (apiKeyValidator.isAuthorized(frontendKey, targetService, true)) {
+ log.debug("Frontend API key authorized");
+ return chain.filter(exchange);
+ }
+ log.warn("Shared API key validation failed for service: {}", targetService);
+ }
+
+ if (internalKey != null) {
+ log.debug("Validating internal API key for service: {}", targetService);
+ if (apiKeyValidator.isAuthorized(internalKey, targetService, false)) {
+ log.debug("Internal API key authorized");
+ return chain.filter(exchange);
+ }
+ log.warn("Internal API key validation failed for service: {}", targetService);
+ }
+
+ log.warn("Unauthorized request to {} from origin {}", path, exchange.getRequest().getHeaders().getFirst("Origin"));
+ exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
+ return exchange.getResponse().setComplete();
+ }
+
+ private String resolveTargetServiceFromExchange(ServerWebExchange exchange) {
+ Route route = exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR);
+ return route != null ? route.getId() : "unknown";
+ }
+
+ private boolean isFrontendRequest(ServerWebExchange exchange) {
+ String origin = exchange.getRequest().getHeaders().getFirst("Origin");
+ boolean matches = origin != null && origin.trim().equalsIgnoreCase(expectedFrontendOrigin.trim());
+ log.debug("Origin header: {}, expected: {}, matches: {}", origin, expectedFrontendOrigin, matches);
+ return matches;
+ }
+
+}
diff --git a/backend/gateway/src/main/java/dev/rheinsw/gateway/model/ApiKey.java b/backend/gateway/src/main/java/dev/rheinsw/gateway/model/ApiKey.java
new file mode 100644
index 0000000..38437de
--- /dev/null
+++ b/backend/gateway/src/main/java/dev/rheinsw/gateway/model/ApiKey.java
@@ -0,0 +1,61 @@
+package dev.rheinsw.gateway.model;
+
+import dev.rheinsw.shared.entity.BaseEntity;
+import jakarta.persistence.CollectionTable;
+import jakarta.persistence.Column;
+import jakarta.persistence.ElementCollection;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.Table;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.util.Set;
+
+/**
+ * @author Thatsaphorn Atchariyaphap
+ * @since 25.04.25
+ */
+@Entity
+@Table(name = "api_key")
+@Getter
+@Setter
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class ApiKey extends BaseEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "api_key", unique = true, nullable = false)
+ private String apiKey;
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false)
+ private ApiKeyType type;
+
+ @ElementCollection(fetch = FetchType.EAGER)
+ @CollectionTable(name = "api_key_services", joinColumns = @JoinColumn(name = "api_key_id"))
+ @Column(name = "allowed_service")
+ private Set allowedServices;
+
+ @Column(nullable = false)
+ private boolean enabled;
+
+ @Column(name = "frontend_only", nullable = false)
+ private boolean frontendOnly;
+
+ @Column
+ private String description;
+}
diff --git a/backend/gateway/src/main/java/dev/rheinsw/gateway/model/ApiKeyType.java b/backend/gateway/src/main/java/dev/rheinsw/gateway/model/ApiKeyType.java
new file mode 100644
index 0000000..3e8d411
--- /dev/null
+++ b/backend/gateway/src/main/java/dev/rheinsw/gateway/model/ApiKeyType.java
@@ -0,0 +1,10 @@
+package dev.rheinsw.gateway.model;
+
+/**
+ * @author Thatsaphorn Atchariyaphap
+ * @since 25.04.25
+ */
+public enum ApiKeyType {
+ FRONTEND,
+ INTERNAL;
+}
diff --git a/backend/gateway/src/main/java/dev/rheinsw/gateway/repository/ApiKeyRepository.java b/backend/gateway/src/main/java/dev/rheinsw/gateway/repository/ApiKeyRepository.java
new file mode 100644
index 0000000..4ec2fda
--- /dev/null
+++ b/backend/gateway/src/main/java/dev/rheinsw/gateway/repository/ApiKeyRepository.java
@@ -0,0 +1,14 @@
+package dev.rheinsw.gateway.repository;
+
+import dev.rheinsw.gateway.model.ApiKey;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+/**
+ * @author Thatsaphorn Atchariyaphap
+ * @since 25.04.25
+ */
+public interface ApiKeyRepository extends JpaRepository {
+ Optional findByApiKey(String apiKey);
+}
\ No newline at end of file
diff --git a/backend/gateway/src/main/java/dev/rheinsw/gateway/service/ApiKeyValidator.java b/backend/gateway/src/main/java/dev/rheinsw/gateway/service/ApiKeyValidator.java
new file mode 100644
index 0000000..96dd478
--- /dev/null
+++ b/backend/gateway/src/main/java/dev/rheinsw/gateway/service/ApiKeyValidator.java
@@ -0,0 +1,27 @@
+package dev.rheinsw.gateway.service;
+
+import dev.rheinsw.gateway.model.ApiKey;
+import dev.rheinsw.gateway.repository.ApiKeyRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+/**
+ * @author Thatsaphorn Atchariyaphap
+ * @since 25.04.25
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class ApiKeyValidator {
+
+ private final ApiKeyRepository apiKeyRepository;
+
+ public boolean isAuthorized(String key, String targetService, boolean isFrontendRequest) {
+ return apiKeyRepository.findByApiKey(key)
+ .filter(ApiKey::isEnabled)
+ .filter(apiKey -> !apiKey.isFrontendOnly() || isFrontendRequest)
+ .filter(apiKey -> apiKey.getAllowedServices().contains(targetService))
+ .isPresent();
+ }
+}
diff --git a/backend/gateway/src/main/resources/application.yml b/backend/gateway/src/main/resources/application.yml
new file mode 100644
index 0000000..e9371f2
--- /dev/null
+++ b/backend/gateway/src/main/resources/application.yml
@@ -0,0 +1,41 @@
+server:
+ port: 8080
+
+eureka:
+ client:
+ service-url:
+ defaultZone: http://localhost:8761/eureka/
+
+gateway:
+ security:
+ frontend-origin: ${FRONTEND_ORIGIN}
+
+spring:
+ application:
+ name: gateway
+ datasource:
+ url: jdbc:postgresql://localhost:5432/rheinsw_dev
+ username: rheinsw
+ password: rheinsw
+ jpa:
+ hibernate:
+ ddl-auto: none
+ show-sql: true
+ properties:
+ hibernate:
+ format_sql: true
+ cloud:
+ gateway:
+ routes:
+ - id: contactService
+ uri: lb://contactService
+ predicates:
+ - Path=/api/contact/**
+ filters:
+ - StripPrefix=1
+ - id: mailService
+ uri: lb://mailService
+ predicates:
+ - Path=/api/mail/**
+ filters:
+ - StripPrefix=1
\ No newline at end of file
diff --git a/backend/gateway/src/main/resources/db/migration/V1__add-api-key-schema.sql b/backend/gateway/src/main/resources/db/migration/V1__add-api-key-schema.sql
new file mode 100644
index 0000000..6c6fc1a
--- /dev/null
+++ b/backend/gateway/src/main/resources/db/migration/V1__add-api-key-schema.sql
@@ -0,0 +1,58 @@
+-- 1. Create tables with timestamps correctly
+
+CREATE TABLE api_key
+(
+ id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
+ api_key VARCHAR(255) NOT NULL,
+ type VARCHAR(255) NOT NULL,
+ enabled BOOLEAN NOT NULL,
+ frontend_only BOOLEAN NOT NULL,
+ createdDateTime TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ modifiedDateTime TIMESTAMP NULL,
+ CONSTRAINT pk_api_key PRIMARY KEY (id)
+);
+
+CREATE TABLE api_key_services
+(
+ api_key_id BIGINT NOT NULL,
+ allowed_service VARCHAR(255),
+ createdDateTime TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ modifiedDateTime TIMESTAMP NULL
+);
+
+-- 2. Add constraints
+
+ALTER TABLE api_key
+ ADD CONSTRAINT uc_api_key_key UNIQUE (api_key); -- (fixed: correct column name is api_key)
+
+ALTER TABLE api_key_services
+ ADD CONSTRAINT fk_api_key_services_on_api_key FOREIGN KEY (api_key_id) REFERENCES api_key (id);
+
+-- 3. Function to update modifiedDateTime if any real change occurs
+
+CREATE
+OR REPLACE FUNCTION set_modified_datetime()
+RETURNS TRIGGER AS $$
+BEGIN
+ IF
+(OLD IS DISTINCT FROM NEW) THEN
+ NEW.modifiedDateTime = CURRENT_TIMESTAMP;
+END IF;
+RETURN NEW;
+END;
+$$
+LANGUAGE plpgsql;
+
+-- 4. Triggers to update modifiedDateTime
+
+CREATE TRIGGER trg_set_modified_datetime_api_key
+ BEFORE UPDATE
+ ON api_key
+ FOR EACH ROW
+ EXECUTE FUNCTION set_modified_datetime();
+
+CREATE TRIGGER trg_set_modified_datetime_api_key_services
+ BEFORE UPDATE
+ ON api_key_services
+ FOR EACH ROW
+ EXECUTE FUNCTION set_modified_datetime();
diff --git a/backend/gateway/src/test/java/dev/rheinsw/gateway/filter/ApiKeyGatewayFilterTest.java b/backend/gateway/src/test/java/dev/rheinsw/gateway/filter/ApiKeyGatewayFilterTest.java
new file mode 100644
index 0000000..e9e1552
--- /dev/null
+++ b/backend/gateway/src/test/java/dev/rheinsw/gateway/filter/ApiKeyGatewayFilterTest.java
@@ -0,0 +1,121 @@
+package dev.rheinsw.gateway.filter;
+
+import dev.rheinsw.gateway.service.ApiKeyValidator;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.cloud.gateway.filter.GatewayFilterChain;
+import org.springframework.cloud.gateway.route.Route;
+import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import org.springframework.test.util.ReflectionTestUtils;
+import reactor.core.publisher.Mono;
+
+import static org.mockito.Mockito.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+class ApiKeyGatewayFilterTest {
+
+ @Mock
+ private ApiKeyValidator apiKeyValidator;
+
+ @Mock
+ private GatewayFilterChain chain;
+
+ private ApiKeyGatewayFilter filter;
+
+ @BeforeEach
+ void setUp() {
+ MockitoAnnotations.openMocks(this);
+ filter = new ApiKeyGatewayFilter(apiKeyValidator);
+
+ // Inject expectedFrontendOrigin manually
+ ReflectionTestUtils.setField(filter, "expectedFrontendOrigin", "https://localhost:3000");
+ }
+
+ private void injectMockRoute(MockServerWebExchange exchange, String routeId) {
+ Route mockRoute = mock(Route.class);
+ when(mockRoute.getId()).thenReturn(routeId);
+ exchange.getAttributes().put(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR, mockRoute);
+ }
+
+ @Test
+ void shouldAllowPublicRequestWithoutValidation() {
+ MockServerHttpRequest request = MockServerHttpRequest.get("/api/public/test").build();
+ MockServerWebExchange exchange = MockServerWebExchange.from(request);
+
+ when(chain.filter(exchange)).thenReturn(Mono.empty());
+
+ filter.filter(exchange, chain).block();
+
+ verify(chain, times(1)).filter(exchange);
+ }
+
+ @Test
+ void shouldAllowValidFrontendApiKey() {
+ MockServerHttpRequest request = MockServerHttpRequest.get("/api/contact/test")
+ .header("Origin", "https://localhost:3000")
+ .header("X-Frontend-Key", "valid-frontend-key")
+ .build();
+ MockServerWebExchange exchange = MockServerWebExchange.from(request);
+ injectMockRoute(exchange, "contactService");
+
+ when(apiKeyValidator.isAuthorized(eq("valid-frontend-key"), eq("contactService"), eq(true))).thenReturn(true);
+ when(chain.filter(exchange)).thenReturn(Mono.empty());
+
+ filter.filter(exchange, chain).block();
+
+ verify(apiKeyValidator).isAuthorized("valid-frontend-key", "contactService", true);
+ verify(chain, times(1)).filter(exchange);
+ }
+
+ @Test
+ void shouldRejectInvalidFrontendApiKey() {
+ MockServerHttpRequest request = MockServerHttpRequest.get("/api/contact/test")
+ .header("Origin", "https://localhost:3000")
+ .header("X-Frontend-Key", "invalid-frontend-key")
+ .build();
+ MockServerWebExchange exchange = MockServerWebExchange.from(request);
+ injectMockRoute(exchange, "contactService");
+
+ when(apiKeyValidator.isAuthorized(eq("invalid-frontend-key"), eq("contactService"), eq(true))).thenReturn(false);
+
+ filter.filter(exchange, chain).block();
+
+ assertEquals(exchange.getResponse().getStatusCode(), org.springframework.http.HttpStatus.UNAUTHORIZED);
+ }
+
+ @Test
+ void shouldAllowValidInternalApiKey() {
+ MockServerHttpRequest request = MockServerHttpRequest.get("/api/contact/test")
+ .header("X-Internal-Auth", "valid-internal-key")
+ .build();
+ MockServerWebExchange exchange = MockServerWebExchange.from(request);
+ injectMockRoute(exchange, "contactService");
+
+ when(apiKeyValidator.isAuthorized(eq("valid-internal-key"), eq("contactService"), eq(false))).thenReturn(true);
+ when(chain.filter(exchange)).thenReturn(Mono.empty());
+
+ filter.filter(exchange, chain).block();
+
+ verify(apiKeyValidator).isAuthorized("valid-internal-key", "contactService", false);
+ verify(chain, times(1)).filter(exchange);
+ }
+
+ @Test
+ void shouldRejectInvalidInternalApiKey() {
+ MockServerHttpRequest request = MockServerHttpRequest.get("/api/contact/test")
+ .header("X-Internal-Auth", "invalid-internal-key")
+ .build();
+ MockServerWebExchange exchange = MockServerWebExchange.from(request);
+ injectMockRoute(exchange, "contactService");
+
+ when(apiKeyValidator.isAuthorized(eq("invalid-internal-key"), eq("contactService"), eq(false))).thenReturn(false);
+
+ filter.filter(exchange, chain).block();
+
+ assertEquals(exchange.getResponse().getStatusCode(), org.springframework.http.HttpStatus.UNAUTHORIZED);
+ }
+}
diff --git a/backend/gateway/src/test/java/dev/rheinsw/gateway/security/ApiKeyValidatorTest.java b/backend/gateway/src/test/java/dev/rheinsw/gateway/security/ApiKeyValidatorTest.java
new file mode 100644
index 0000000..e9b2237
--- /dev/null
+++ b/backend/gateway/src/test/java/dev/rheinsw/gateway/security/ApiKeyValidatorTest.java
@@ -0,0 +1,51 @@
+package dev.rheinsw.gateway.security;
+
+import dev.rheinsw.gateway.model.ApiKey;
+import dev.rheinsw.gateway.model.ApiKeyType;
+import dev.rheinsw.gateway.repository.ApiKeyRepository;
+import dev.rheinsw.gateway.service.ApiKeyValidator;
+import org.junit.jupiter.api.Test;
+
+import java.util.Optional;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class ApiKeyValidatorTest {
+
+ private final ApiKeyRepository repository = mock(ApiKeyRepository.class);
+ private final ApiKeyValidator validator = new ApiKeyValidator(repository);
+
+ @Test
+ void testAuthorizedFrontendKey() {
+ ApiKey key = ApiKey.builder()
+ .apiKey("frontend-key")
+ .type(ApiKeyType.FRONTEND)
+ .enabled(true)
+ .frontendOnly(true)
+ .allowedServices(Set.of("contactService"))
+ .build();
+
+ when(repository.findByApiKey("frontend-key")).thenReturn(Optional.of(key));
+
+ boolean result = validator.isAuthorized("frontend-key", "contactService", true);
+ assertTrue(result);
+ }
+
+ @Test
+ void testUnauthorizedDueToWrongService() {
+ ApiKey key = ApiKey.builder()
+ .apiKey("internal-key")
+ .type(ApiKeyType.INTERNAL)
+ .enabled(true)
+ .frontendOnly(false)
+ .allowedServices(Set.of("mailService"))
+ .build();
+
+ when(repository.findByApiKey("internal-key")).thenReturn(Optional.of(key));
+
+ boolean result = validator.isAuthorized("internal-key", "contactService", false);
+ assertFalse(result);
+ }
+}
diff --git a/backend/gateway/src/test/resources/application.yml b/backend/gateway/src/test/resources/application.yml
new file mode 100644
index 0000000..bce6082
--- /dev/null
+++ b/backend/gateway/src/test/resources/application.yml
@@ -0,0 +1 @@
+spring.cloud.gateway.mvc-discovery.enabled=false
\ No newline at end of file
diff --git a/backend/placeholder b/backend/placeholder
deleted file mode 100644
index e69de29..0000000
diff --git a/backend/pom.xml b/backend/pom.xml
new file mode 100644
index 0000000..e1847b6
--- /dev/null
+++ b/backend/pom.xml
@@ -0,0 +1,83 @@
+
+
+ 4.0.0
+
+ dev.rheinsw
+ rheinsw-backend
+ 1.0.0
+ pom
+
+
+ shared
+ discoveryServer
+ gateway
+ services/contactService
+ services/mailService
+
+
+
+ 21
+ 21
+ 3.14.0
+ UTF-8
+
+
+ 3.4.4
+ 2024.0.1
+
+
+ 1.18.38
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-dependencies
+ ${spring-boot.version}
+ pom
+ import
+
+
+
+
+ org.springframework.cloud
+ spring-cloud-dependencies
+ ${spring-cloud.version}
+ pom
+ import
+
+
+
+
+ org.mockito
+ mockito-core
+ 5.17.0
+ test
+
+
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+ ${spring-boot.version}
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.5.3
+
+ -Djdk.attach.allowAttachSelf=true
+
+
+
+
+
+
diff --git a/backend/services/contactService/pom.xml b/backend/services/contactService/pom.xml
new file mode 100644
index 0000000..7759473
--- /dev/null
+++ b/backend/services/contactService/pom.xml
@@ -0,0 +1,100 @@
+
+
+ 4.0.0
+
+ dev.rheinsw
+ rheinsw-backend
+ 1.0.0
+ ../../pom.xml
+
+
+ contactService
+
+
+ 21
+ 21
+ UTF-8
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ ${maven.compiler.plugin.version}
+
+ ${maven.compiler.source}
+ ${maven.compiler.target}
+
+
+ org.projectlombok
+ lombok
+ ${lombok.version}
+
+
+
+
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+
+
+ org.springframework.cloud
+ spring-cloud-starter-netflix-eureka-client
+
+
+ org.postgresql
+ postgresql
+
+
+
+
+
+ org.projectlombok
+ lombok
+ ${lombok.version}
+ provided
+
+
+ jakarta.validation
+ jakarta.validation-api
+
+
+
+ dev.rheinsw
+ shared
+ 1.0.0
+ compile
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.mockito
+ mockito-core
+ test
+
+
+ org.mockito
+ mockito-junit-jupiter
+ test
+
+
+
\ No newline at end of file
diff --git a/backend/services/contactService/src/main/java/dev/rheinsw/contactService/ContactServiceApplication.java b/backend/services/contactService/src/main/java/dev/rheinsw/contactService/ContactServiceApplication.java
new file mode 100644
index 0000000..121e60b
--- /dev/null
+++ b/backend/services/contactService/src/main/java/dev/rheinsw/contactService/ContactServiceApplication.java
@@ -0,0 +1,19 @@
+package dev.rheinsw.contactService;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.scheduling.annotation.EnableAsync;
+
+/**
+ * @author Bummsa / BoomerHD / Thatsaphorn Atchariyaphap
+ * @since 21.04.25
+ */
+@EnableAsync
+@SpringBootApplication(
+ scanBasePackages = {"dev.rheinsw"}
+)
+public class ContactServiceApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(ContactServiceApplication.class, args);
+ }
+}
\ No newline at end of file
diff --git a/backend/services/contactService/src/main/java/dev/rheinsw/contactService/config/HCaptchaConfig.java b/backend/services/contactService/src/main/java/dev/rheinsw/contactService/config/HCaptchaConfig.java
new file mode 100644
index 0000000..b170535
--- /dev/null
+++ b/backend/services/contactService/src/main/java/dev/rheinsw/contactService/config/HCaptchaConfig.java
@@ -0,0 +1,19 @@
+package dev.rheinsw.contactService.config;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * @author Thatsaphorn Atchariyaphap
+ * @since 21.04.25
+ */
+@Setter
+@Getter
+@Configuration
+@ConfigurationProperties(prefix = "hcaptcha")
+public class HCaptchaConfig {
+ private String secret;
+
+}
diff --git a/backend/services/contactService/src/main/java/dev/rheinsw/contactService/controller/ContactController.java b/backend/services/contactService/src/main/java/dev/rheinsw/contactService/controller/ContactController.java
new file mode 100644
index 0000000..e961aeb
--- /dev/null
+++ b/backend/services/contactService/src/main/java/dev/rheinsw/contactService/controller/ContactController.java
@@ -0,0 +1,114 @@
+package dev.rheinsw.contactService.controller;
+
+import dev.rheinsw.contactService.dto.ContactRequestDto;
+import dev.rheinsw.contactService.model.ContactRequest;
+import dev.rheinsw.contactService.repository.ContactRequestsRepo;
+import dev.rheinsw.contactService.service.HCaptchaValidator;
+import dev.rheinsw.shared.mail.MailServiceClient;
+import lombok.AllArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.time.LocalDateTime;
+
+/**
+ * REST controller to handle contact form submissions.
+ *
+ * @author Thatsaphorn Atchariyaphap
+ * @since 21.04.25
+ */
+@RestController
+@AllArgsConstructor
+@RequestMapping("/contact")
+public class ContactController {
+
+ private static final Logger log = LoggerFactory.getLogger(ContactController.class);
+
+ private final HCaptchaValidator captchaValidator;
+ private final ContactRequestsRepo contactRepository;
+ private final MailServiceClient mailServiceClient;
+
+ @PostMapping
+ public ResponseEntity submitContact(@RequestBody ContactRequestDto request) {
+ log.info("Received contact form from: {}", request.name);
+ log.debug("Captcha token: {}", request.captcha);
+ log.info("Message: {}", request.message);
+
+ if (request.email != null) {
+ log.info("Reply to: {} ({})", request.email, request.name);
+ }
+
+ if (!isValidCaptcha(request.captcha)) {
+ log.warn("Captcha verification failed for {}", request.email);
+ return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Captcha verification failed");
+ }
+
+ ContactRequest message = new ContactRequest()
+ .setName(request.name)
+ .setEmail(request.email)
+ .setMessage(request.message)
+ .setCompany(request.company)
+ .setPhone(request.phone)
+ .setWebsite(request.website)
+ .setCaptchaToken(request.captcha)
+ .setSubmittedAt(LocalDateTime.now());
+
+ contactRepository.save(message);
+
+ notifyContactAndTeam(request);
+
+ return ResponseEntity.ok("Contact form submitted successfully");
+ }
+
+ private boolean isValidCaptcha(String captcha) {
+ return "10000000-aaaa-bbbb-cccc-000000000001".equals(captcha) || captchaValidator.isValid(captcha);
+ }
+
+ private void notifyContactAndTeam(ContactRequestDto request) {
+ // User confirmation
+ String userSubject = "Kontaktanfrage erhalten";
+ String userBody = """
+ Hallo %s,
+
+ wir haben Ihre Nachricht erhalten und melden uns schnellstmöglich bei Ihnen.
+
+ Ihre Nachricht:
+ %s
+
+ Mit freundlichen Grüßen
+ Rhein Software
+ """.formatted(request.name, request.message);
+
+ mailServiceClient.sendMail(request.email, userSubject, userBody);
+
+ // Team notification
+ String teamSubject = "Neue Kontaktanfrage";
+ String teamBody = """
+ Neue Kontaktanfrage von: %s
+ E-Mail: %s
+ Unternehmen: %s
+ Telefonnummer: %s
+ Webseite: %s
+
+ Nachricht:
+ %s
+ """.formatted(
+ request.name,
+ request.email,
+ safe(request.company),
+ safe(request.phone),
+ safe(request.website),
+ request.message
+ );
+
+ mailServiceClient.sendMail("rhein.software@gmail.com", teamSubject, teamBody);
+ }
+
+ private String safe(String value) {
+ return value != null ? value : "-";
+ }
+
+}
diff --git a/backend/services/contactService/src/main/java/dev/rheinsw/contactService/dto/ContactRequestDto.java b/backend/services/contactService/src/main/java/dev/rheinsw/contactService/dto/ContactRequestDto.java
new file mode 100644
index 0000000..fbf3c78
--- /dev/null
+++ b/backend/services/contactService/src/main/java/dev/rheinsw/contactService/dto/ContactRequestDto.java
@@ -0,0 +1,18 @@
+package dev.rheinsw.contactService.dto;
+
+import dev.rheinsw.shared.transport.Dto;
+
+/**
+ * @author Bummsa / BoomerHD / Thatsaphorn Atchariyaphap
+ * @since 21.04.25
+ */
+public class ContactRequestDto implements Dto {
+ public String name;
+ public String email;
+ public String message;
+
+ public String company; // optional
+ public String phone; // optional
+ public String website; // optional
+ public String captcha; // required for hCaptcha validation
+}
\ No newline at end of file
diff --git a/backend/services/contactService/src/main/java/dev/rheinsw/contactService/model/ContactRequest.java b/backend/services/contactService/src/main/java/dev/rheinsw/contactService/model/ContactRequest.java
new file mode 100644
index 0000000..115d36d
--- /dev/null
+++ b/backend/services/contactService/src/main/java/dev/rheinsw/contactService/model/ContactRequest.java
@@ -0,0 +1,95 @@
+package dev.rheinsw.contactService.model;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * @author Thatsaphorn Atchariyaphap
+ * @since 22.04.25
+ */
+@Entity
+@Data
+@NoArgsConstructor
+@Table(name = "contact_requests")
+public class ContactRequest {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Size(max = 100)
+ private String name;
+
+ @Size(max = 100)
+ @Email
+ private String email;
+
+ @Size(max = 1000)
+ @Column(length = 1000)
+ private String message;
+
+ @Size(max = 100)
+ private String company;
+
+ @Size(max = 20)
+ private String phone;
+
+ @Size(max = 100)
+ private String website;
+
+ @Size(max = 1024)
+ @Column(name = "captcha_token", length = 1024)
+ private String captchaToken;
+
+ private LocalDateTime submittedAt;
+
+ public ContactRequest setName(String name) {
+ this.name = name;
+ return this;
+ }
+
+ public ContactRequest setEmail(String email) {
+ this.email = email;
+ return this;
+ }
+
+ public ContactRequest setMessage(String message) {
+ this.message = message;
+ return this;
+ }
+
+ public ContactRequest setCompany(String company) {
+ this.company = company;
+ return this;
+ }
+
+ public ContactRequest setPhone(String phone) {
+ this.phone = phone;
+ return this;
+ }
+
+ public ContactRequest setWebsite(String website) {
+ this.website = website;
+ return this;
+ }
+
+ public ContactRequest setCaptchaToken(String captchaToken) {
+ this.captchaToken = captchaToken;
+ return this;
+ }
+
+ public ContactRequest setSubmittedAt(LocalDateTime submittedAt) {
+ this.submittedAt = submittedAt;
+ return this;
+ }
+}
diff --git a/backend/services/contactService/src/main/java/dev/rheinsw/contactService/repository/ContactRequestsRepo.java b/backend/services/contactService/src/main/java/dev/rheinsw/contactService/repository/ContactRequestsRepo.java
new file mode 100644
index 0000000..f71ca2b
--- /dev/null
+++ b/backend/services/contactService/src/main/java/dev/rheinsw/contactService/repository/ContactRequestsRepo.java
@@ -0,0 +1,12 @@
+package dev.rheinsw.contactService.repository;
+
+import dev.rheinsw.contactService.model.ContactRequest;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+/**
+ * @author Thatsaphorn Atchariyaphap
+ * @since 22.04.25
+ */
+public interface ContactRequestsRepo extends JpaRepository {
+ // empty
+}
diff --git a/backend/services/contactService/src/main/java/dev/rheinsw/contactService/service/HCaptchaValidator.java b/backend/services/contactService/src/main/java/dev/rheinsw/contactService/service/HCaptchaValidator.java
new file mode 100644
index 0000000..426ce25
--- /dev/null
+++ b/backend/services/contactService/src/main/java/dev/rheinsw/contactService/service/HCaptchaValidator.java
@@ -0,0 +1,55 @@
+package dev.rheinsw.contactService.service;
+
+import dev.rheinsw.contactService.config.HCaptchaConfig;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.Map;
+
+/**
+ * @author Thatsaphorn Atchariyaphap
+ * @since 21.04.25
+ */
+@Service
+public class HCaptchaValidator {
+ private static final Logger log = LoggerFactory.getLogger(HCaptchaValidator.class);
+
+ private final HCaptchaConfig config;
+ private final RestTemplate restTemplate;
+
+ public HCaptchaValidator(HCaptchaConfig config, RestTemplate restTemplate) {
+ this.config = config;
+ this.restTemplate = restTemplate;
+ }
+
+ public boolean isValid(String token) {
+ if (token == null || token.isBlank()) {
+ log.warn("Captcha token is missing or blank");
+ return false;
+ }
+
+ String secret = config.getSecret();
+ if (secret == null || secret.isBlank()) {
+ log.error("Captcha secret is missing");
+ return false;
+ }
+
+ try {
+ var response = restTemplate.postForObject(
+ "https://api.hcaptcha.com/siteverify",
+ new org.springframework.util.LinkedMultiValueMap() {{
+ add("secret", secret);
+ add("response", token);
+ }},
+ Map.class
+ );
+
+ return response != null && Boolean.TRUE.equals(response.get("success"));
+ } catch (Exception e) {
+ log.error("Failed to verify hCaptcha", e);
+ return false;
+ }
+ }
+}
diff --git a/backend/services/contactService/src/main/resources/application.yml b/backend/services/contactService/src/main/resources/application.yml
new file mode 100644
index 0000000..a523a90
--- /dev/null
+++ b/backend/services/contactService/src/main/resources/application.yml
@@ -0,0 +1,31 @@
+server:
+ port: 0 # random port
+
+spring:
+ application:
+ name: contactService
+ datasource:
+ url: jdbc:postgresql://localhost:5432/rheinsw_dev
+ username: rheinsw
+ password: rheinsw
+
+ jpa:
+ hibernate:
+ ddl-auto: none
+ show-sql: true
+ properties:
+ hibernate:
+ format_sql: true
+
+eureka:
+ client:
+ service-url:
+ defaultZone: http://localhost:8761/eureka/
+
+hcaptcha:
+ secret: ES_ff59a664dc764f92870bf2c7b4eab7c5
+
+logging:
+ level:
+ org.hibernate.SQL: DEBUG
+ org.hibernate.type.descriptor.sql.BasicBinder: TRACE
\ No newline at end of file
diff --git a/backend/services/contactService/src/main/resources/db/migration/V1__create_tables.sql b/backend/services/contactService/src/main/resources/db/migration/V1__create_tables.sql
new file mode 100644
index 0000000..254e429
--- /dev/null
+++ b/backend/services/contactService/src/main/resources/db/migration/V1__create_tables.sql
@@ -0,0 +1,12 @@
+CREATE TABLE contact_requests
+(
+ id SERIAL PRIMARY KEY,
+ name VARCHAR(100),
+ email VARCHAR(100),
+ message VARCHAR(1000),
+ company VARCHAR(100),
+ phone VARCHAR(20),
+ website VARCHAR(100),
+ captcha_token VARCHAR(1024),
+ submitted_at TIMESTAMP
+);
diff --git a/backend/services/contactService/src/test/java/dev/rheinsw/contactService/controller/ContactControllerTest.java b/backend/services/contactService/src/test/java/dev/rheinsw/contactService/controller/ContactControllerTest.java
new file mode 100644
index 0000000..43432bb
--- /dev/null
+++ b/backend/services/contactService/src/test/java/dev/rheinsw/contactService/controller/ContactControllerTest.java
@@ -0,0 +1,125 @@
+package dev.rheinsw.contactService.controller;
+
+import dev.rheinsw.contactService.dto.ContactRequestDto;
+import dev.rheinsw.contactService.model.ContactRequest;
+import dev.rheinsw.contactService.repository.ContactRequestsRepo;
+import dev.rheinsw.contactService.service.HCaptchaValidator;
+import dev.rheinsw.shared.mail.MailServiceClient;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.*;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class ContactControllerTest {
+
+ @Mock
+ private HCaptchaValidator hCaptchaValidator;
+
+ @Mock
+ private ContactRequestsRepo repository;
+
+ @Mock
+ private MailServiceClient mailServiceClient;
+
+ @InjectMocks
+ private ContactController controller;
+
+ @BeforeEach
+ void setUp() {
+ MockitoAnnotations.openMocks(this);
+ }
+
+ @Test
+ void submitContact_ShouldReturnSuccess_WhenDevCaptchaTokenUsed() {
+ // Arrange
+ ContactRequestDto request = new ContactRequestDto();
+ request.name = "Test User";
+ request.email = "test@example.com";
+ request.message = "Hello!";
+ request.captcha = "10000000-aaaa-bbbb-cccc-000000000001";
+
+ // Act
+ ResponseEntity response = controller.submitContact(request);
+
+ // Assert
+ assertEquals(HttpStatus.OK, response.getStatusCode());
+ assertEquals("Contact form submitted successfully", response.getBody());
+
+ verify(repository, times(1)).save(any(ContactRequest.class));
+ verify(mailServiceClient, times(2)).sendMail(anyString(), anyString(), anyString());
+ verifyNoInteractions(hCaptchaValidator);
+ }
+
+ @Test
+ void submitContact_ShouldReturnSuccess_WhenCaptchaValid() {
+ // Arrange
+ ContactRequestDto request = new ContactRequestDto();
+ request.name = "Valid User";
+ request.email = "valid@example.com";
+ request.message = "Some message";
+ request.captcha = "real-token";
+
+ when(hCaptchaValidator.isValid("real-token")).thenReturn(true);
+
+ // Act
+ ResponseEntity response = controller.submitContact(request);
+
+ // Assert
+ assertEquals(HttpStatus.OK, response.getStatusCode());
+ assertEquals("Contact form submitted successfully", response.getBody());
+
+ verify(hCaptchaValidator, times(1)).isValid("real-token");
+ verify(repository, times(1)).save(any(ContactRequest.class));
+ verify(mailServiceClient, times(2)).sendMail(anyString(), anyString(), anyString());
+ }
+
+ @Test
+ void submitContact_ShouldReturnForbidden_WhenCaptchaInvalid() {
+ // Arrange
+ ContactRequestDto request = new ContactRequestDto();
+ request.name = "Bot User";
+ request.email = "bot@example.com";
+ request.message = "Spam spam spam";
+ request.captcha = "invalid-token";
+
+ when(hCaptchaValidator.isValid("invalid-token")).thenReturn(false);
+
+ // Act
+ ResponseEntity response = controller.submitContact(request);
+
+ // Assert
+ assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
+ assertEquals("Captcha verification failed", response.getBody());
+
+ verify(hCaptchaValidator).isValid("invalid-token");
+ verifyNoInteractions(repository);
+ verifyNoInteractions(mailServiceClient);
+ }
+
+ @Test
+ void submitContact_ShouldHandleNullOptionalFields() {
+ // Arrange
+ ContactRequestDto request = new ContactRequestDto();
+ request.name = "No Company";
+ request.email = "user@example.com";
+ request.message = "Just a message";
+ request.captcha = "real-token";
+ request.company = null;
+ request.phone = null;
+ request.website = null;
+
+ when(hCaptchaValidator.isValid("real-token")).thenReturn(true);
+
+ // Act
+ ResponseEntity response = controller.submitContact(request);
+
+ // Assert
+ assertEquals(HttpStatus.OK, response.getStatusCode());
+ verify(repository).save(any(ContactRequest.class));
+ verify(mailServiceClient, times(2)).sendMail(anyString(), anyString(), anyString());
+ }
+}
diff --git a/backend/services/contactService/src/test/java/dev/rheinsw/contactService/service/HCaptchaValidatorTest.java b/backend/services/contactService/src/test/java/dev/rheinsw/contactService/service/HCaptchaValidatorTest.java
new file mode 100644
index 0000000..0066f81
--- /dev/null
+++ b/backend/services/contactService/src/test/java/dev/rheinsw/contactService/service/HCaptchaValidatorTest.java
@@ -0,0 +1,147 @@
+package dev.rheinsw.contactService.service;
+
+import dev.rheinsw.contactService.config.HCaptchaConfig;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+
+@ExtendWith(MockitoExtension.class)
+class HCaptchaValidatorTest {
+
+ @Mock
+ private HCaptchaConfig hCaptchaConfig;
+
+ @Mock
+ private RestTemplate restTemplate;
+
+ private HCaptchaValidator validator;
+
+ @BeforeEach
+ void setUp() {
+ validator = new HCaptchaValidator(hCaptchaConfig, restTemplate);
+ }
+
+ @Test
+ void isValid_ShouldReturnFalse_WhenTokenIsNull() {
+ // Arrange
+
+ // Act
+ boolean result = validator.isValid(null);
+
+ // Assert
+ assertFalse(result);
+ }
+
+ @Test
+ void isValid_ShouldReturnFalse_WhenTokenIsBlank() {
+ // Arrange
+
+ // Act
+ boolean result = validator.isValid(" ");
+
+ // Assert
+ assertFalse(result);
+ }
+
+ @Test
+ void isValid_ShouldReturnFalse_WhenSecretIsNull() {
+ // Arrange
+ Mockito.when(hCaptchaConfig.getSecret()).thenReturn(null);
+
+ // Act
+ boolean result = validator.isValid("test-token");
+
+ // Assert
+ assertFalse(result);
+ }
+
+ @Test
+ void isValid_ShouldReturnFalse_WhenSecretIsBlank() {
+ // Arrange
+ Mockito.when(hCaptchaConfig.getSecret()).thenReturn(" ");
+
+ // Act
+ boolean result = validator.isValid("test-token");
+
+ // Assert
+ assertFalse(result);
+ }
+
+ @Test
+ void isValid_ShouldReturnFalse_WhenApiResponseIsNull() {
+ // Arrange
+ Mockito.when(hCaptchaConfig.getSecret()).thenReturn("test-secret");
+ Mockito.when(restTemplate.postForObject(
+ eq("https://api.hcaptcha.com/siteverify"),
+ any(),
+ eq(Map.class)
+ )).thenReturn(null);
+
+ // Act
+ boolean result = validator.isValid("test-token");
+
+ // Assert
+ assertFalse(result);
+ }
+
+ @Test
+ void isValid_ShouldReturnFalse_WhenApiResponseSuccessIsFalse() {
+ // Arrange
+ Mockito.when(hCaptchaConfig.getSecret()).thenReturn("test-secret");
+ Mockito.when(restTemplate.postForObject(
+ eq("https://api.hcaptcha.com/siteverify"),
+ any(),
+ eq(Map.class)
+ )).thenReturn(Map.of("success", false));
+
+ // Act
+ boolean result = validator.isValid("test-token");
+
+ // Assert
+ assertFalse(result);
+ }
+
+ @Test
+ void isValid_ShouldReturnTrue_WhenApiResponseSuccessIsTrue() {
+ // Arrange
+ Mockito.when(hCaptchaConfig.getSecret()).thenReturn("test-secret");
+ Mockito.when(restTemplate.postForObject(
+ eq("https://api.hcaptcha.com/siteverify"),
+ any(),
+ eq(Map.class)
+ )).thenReturn(Map.of("success", true));
+
+ // Act
+ boolean result = validator.isValid("test-token");
+
+ // Assert
+ assertTrue(result);
+ }
+
+ @Test
+ void isValid_ShouldReturnFalse_WhenExceptionIsThrown() {
+ // Arrange
+ Mockito.when(hCaptchaConfig.getSecret()).thenReturn("test-secret");
+ Mockito.when(restTemplate.postForObject(
+ eq("https://api.hcaptcha.com/siteverify"),
+ any(),
+ eq(Map.class)
+ )).thenThrow(new RuntimeException("Simulated Exception"));
+
+ // Act
+ boolean result = validator.isValid("test-token");
+
+ // Assert
+ assertFalse(result);
+ }
+}
diff --git a/backend/services/mailService/pom.xml b/backend/services/mailService/pom.xml
new file mode 100644
index 0000000..feb9bb1
--- /dev/null
+++ b/backend/services/mailService/pom.xml
@@ -0,0 +1,114 @@
+
+
+ 4.0.0
+
+ dev.rheinsw
+ rheinsw-backend
+ 1.0.0
+ ../../pom.xml
+
+
+ mailService
+
+
+ 21
+ 21
+ UTF-8
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ ${maven.compiler.plugin.version}
+
+ ${maven.compiler.source}
+ ${maven.compiler.target}
+
+
+ org.projectlombok
+ lombok
+ ${lombok.version}
+
+
+
+
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+ org.springframework.boot
+ spring-boot-starter-mail
+
+
+ org.springframework.cloud
+ spring-cloud-starter-netflix-eureka-client
+
+
+
+
+ org.projectlombok
+ lombok
+ provided
+
+
+
+
+ jakarta.mail
+ jakarta.mail-api
+ 2.1.3
+
+
+ jakarta.validation
+ jakarta.validation-api
+
+
+ org.springframework.boot
+ spring-boot-actuator-autoconfigure
+
+
+ com.google.guava
+ guava
+ 33.4.0-jre
+ compile
+
+
+
+ dev.rheinsw
+ shared
+ 1.0.0
+ compile
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.mockito
+ mockito-core
+ test
+
+
+ org.mockito
+ mockito-junit-jupiter
+ test
+
+
+
+
\ No newline at end of file
diff --git a/backend/services/mailService/src/main/java/dev/rheinsw/mail/MailServiceApplication.java b/backend/services/mailService/src/main/java/dev/rheinsw/mail/MailServiceApplication.java
new file mode 100644
index 0000000..304370e
--- /dev/null
+++ b/backend/services/mailService/src/main/java/dev/rheinsw/mail/MailServiceApplication.java
@@ -0,0 +1,18 @@
+package dev.rheinsw.mail;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * @author Thatsaphorn Atchariyaphap
+ * @since 22.04.25
+ */
+@SpringBootApplication(exclude = {
+ org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration.class,
+ org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration.class
+})
+public class MailServiceApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(MailServiceApplication.class, args);
+ }
+}
\ No newline at end of file
diff --git a/backend/services/mailService/src/main/java/dev/rheinsw/mail/controller/MailController.java b/backend/services/mailService/src/main/java/dev/rheinsw/mail/controller/MailController.java
new file mode 100644
index 0000000..5a0601c
--- /dev/null
+++ b/backend/services/mailService/src/main/java/dev/rheinsw/mail/controller/MailController.java
@@ -0,0 +1,33 @@
+package dev.rheinsw.mail.controller;
+
+import dev.rheinsw.shared.mail.dto.MailRequest;
+import dev.rheinsw.mail.service.MailService;
+import jakarta.mail.MessagingException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.bind.annotation.RequestBody;
+
+/**
+ * @author Thatsaphorn Atchariyaphap
+ * @since 22.04.25
+ */
+@RestController
+@RequestMapping("/mail")
+@RequiredArgsConstructor
+public class MailController {
+
+ private final MailService mailService;
+
+ @PostMapping("/send")
+ public ResponseEntity sendEmail(@RequestBody MailRequest request) {
+ try {
+ mailService.sendEmail(request);
+ return ResponseEntity.ok("Email sent successfully");
+ } catch (MessagingException e) {
+ return ResponseEntity.status(500).body("Failed to send email: " + e.getMessage());
+ }
+ }
+}
diff --git a/backend/services/mailService/src/main/java/dev/rheinsw/mail/service/MailService.java b/backend/services/mailService/src/main/java/dev/rheinsw/mail/service/MailService.java
new file mode 100644
index 0000000..75e6b28
--- /dev/null
+++ b/backend/services/mailService/src/main/java/dev/rheinsw/mail/service/MailService.java
@@ -0,0 +1,33 @@
+package dev.rheinsw.mail.service;
+
+import dev.rheinsw.shared.mail.dto.MailRequest;
+import jakarta.mail.MessagingException;
+import jakarta.mail.internet.MimeMessage;
+import lombok.RequiredArgsConstructor;
+import org.springframework.mail.javamail.JavaMailSender;
+import org.springframework.mail.javamail.MimeMessageHelper;
+import org.springframework.stereotype.Service;
+
+/**
+ * @author Thatsaphorn Atchariyaphap
+ * @since 22.04.25
+ */
+@Service
+@RequiredArgsConstructor
+public class MailService {
+
+ private final JavaMailSender mailSender;
+
+ public void sendEmail(MailRequest request) throws MessagingException {
+ MimeMessage message = mailSender.createMimeMessage();
+ MimeMessageHelper helper = new MimeMessageHelper(message, true);
+
+ helper.setFrom("noreply@rhein-software.dev");
+ helper.setTo(request.getTo());
+ helper.setSubject(request.getSubject());
+ helper.setText(request.getMessage(), false);
+
+ mailSender.send(message);
+ }
+
+}
diff --git a/backend/services/mailService/src/main/resources/application.yml b/backend/services/mailService/src/main/resources/application.yml
new file mode 100644
index 0000000..d7cd18f
--- /dev/null
+++ b/backend/services/mailService/src/main/resources/application.yml
@@ -0,0 +1,24 @@
+server:
+ port: 0 # random port
+
+spring:
+ application:
+ name: mailService
+
+ mail:
+ host: smtp.resend.com
+ port: 587
+ username: resend
+ password: re_JnLD5ndg_GnKtXcTqskXm1bg7Wxnghna3
+ properties:
+ mail:
+ smtp:
+ auth: true
+ starttls:
+ enable: true
+ default-encoding: UTF-8
+
+eureka:
+ client:
+ service-url:
+ defaultZone: http://localhost:8761/eureka/
\ No newline at end of file
diff --git a/backend/services/mailService/src/test/java/dev/rheinsw/mail/controller/MailControllerTest.java b/backend/services/mailService/src/test/java/dev/rheinsw/mail/controller/MailControllerTest.java
new file mode 100644
index 0000000..617de0b
--- /dev/null
+++ b/backend/services/mailService/src/test/java/dev/rheinsw/mail/controller/MailControllerTest.java
@@ -0,0 +1,63 @@
+package dev.rheinsw.mail.controller;
+
+import dev.rheinsw.mail.service.MailService;
+import dev.rheinsw.shared.mail.dto.MailRequest;
+import jakarta.mail.MessagingException;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.*;
+import org.springframework.http.ResponseEntity;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class MailControllerTest {
+
+ @Mock
+ private MailService mailService;
+
+ @InjectMocks
+ private MailController mailController;
+
+ private AutoCloseable closeable;
+
+ @BeforeEach
+ void setUp() {
+ closeable = MockitoAnnotations.openMocks(this);
+ }
+
+ @Test
+ void sendEmail_shouldReturnOk_whenEmailSentSuccessfully() throws MessagingException {
+ // Arrange
+ MailRequest request = new MailRequest("user@example.com", "Test Subject", "Message");
+
+ // Act
+ ResponseEntity response = mailController.sendEmail(request);
+
+ // Assert
+ verify(mailService).sendEmail(request);
+ assertEquals(200, response.getStatusCodeValue());
+ assertEquals("Email sent successfully", response.getBody());
+ }
+
+ @Test
+ void sendEmail_shouldReturnServerError_whenMessagingExceptionThrown() throws MessagingException {
+ // Arrange
+ MailRequest request = new MailRequest("user@example.com", "Test Subject", "Message");
+ doThrow(new MessagingException("SMTP failed")).when(mailService).sendEmail(request);
+
+ // Act
+ ResponseEntity response = mailController.sendEmail(request);
+
+ // Assert
+ verify(mailService).sendEmail(request);
+ assertEquals(500, response.getStatusCodeValue());
+ assertTrue(response.getBody().contains("Failed to send email: SMTP failed"));
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ closeable.close();
+ }
+}
diff --git a/backend/services/mailService/src/test/java/dev/rheinsw/mail/service/MailServiceTest.java b/backend/services/mailService/src/test/java/dev/rheinsw/mail/service/MailServiceTest.java
new file mode 100644
index 0000000..5489102
--- /dev/null
+++ b/backend/services/mailService/src/test/java/dev/rheinsw/mail/service/MailServiceTest.java
@@ -0,0 +1,60 @@
+package dev.rheinsw.mail.service;
+
+import dev.rheinsw.shared.mail.dto.MailRequest;
+import jakarta.mail.MessagingException;
+import jakarta.mail.internet.MimeMessage;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.*;
+import org.springframework.mail.javamail.JavaMailSender;
+import org.springframework.mail.javamail.MimeMessageHelper;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.*;
+
+class MailServiceTest {
+
+ @Mock
+ private JavaMailSender mailSender;
+
+ @InjectMocks
+ private MailService mailService;
+
+ private AutoCloseable closeable;
+
+ @BeforeEach
+ void setUp() {
+ closeable = MockitoAnnotations.openMocks(this);
+ }
+
+ @Test
+ void sendEmail_shouldSendMessageSuccessfully() throws MessagingException {
+ // Arrange
+ MailRequest request = new MailRequest("user@example.com", "Hello", "This is a test message.");
+ MimeMessage mimeMessage = mock(MimeMessage.class);
+ when(mailSender.createMimeMessage()).thenReturn(mimeMessage);
+
+ // Act & Assert
+ assertDoesNotThrow(() -> mailService.sendEmail(request));
+ verify(mailSender).send(mimeMessage);
+ }
+
+ @Test
+ void sendEmail_shouldThrowMessagingException_whenMailSenderFails() {
+ // Arrange
+ MailRequest request = new MailRequest("user@example.com", "Hello", "This is a test message.");
+ MimeMessage mimeMessage = mock(MimeMessage.class);
+ when(mailSender.createMimeMessage()).thenReturn(mimeMessage);
+ doThrow(new RuntimeException("Simulated failure")).when(mailSender).send(mimeMessage);
+
+ // Act & Assert
+ RuntimeException exception = assertThrows(RuntimeException.class, () -> {
+ mailService.sendEmail(request);
+ });
+
+ assertEquals("Simulated failure", exception.getMessage());
+ }
+
+}
diff --git a/backend/shared/pom.xml b/backend/shared/pom.xml
new file mode 100644
index 0000000..3c204f5
--- /dev/null
+++ b/backend/shared/pom.xml
@@ -0,0 +1,88 @@
+
+
+ 4.0.0
+
+
+ dev.rheinsw
+ rheinsw-backend
+ 1.0.0
+
+
+ shared
+
+
+ 21
+ 21
+ UTF-8
+
+ 2.18.3
+ 1.18.38
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ ${maven.compiler.plugin.version}
+
+ ${maven.compiler.source}
+ ${maven.compiler.target}
+
+
+ org.projectlombok
+ lombok
+ ${lombok.version}
+
+
+
+
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.cloud
+ spring-cloud-starter-loadbalancer
+
+
+
+
+
+ jakarta.persistence
+ jakarta.persistence-api
+ 3.2.0
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.mockito
+ mockito-core
+ test
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ ${jackson-databind.version}
+
+
+ org.projectlombok
+ lombok
+ ${lombok.version}
+ provided
+
+
+
+
\ No newline at end of file
diff --git a/backend/shared/src/main/java/dev/rheinsw/shared/entity/BaseEntity.java b/backend/shared/src/main/java/dev/rheinsw/shared/entity/BaseEntity.java
new file mode 100644
index 0000000..f0ee6cf
--- /dev/null
+++ b/backend/shared/src/main/java/dev/rheinsw/shared/entity/BaseEntity.java
@@ -0,0 +1,37 @@
+package dev.rheinsw.shared.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.MappedSuperclass;
+import jakarta.persistence.PrePersist;
+import jakarta.persistence.PreUpdate;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+/**
+ * @author Thatsaphorn Atchariyaphap
+ * @since 26.04.25
+ */
+
+@Getter
+@Setter
+@MappedSuperclass
+public abstract class BaseEntity {
+
+ @Column(name = "createdDateTime", nullable = false, updatable = false)
+ private LocalDateTime createdDateTime;
+
+ @Column(name = "modifiedDateTime")
+ private LocalDateTime modifiedDateTime;
+
+ @PrePersist
+ protected void onCreate() {
+ createdDateTime = LocalDateTime.now();
+ }
+
+ @PreUpdate
+ protected void onUpdate() {
+ modifiedDateTime = LocalDateTime.now();
+ }
+}
\ No newline at end of file
diff --git a/backend/shared/src/main/java/dev/rheinsw/shared/mail/MailServiceClient.java b/backend/shared/src/main/java/dev/rheinsw/shared/mail/MailServiceClient.java
new file mode 100644
index 0000000..da2d7ed
--- /dev/null
+++ b/backend/shared/src/main/java/dev/rheinsw/shared/mail/MailServiceClient.java
@@ -0,0 +1,50 @@
+package dev.rheinsw.shared.mail;
+
+import dev.rheinsw.shared.mail.dto.MailRequest;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+/**
+ * @author Thatsaphorn Atchariyaphap
+ * @since 22.04.25
+ */
+@Service
+@RequiredArgsConstructor
+public class MailServiceClient {
+
+ private static final Logger log = LoggerFactory.getLogger(MailServiceClient.class);
+
+ private final RestTemplate restTemplate;
+
+ private static final String MAIL_ENDPOINT = "http://gateway/api/mail";
+
+ @Value("${INTERNAL_API_KEY}")
+ private String internalApiKey;
+
+ @Async
+ public void sendMail(String email, String subject, String userMessage) {
+ MailRequest request = new MailRequest(email, subject, userMessage);
+ postEmail(request);
+ }
+
+ private void postEmail(MailRequest request) {
+ try {
+ HttpHeaders headers = new HttpHeaders();
+ headers.set("X-Internal-Auth", internalApiKey);
+
+ HttpEntity entity = new HttpEntity<>(request, headers);
+
+ restTemplate.postForEntity(MAIL_ENDPOINT + "/send", entity, String.class);
+ } catch (Exception e) {
+ log.error("Failed to send email to {}: {}", request.getTo(), e.getMessage());
+ }
+ }
+
+}
diff --git a/backend/shared/src/main/java/dev/rheinsw/shared/mail/dto/MailRequest.java b/backend/shared/src/main/java/dev/rheinsw/shared/mail/dto/MailRequest.java
new file mode 100644
index 0000000..299a629
--- /dev/null
+++ b/backend/shared/src/main/java/dev/rheinsw/shared/mail/dto/MailRequest.java
@@ -0,0 +1,20 @@
+package dev.rheinsw.shared.mail.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+/**
+ * @author Thatsaphorn Atchariyaphap
+ * @since 22.04.25
+ */
+@Data
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+public class MailRequest {
+ private String to;
+ private String subject;
+ private String message;
+}
diff --git a/backend/shared/src/main/java/dev/rheinsw/shared/rest/RestTemplateConfig.java b/backend/shared/src/main/java/dev/rheinsw/shared/rest/RestTemplateConfig.java
new file mode 100644
index 0000000..206ce2a
--- /dev/null
+++ b/backend/shared/src/main/java/dev/rheinsw/shared/rest/RestTemplateConfig.java
@@ -0,0 +1,20 @@
+package dev.rheinsw.shared.rest;
+
+import org.springframework.cloud.client.loadbalancer.LoadBalanced;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.client.RestTemplate;
+
+/**
+ * @author Thatsaphorn Atchariyaphap
+ * @since 23.04.25
+ */
+@Configuration
+public class RestTemplateConfig {
+
+ @LoadBalanced
+ @Bean
+ public RestTemplate mailRestTemplate() {
+ return new RestTemplate();
+ }
+}
\ No newline at end of file
diff --git a/backend/shared/src/main/java/dev/rheinsw/shared/transport/Dto.java b/backend/shared/src/main/java/dev/rheinsw/shared/transport/Dto.java
new file mode 100644
index 0000000..e820ef2
--- /dev/null
+++ b/backend/shared/src/main/java/dev/rheinsw/shared/transport/Dto.java
@@ -0,0 +1,10 @@
+package dev.rheinsw.shared.transport;
+
+import java.io.Serializable;
+
+/**
+ * @author Bummsa / BoomerHD / Thatsaphorn Atchariyaphap
+ * @since 21.04.25
+ */
+public interface Dto extends Serializable {
+}
diff --git a/backend/shared/src/test/java/dev/rheinsw/shared/entity/BaseEntityTest.java b/backend/shared/src/test/java/dev/rheinsw/shared/entity/BaseEntityTest.java
new file mode 100644
index 0000000..1b80fe4
--- /dev/null
+++ b/backend/shared/src/test/java/dev/rheinsw/shared/entity/BaseEntityTest.java
@@ -0,0 +1,58 @@
+package dev.rheinsw.shared.entity;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class BaseEntityTest {
+
+ // Dummy entity for testing
+ static class DummyEntity extends BaseEntity {
+ }
+
+ @Test
+ void onCreate_shouldSetCreatedDateTime() {
+ // Arrange
+ DummyEntity entity = new DummyEntity();
+
+ // Act
+ entity.onCreate();
+
+ // Assert
+ assertNotNull(entity.getCreatedDateTime(), "createdDateTime should be set");
+ assertNull(entity.getModifiedDateTime(), "modifiedDateTime should still be null after creation");
+ }
+
+ @Test
+ void onUpdate_shouldSetModifiedDateTime() {
+ // Arrange
+ DummyEntity entity = new DummyEntity();
+
+ // Act
+ entity.onUpdate();
+
+ // Assert
+ assertNotNull(entity.getModifiedDateTime(), "modifiedDateTime should be set");
+ assertNull(entity.getCreatedDateTime(), "createdDateTime should still be null if onCreate() is not called");
+ }
+
+ @Test
+ void onCreate_thenOnUpdate_shouldSetBothTimestamps() throws InterruptedException {
+ // Arrange
+ DummyEntity entity = new DummyEntity();
+
+ // Act
+ entity.onCreate();
+ LocalDateTime created = entity.getCreatedDateTime();
+ Thread.sleep(10); // slight pause to differentiate timestamps
+ entity.onUpdate();
+ LocalDateTime modified = entity.getModifiedDateTime();
+
+ // Assert
+ assertNotNull(created);
+ assertNotNull(modified);
+ assertTrue(modified.isAfter(created), "modifiedDateTime should be after createdDateTime");
+ }
+}
diff --git a/backend/shared/src/test/java/dev/rheinsw/shared/mail/MailServiceClientTest.java b/backend/shared/src/test/java/dev/rheinsw/shared/mail/MailServiceClientTest.java
new file mode 100644
index 0000000..6708ae1
--- /dev/null
+++ b/backend/shared/src/test/java/dev/rheinsw/shared/mail/MailServiceClientTest.java
@@ -0,0 +1,83 @@
+package dev.rheinsw.shared.mail;
+
+import dev.rheinsw.shared.mail.dto.MailRequest;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.http.HttpEntity;
+import org.springframework.web.client.RestTemplate;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.mockito.Mockito.*;
+
+class MailServiceClientTest {
+
+ @Mock
+ private RestTemplate restTemplate;
+
+ @InjectMocks
+ private MailServiceClient mailServiceClient;
+
+ @Captor
+ private ArgumentCaptor> httpEntityCaptor;
+
+ private AutoCloseable closeable;
+
+ @BeforeEach
+ void setUp() {
+ closeable = MockitoAnnotations.openMocks(this);
+ }
+
+ @Test
+ void sendMail_shouldSendCorrectRequest() {
+ // Arrange
+ String email = "user@example.com";
+ String subject = "Test Subject";
+ String message = "This is a test message.";
+
+ // Act
+ mailServiceClient.sendMail(email, subject, message);
+
+ // Assert
+ verify(restTemplate).postForEntity(
+ eq("http://gateway/api/mail/send"),
+ httpEntityCaptor.capture(),
+ eq(String.class)
+ );
+
+ MailRequest captured = httpEntityCaptor.getValue().getBody(); // extract the MailRequest
+ assert captured != null;
+ assert captured.getTo().equals(email);
+ assert captured.getSubject().equals(subject);
+ assert captured.getMessage().equals(message);
+ }
+
+ @Test
+ void sendMail_shouldHandleExceptionDuringPost() {
+ // Arrange
+ String email = "user@example.com";
+ String subject = "Test Subject";
+ String message = "This is a test message.";
+
+ doThrow(new RuntimeException("Simulated error")).when(restTemplate).postForEntity(
+ anyString(),
+ any(),
+ eq(String.class)
+ );
+
+ // Act & Assert
+ assertDoesNotThrow(() -> mailServiceClient.sendMail(email, subject, message),
+ "sendMail should handle exception internally and not throw it");
+ }
+
+
+ @AfterEach
+ void tearDown() throws Exception {
+ closeable.close();
+ }
+}
diff --git a/backend/shared/src/test/java/dev/rheinsw/shared/rest/RestTemplateConfigTest.java b/backend/shared/src/test/java/dev/rheinsw/shared/rest/RestTemplateConfigTest.java
new file mode 100644
index 0000000..7d32d69
--- /dev/null
+++ b/backend/shared/src/test/java/dev/rheinsw/shared/rest/RestTemplateConfigTest.java
@@ -0,0 +1,30 @@
+package dev.rheinsw.shared.rest;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.web.client.RestTemplate;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class RestTemplateConfigTest {
+
+ private final RestTemplateConfig restTemplateConfig = new RestTemplateConfig();
+
+ @Test
+ void mailRestTemplate_shouldReturnNonNullRestTemplate() {
+ // Act
+ RestTemplate restTemplate = restTemplateConfig.mailRestTemplate();
+
+ // Assert
+ assertNotNull(restTemplate, "RestTemplate should not be null");
+ }
+
+ @Test
+ void mailRestTemplate_shouldCreateNewInstanceEachTime() {
+ // Act
+ RestTemplate restTemplate1 = restTemplateConfig.mailRestTemplate();
+ RestTemplate restTemplate2 = restTemplateConfig.mailRestTemplate();
+
+ // Assert
+ assertNotSame(restTemplate1, restTemplate2, "Each call should create a new RestTemplate instance");
+ }
+}