2 Commits

Author SHA1 Message Date
5078a11142 Merge branch 'dev' into 'production'
internal frontend implementation with keycloak authentication

See merge request rheinsw/rheinsw-mono-repo!16
2025-07-02 02:24:35 +00:00
66a415b0dd internal frontend implementation with keycloak authentication 2025-07-02 02:24:35 +00:00
143 changed files with 842 additions and 6395 deletions

View File

@@ -1,12 +0,0 @@
{
"permissions": {
"allow": [
"Bash(find:*)",
"Bash(mvn clean:*)",
"Bash(mvn test:*)",
"Bash(npm run build:*)",
"Bash(npm run lint)"
],
"deny": []
}
}

View File

@@ -1,3 +0,0 @@
Make sure the project still builds successfully after your changes.

View File

@@ -0,0 +1,21 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="GatewayApplication" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot" nameIsGenerated="true">
<option name="envFilePaths">
<option value="$PROJECT_DIR$/gateway.env" />
</option>
<module name="gateway" />
<selectedOptions>
<option name="environmentVariables" />
</selectedOptions>
<option name="SPRING_BOOT_MAIN_CLASS" value="dev.rheinsw.gateway.GatewayApplication" />
<extension name="coverage">
<pattern>
<option name="PATTERN" value="dev.rheinsw.gateway.*" />
<option name="ENABLED" value="true" />
</pattern>
</extension>
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
</component>

View File

@@ -7,8 +7,9 @@
</scripts> </scripts>
<node-interpreter value="project" /> <node-interpreter value="project" />
<envs> <envs>
<env name="HCAPTCHA_SECRET" value="10000000-ffff-ffff-ffff-000000000001" />
<env name="USE_LOCAL_GATEWAY" value="true" />
<env name="SERVER_HOST" value="localhost" /> <env name="SERVER_HOST" value="localhost" />
<env name="USE_LOCAL_SERVER" value="true" />
</envs> </envs>
<method v="2" /> <method v="2" />
</configuration> </configuration>

View File

@@ -7,6 +7,7 @@ build_backend:
artifacts: artifacts:
paths: paths:
- backend/common/target/ - backend/common/target/
- backend/gateway/target/
- backend/discovery/target/ - backend/discovery/target/
- backend/server/target - backend/server/target
expire_in: 1 hour expire_in: 1 hour
@@ -20,6 +21,18 @@ docker_common:
needs: needs:
- build_backend - build_backend
docker_gateway:
extends: .docker-build-template
variables:
IMAGE_NAME: gateway
COMMON_IMAGE: "$CI_REGISTRY/$CI_PROJECT_PATH/common"
WORKDIR_PATH: backend
DOCKERFILE_PATH: Dockerfile.app
BUILD_FOLDER: "gateway/target"
MAIN_CLASS: dev.rheinsw.gateway.GatewayApplication
needs:
- build_backend
- docker_common
docker_server: docker_server:
extends: .docker-build-template extends: .docker-build-template

View File

@@ -1,15 +0,0 @@
package dev.rheinsw.common.controller.exception;
/**
* @author Thatsaphorn Atchariyaphap
* @since 06.07.25
*/
public class ApiException extends RuntimeException {
public ApiException(String message) {
super(message);
}
public ApiException(Throwable cause) {
super(cause);
}
}

View File

@@ -1,87 +0,0 @@
package dev.rheinsw.common.controller.exception.handler;
import dev.rheinsw.common.controller.exception.ApiException;
import dev.rheinsw.common.usecase.exception.UseCaseException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
/**
* @author Thatsaphorn Atchariyaphap
* @since 06.07.25
*/
@Slf4j
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ApiException.class)
public ResponseEntity<ApiErrorResponse> handleBusinessException(ApiException ex, WebRequest request) {
String correlationId = UUID.randomUUID().toString();
log.warn("Business exception [{}] at {}: {}", correlationId, request.getDescription(false), ex.getMessage(), ex);
return ResponseEntity.badRequest().body(
new ApiErrorResponse(Instant.now(), ex.getMessage(), List.of(ex.getMessage()), correlationId)
);
}
@ExceptionHandler(UseCaseException.class)
public ResponseEntity<ApiErrorResponse> handleUseCaseException(UseCaseException ex, WebRequest request) {
String correlationId = UUID.randomUUID().toString();
log.warn("Use case exception [{}] at {}: {}", correlationId, request.getDescription(false), ex.getMessage(), ex);
return ResponseEntity.badRequest().body(
new ApiErrorResponse(Instant.now(), ex.getMessage(), List.of(ex.getMessage()), correlationId)
);
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiErrorResponse> handleIllegalArgumentException(IllegalArgumentException ex, WebRequest request) {
String correlationId = UUID.randomUUID().toString();
log.warn("Invalid argument [{}] at {}: {}", correlationId, request.getDescription(false), ex.getMessage());
return ResponseEntity.badRequest().body(
new ApiErrorResponse(Instant.now(), "Ungültige Eingabedaten", List.of("Die übermittelten Daten sind ungültig"), correlationId)
);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiErrorResponse> handleValidationException(MethodArgumentNotValidException ex, WebRequest request) {
String correlationId = UUID.randomUUID().toString();
log.warn("Validation failure [{}] at {}: {} validation errors", correlationId, request.getDescription(false), ex.getBindingResult().getErrorCount());
List<String> errors = ex.getBindingResult().getFieldErrors().stream()
.map(fieldError -> fieldError.getField() + ": " + fieldError.getDefaultMessage())
.toList();
return ResponseEntity.badRequest().body(
new ApiErrorResponse(Instant.now(), "Validierungsfehler in den übermittelten Daten", errors, correlationId)
);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiErrorResponse> handleGeneric(Exception ex, WebRequest request) {
String correlationId = UUID.randomUUID().toString();
log.error("Unexpected error [{}] at {}", correlationId, request.getDescription(false), ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(
new ApiErrorResponse(Instant.now(), "Ein unerwarteter Fehler ist aufgetreten",
List.of("Bitte versuchen Sie es später erneut oder kontaktieren Sie den Support"), correlationId)
);
}
public record ApiErrorResponse(
Instant timestamp,
String message,
List<String> errors,
String correlationId
) {
}
}

View File

@@ -1,15 +0,0 @@
package dev.rheinsw.common.usecase.exception;
/**
* @author Thatsaphorn Atchariyaphap
* @since 06.07.25
*/
public class UseCaseException extends RuntimeException {
public UseCaseException(String message) {
super(message);
}
public UseCaseException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,38 @@
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;
/**
* Base Entity
*
* @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();
}
}

View File

@@ -1,12 +1,14 @@
package dev.rheinsw.common.rest; package dev.rheinsw.shared.rest;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
/** /**
* @author Thatsaphorn Atchariyaphap * @author Thatsaphorn Atchariyaphap
* @since 23.07.25 * @since 23.04.25
*/ */
@Configuration @Configuration
public class RestTemplateConfig { public class RestTemplateConfig {
@@ -16,5 +18,4 @@ public class RestTemplateConfig {
return new RestTemplate(); return new RestTemplate();
} }
} }

View File

@@ -1,4 +1,4 @@
package dev.rheinsw.common.dtos; package dev.rheinsw.shared.transport;
import java.io.Serializable; import java.io.Serializable;

View File

@@ -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");
}
}

View File

@@ -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 plainRestTemplate_shouldReturnNonNullRestTemplate() {
// Act
RestTemplate restTemplate = restTemplateConfig.plainRestTemplate();
// Assert
assertNotNull(restTemplate, "RestTemplate should not be null");
}
@Test
void plainRestTemplate_shouldCreateNewInstanceEachTime() {
// Act
RestTemplate restTemplate1 = restTemplateConfig.plainRestTemplate();
RestTemplate restTemplate2 = restTemplateConfig.plainRestTemplate();
// Assert
assertNotSame(restTemplate1, restTemplate2, "Each call should create a new RestTemplate instance");
}
}

88
backend/gateway/pom.xml Normal file
View File

@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.rheinsw</groupId>
<artifactId>backend</artifactId>
<version>1.0.0</version>
</parent>
<groupId>dev.rheinsw.backend</groupId>
<artifactId>gateway</artifactId>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>${maven.compiler.plugin.version}</version>
<configuration>
<source>${maven.compiler.source}</source>
<target>${maven.compiler.target}</target>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<id>copy-dependencies</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/libs</outputDirectory>
<includeScope>runtime</includeScope>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!-- https://mvnrepository.com/artifact/com.github.ben-manes.caffeine/caffeine -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.2.0</version>
</dependency>
<!-- Tools -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>dev.rheinsw</groupId>
<artifactId>common</artifactId>
<version>1.0.0</version>
<scope>compile</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,15 @@
package dev.rheinsw.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* @author Thatsaphorn Atchariyaphap
* @since 04.05.25
*/
@SpringBootApplication
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
}

View File

@@ -0,0 +1,18 @@
server:
port: 8080
spring:
application:
name: gateway
main:
web-application-type: reactive # Set the application type to reactive
cloud:
gateway:
routes:
- id: server
uri: http://${SERVER_HOST:localhost}:8081
predicates:
- Path=/api/**
filters:
- StripPrefix=1

View File

@@ -11,6 +11,7 @@
<modules> <modules>
<module>common</module> <module>common</module>
<module>gateway</module>
<module>server</module> <module>server</module>
</modules> </modules>

View File

@@ -75,14 +75,6 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId> <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Tools --> <!-- Tools -->
<!-- https://mvnrepository.com/artifact/org.projectlombok/lombok --> <!-- https://mvnrepository.com/artifact/org.projectlombok/lombok -->
@@ -104,17 +96,6 @@
<groupId>org.flywaydb</groupId> <groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId> <artifactId>flyway-database-postgresql</artifactId>
</dependency> </dependency>
<!-- FIX: com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Java 8 date/time type `java.time.Instant` not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling (through reference chain: java.util.ArrayList[0]->dev.rheinsw.server.customer.model.records.CustomerNote["createdAt"]) -->
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<dependency>
<groupId>com.vladmihalcea</groupId>
<artifactId>hibernate-types-60</artifactId> <!-- for Hibernate 6 -->
<version>2.21.1</version>
</dependency>
<dependency> <dependency>
<groupId>dev.rheinsw</groupId> <groupId>dev.rheinsw</groupId>
@@ -122,23 +103,6 @@
<version>1.0.0</version> <version>1.0.0</version>
<scope>compile</scope> <scope>compile</scope>
</dependency> </dependency>
<!-- Test Dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>

View File

@@ -1,7 +1,7 @@
package dev.rheinsw.server.internal.contact.controller; package dev.rheinsw.server.contact.controller;
import dev.rheinsw.server.internal.contact.model.ContactRequestDto; import dev.rheinsw.server.contact.model.ContactRequestDto;
import dev.rheinsw.server.internal.contact.usecase.SubmitContactUseCase; import dev.rheinsw.server.contact.usecase.SubmitContactUseCase;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -19,7 +19,7 @@ import org.springframework.web.bind.annotation.RestController;
*/ */
@RestController @RestController
@AllArgsConstructor @AllArgsConstructor
@RequestMapping("/api/contact") @RequestMapping("/contact")
public class ContactController { public class ContactController {
private static final Logger log = LoggerFactory.getLogger(ContactController.class); private static final Logger log = LoggerFactory.getLogger(ContactController.class);

View File

@@ -1,4 +1,4 @@
package dev.rheinsw.server.internal.contact.model; package dev.rheinsw.server.contact.model;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;

View File

@@ -1,6 +1,6 @@
package dev.rheinsw.server.internal.contact.model; package dev.rheinsw.server.contact.model;
import dev.rheinsw.common.dtos.Dto; import dev.rheinsw.shared.transport.Dto;
/** /**
* @param company optional * @param company optional

View File

@@ -1,4 +1,4 @@
package dev.rheinsw.server.internal.contact.model; package dev.rheinsw.server.contact.model;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;

View File

@@ -1,6 +1,6 @@
package dev.rheinsw.server.internal.contact.repository; package dev.rheinsw.server.contact.repository;
import dev.rheinsw.server.internal.contact.model.ContactRequest; import dev.rheinsw.server.contact.model.ContactRequest;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
/** /**

View File

@@ -1,6 +1,6 @@
package dev.rheinsw.server.internal.contact.usecase; package dev.rheinsw.server.contact.usecase;
import dev.rheinsw.server.internal.contact.model.ContactRequestDto; import dev.rheinsw.server.contact.model.ContactRequestDto;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
/** /**

View File

@@ -1,11 +1,11 @@
package dev.rheinsw.server.internal.contact.usecase; package dev.rheinsw.server.contact.usecase;
import dev.rheinsw.server.internal.contact.model.ContactRequest; import dev.rheinsw.server.contact.model.ContactRequest;
import dev.rheinsw.server.internal.contact.model.ContactRequestDto; import dev.rheinsw.server.contact.model.ContactRequestDto;
import dev.rheinsw.server.internal.contact.repository.ContactRequestsRepo; import dev.rheinsw.server.contact.repository.ContactRequestsRepo;
import dev.rheinsw.server.internal.contact.util.HCaptchaValidator; import dev.rheinsw.server.contact.util.HCaptchaValidator;
import dev.rheinsw.server.internal.mail.domain.MailRequest; import dev.rheinsw.server.mail.domain.MailRequest;
import dev.rheinsw.server.internal.mail.usecase.SendMailUseCase; import dev.rheinsw.server.mail.usecase.SendMailUseCase;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;

View File

@@ -1,6 +1,6 @@
package dev.rheinsw.server.internal.contact.util; package dev.rheinsw.server.contact.util;
import dev.rheinsw.server.internal.contact.model.HCaptchaConfig; import dev.rheinsw.server.contact.model.HCaptchaConfig;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;

View File

@@ -1,112 +0,0 @@
package dev.rheinsw.server.internal.customer.controller;
import dev.rheinsw.server.system.controller.AbstractController;
import dev.rheinsw.server.internal.customer.dtos.CreateCustomerDto;
import dev.rheinsw.server.internal.customer.dtos.CustomerValidationRequest;
import dev.rheinsw.server.internal.customer.model.Customer;
import dev.rheinsw.server.internal.customer.repository.CustomerRepository;
import dev.rheinsw.server.internal.customer.usecase.LoadCustomerQuery;
import dev.rheinsw.server.internal.customer.usecase.RegisterCustomerUseCase;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.UUID;
/**
* @author Thatsaphorn Atchariyaphap
* @since 02.07.25
*/
@Slf4j
@RestController
@RequestMapping("/api/customers")
@RequiredArgsConstructor
public class CustomerController extends AbstractController {
private final CustomerRepository repository;
private final RegisterCustomerUseCase registerCustomerUseCase;
private final LoadCustomerQuery loadCustomerQuery;
@PostMapping
public ResponseEntity<UUID> register(@Valid @RequestBody CreateCustomerDto request) {
var currentUser = getUserFromCurrentSession();
log.info("User {} registering new customer: {} ({})",
currentUser.getUsername(), request.name(), request.email());
var result = registerCustomerUseCase.register(
currentUser,
request.email(),
request.name(),
request.companyName(),
request.phoneNumbers(),
request.street(),
request.zip(),
request.city(),
request.notes()
);
log.info("Successfully registered customer with ID: {} by user: {}",
result, currentUser.getUsername());
return ResponseEntity.ok(result);
}
@GetMapping("/{id}")
public ResponseEntity<Customer> loadById(@PathVariable("id") UUID id) {
if (id == null) {
log.warn("Attempted to load customer with null ID");
throw new IllegalArgumentException("Customer ID cannot be null");
}
var currentUser = getUserFromCurrentSession();
log.debug("User {} loading customer: {}", currentUser.getUsername(), id);
Customer customer = loadCustomerQuery.loadById(id);
log.debug("Successfully loaded customer: {} for user: {}",
customer.getId(), currentUser.getUsername());
return ResponseEntity.ok(customer);
}
@GetMapping
public ResponseEntity<List<Customer>> findAll() {
var currentUser = getUserFromCurrentSession();
log.debug("User {} loading all customers", currentUser.getUsername());
var result = loadCustomerQuery.findAll();
log.info("User {} loaded {} customers", currentUser.getUsername(), result.size());
return ResponseEntity.ok(result);
}
@PostMapping("/validate")
public ResponseEntity<List<Customer>> validateCustomer(@Valid @RequestBody CustomerValidationRequest request) {
var currentUser = getUserFromCurrentSession();
log.debug("User {} validating potential customer duplicates for email: {}",
currentUser.getUsername(), request.email());
List<Customer> matches = repository.findPotentialDuplicates(
request.email(),
request.companyName(),
request.street(),
request.zip(),
request.city()
);
log.info("Found {} potential duplicate customers for validation by user: {}",
matches.size(), currentUser.getUsername());
return ResponseEntity.ok(matches);
}
}

View File

@@ -1,46 +0,0 @@
package dev.rheinsw.server.internal.customer.dtos;
import dev.rheinsw.server.internal.customer.model.records.CustomerNote;
import dev.rheinsw.server.internal.customer.model.records.CustomerPhoneNumber;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.util.List;
/**
* @author Thatsaphorn Atchariyaphap
* @since 06.07.25
*/
public record CreateCustomerDto(
@NotBlank(message = "Email is required")
@Email(message = "Email must be valid")
String email,
@NotBlank(message = "Name is required")
@Size(max = 255, message = "Name cannot exceed 255 characters")
String name,
@Size(max = 255, message = "Company name cannot exceed 255 characters")
String companyName,
@Valid
List<CustomerPhoneNumber> phoneNumbers,
@NotBlank(message = "Street is required")
@Size(max = 255, message = "Street cannot exceed 255 characters")
String street,
@NotBlank(message = "ZIP code is required")
@Size(max = 10, message = "ZIP code cannot exceed 10 characters")
String zip,
@NotBlank(message = "City is required")
@Size(max = 100, message = "City cannot exceed 100 characters")
String city,
@Valid
List<CustomerNote> notes
) {
}

View File

@@ -1,26 +0,0 @@
package dev.rheinsw.server.internal.customer.dtos;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Size;
/**
* @author Thatsaphorn Atchariyaphap
* @since 06.07.25
*/
public record CustomerValidationRequest(
@Email(message = "Email must be valid if provided")
String email,
@Size(max = 255, message = "Company name cannot exceed 255 characters")
String companyName,
@Size(max = 255, message = "Street cannot exceed 255 characters")
String street,
@Size(max = 10, message = "ZIP code cannot exceed 10 characters")
String zip,
@Size(max = 100, message = "City cannot exceed 100 characters")
String city
) {
}

View File

@@ -1,41 +0,0 @@
package dev.rheinsw.server.internal.customer.model;
import com.vladmihalcea.hibernate.type.json.JsonType;
import dev.rheinsw.server.system.entity.BaseEntity;
import dev.rheinsw.server.internal.customer.model.records.CustomerNote;
import dev.rheinsw.server.internal.customer.model.records.CustomerPhoneNumber;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.Type;
import java.util.List;
import java.util.UUID;
@Entity
@Table(name = "customer")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Customer extends BaseEntity {
@Id
private UUID id;
private String email;
private String name;
private String companyName;
@Column(name = "phone_numbers", columnDefinition = "jsonb")
@Type(JsonType.class)
private List<CustomerPhoneNumber> phoneNumbers;
private String street;
private String zip;
private String city;
@Column(name = "notes", columnDefinition = "jsonb")
@Type(JsonType.class)
private List<CustomerNote> notes;
}

View File

@@ -1,16 +0,0 @@
package dev.rheinsw.server.internal.customer.model.records;
import java.time.Instant;
/**
* @author Thatsaphorn Atchariyaphap
* @since 02.07.25
*/
public record CustomerNote(
String text,
Long createdBy,
Long updatedBy,
Instant createdAt,
Instant updatedAt
) {
}

View File

@@ -1,17 +0,0 @@
package dev.rheinsw.server.internal.customer.model.records;
import java.time.Instant;
/**
* @author Thatsaphorn Atchariyaphap
* @since 02.07.25
*/
public record CustomerPhoneNumber(
String number,
String note,
Long createdBy,
Long updatedBy,
Instant createdAt,
Instant updatedAt
) {
}

View File

@@ -1,37 +0,0 @@
package dev.rheinsw.server.internal.customer.repository;
import dev.rheinsw.server.internal.customer.model.Customer;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* @author Thatsaphorn Atchariyaphap
* @since 02.07.25
*/
public interface CustomerRepository extends JpaRepository<Customer, UUID> {
Optional<Customer> findByEmail(String email);
@Query("""
SELECT c FROM Customer c
WHERE LOWER(c.email) = LOWER(:email)
OR LOWER(c.companyName) = LOWER(:companyName)
OR (
LOWER(c.street) = LOWER(:street)
AND LOWER(c.zip) = LOWER(:zip)
AND LOWER(c.city) = LOWER(:city)
)
""")
List<Customer> findPotentialDuplicates(
@Param("email") String email,
@Param("companyName") String companyName,
@Param("street") String street,
@Param("zip") String zip,
@Param("city") String city
);
}

View File

@@ -1,76 +0,0 @@
package dev.rheinsw.server.internal.customer.usecase;
import dev.rheinsw.common.usecase.exception.UseCaseException;
import dev.rheinsw.server.internal.customer.model.Customer;
import dev.rheinsw.server.internal.customer.model.records.CustomerNote;
import dev.rheinsw.server.internal.customer.model.records.CustomerPhoneNumber;
import dev.rheinsw.server.internal.customer.repository.CustomerRepository;
import dev.rheinsw.server.security.user.entity.User;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
/**
* @author Thatsaphorn Atchariyaphap
* @since 02.07.25
*/
@Service
@RequiredArgsConstructor
public class CustomerUseCaseImpl implements RegisterCustomerUseCase, LoadCustomerQuery {
private final CustomerRepository repository;
@Override
public UUID register(
User creator,
String email,
String name,
String companyName,
List<CustomerPhoneNumber> phoneNumbers,
String street,
String zip,
String city,
List<CustomerNote> notes) {
if (repository.findByEmail(email).isPresent()) {
throw new UseCaseException("Ein Kunde mit dieser E-Mail-Adresse existiert bereits.");
}
final var now = Instant.now();
var enrichedPhoneNumbers = phoneNumbers.stream()
.map(p -> new CustomerPhoneNumber(p.number(), p.note(), creator.getId(), creator.getId(), now, now))
.toList();
var enrichedNotes = notes.stream()
.map(n -> new CustomerNote(n.text(), creator.getId(), creator.getId(), now, now))
.toList();
Customer customer = Customer.builder()
.id(UUID.randomUUID())
.email(email)
.name(name)
.companyName(companyName)
.phoneNumbers(enrichedPhoneNumbers)
.street(street)
.zip(zip)
.city(city)
.notes(enrichedNotes)
.build();
var result = repository.save(customer);
return result.getId();
}
@Override
public Customer loadById(UUID id) {
return repository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Customer not found: " + id));
}
@Override
public List<Customer> findAll() {
return repository.findAll();
}
}

View File

@@ -1,15 +0,0 @@
package dev.rheinsw.server.internal.customer.usecase;
import dev.rheinsw.server.internal.customer.model.Customer;
import java.util.List;
import java.util.UUID;
/**
* @author Thatsaphorn Atchariyaphap
* @since 02.07.25
*/
public interface LoadCustomerQuery {
Customer loadById(UUID id);
List<Customer> findAll();
}

View File

@@ -1,26 +0,0 @@
package dev.rheinsw.server.internal.customer.usecase;
import dev.rheinsw.server.internal.customer.model.records.CustomerNote;
import dev.rheinsw.server.internal.customer.model.records.CustomerPhoneNumber;
import dev.rheinsw.server.security.user.entity.User;
import java.util.List;
import java.util.UUID;
/**
* @author Thatsaphorn Atchariyaphap
* @since 02.07.25
*/
public interface RegisterCustomerUseCase {
UUID register(
User creator,
String email,
String name,
String companyName,
List<CustomerPhoneNumber> phoneNumbers,
String street,
String zip,
String city,
List<CustomerNote> notes
);
}

View File

@@ -1,47 +0,0 @@
package dev.rheinsw.server.internal.demo.model;
import dev.rheinsw.server.internal.customer.model.Customer;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.UUID;
/**
* @author Thatsaphorn Atchariyaphap
* @since 02.07.25
*/
@Entity
@Table(name = "demo")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Demo {
@Id
private UUID id;
@ManyToOne
@JoinColumn(name = "customer_id")
private Customer customer;
private String name;
private String demoUrl;
private String containerName;
private LocalDate createdDate;
private LocalTime createdTime;
private LocalDate updatedDate;
private LocalTime updatedTime;
}

View File

@@ -1,51 +0,0 @@
package dev.rheinsw.server.internal.demo.model;
import dev.rheinsw.server.internal.customer.model.Customer;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.UUID;
/**
* @author Thatsaphorn Atchariyaphap
* @since 02.07.25
*/
@Entity
@Table(name = "demo_access")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class DemoAccess {
@Id
private UUID id;
@ManyToOne
@JoinColumn(name = "customer_id")
private Customer customer;
@ManyToOne
@JoinColumn(name = "demo_id")
private Demo demo;
private String codeHash;
private LocalDate codeExpiresDate;
private LocalTime codeExpiresTime;
private boolean used;
private LocalDate createdDate;
private LocalTime createdTime;
private LocalDate updatedDate;
private LocalTime updatedTime;
}

View File

@@ -1,43 +0,0 @@
package dev.rheinsw.server.internal.demo.model;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.UUID;
/**
* @author Thatsaphorn Atchariyaphap
* @since 02.07.25
*/
@Entity
@Table(name = "demo_access_history")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class DemoAccessHistory {
@Id
private UUID id;
@ManyToOne
@JoinColumn(name = "demo_access_id")
private DemoAccess demoAccess;
private String ipAddress;
private LocalDate accessedDate;
private LocalTime accessedTime;
private LocalDate updatedDate;
private LocalTime updatedTime;
}

View File

@@ -1,101 +0,0 @@
package dev.rheinsw.server.internal.project.controller;
import dev.rheinsw.server.system.controller.AbstractController;
import dev.rheinsw.server.internal.project.model.CreateCustomerProjectDto;
import dev.rheinsw.server.internal.project.model.Project;
import dev.rheinsw.server.internal.project.model.records.ProjectNote;
import dev.rheinsw.server.internal.project.usecase.ProjectUseCaseImpl;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
/**
* @author Thatsaphorn Atchariyaphap
* @since 12.07.25
*/
@Slf4j
@RestController
@RequestMapping("/api/projects")
@RequiredArgsConstructor
public class ProjectController extends AbstractController {
private final ProjectUseCaseImpl useCase;
@PostMapping
public ResponseEntity<UUID> create(@Valid @RequestBody CreateCustomerProjectDto request) {
var currentUser = getUserFromCurrentSession();
log.info("User {} creating new project: {} for customer: {}",
currentUser.getUsername(), request.name(), request.customerId());
var now = Instant.now();
var notes = request.notes().stream()
.map(n -> new ProjectNote(n.text(), currentUser.getId(), currentUser.getId(), now, now))
.toList();
var result = useCase.createProject(
currentUser,
request.customerId(),
request.name(),
request.description(),
request.status(),
notes
);
log.info("Successfully created project with ID: {} by user: {}",
result, currentUser.getUsername());
return ResponseEntity.ok(result);
}
@GetMapping("/{id}")
public ResponseEntity<Project> findProjectById(@PathVariable("id") UUID id) {
if (id == null) {
log.warn("Attempted to load project with null ID");
throw new IllegalArgumentException("Project ID cannot be null");
}
var currentUser = getUserFromCurrentSession();
log.debug("User {} loading project: {}", currentUser.getUsername(), id);
var result = useCase.getProjectById(id);
if (result == null) {
log.warn("Project not found: {} requested by user: {}", id, currentUser.getUsername());
throw new IllegalArgumentException("Project not found: " + id);
}
log.debug("Successfully loaded project: {} for user: {}",
result.getId(), currentUser.getUsername());
return ResponseEntity.ok(result);
}
@GetMapping("/customer/{customerId}")
public ResponseEntity<List<Project>> findAllCustomerProjects(@PathVariable("customerId") UUID customerId) {
if (customerId == null) {
log.warn("Attempted to load projects with null customer ID");
throw new IllegalArgumentException("Customer ID cannot be null");
}
var currentUser = getUserFromCurrentSession();
log.debug("User {} loading projects for customer: {}", currentUser.getUsername(), customerId);
var result = useCase.getProjectsByCustomerId(customerId);
log.info("User {} loaded {} projects for customer: {}",
currentUser.getUsername(), result.size(), customerId);
return ResponseEntity.ok(result);
}
}

View File

@@ -1,43 +0,0 @@
package dev.rheinsw.server.internal.project.model;
import dev.rheinsw.server.internal.project.model.enums.ProjectStatus;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/**
* @author Thatsaphorn Atchariyaphap
* @since 13.07.25
*/
public record CreateCustomerProjectDto(
@NotNull(message = "Customer ID is required")
UUID customerId, // Reference to the related customer
@NotBlank(message = "Project name is required")
@Size(max = 255, message = "Project name cannot exceed 255 characters")
String name, // Project name
@Size(max = 2000, message = "Project description cannot exceed 2000 characters")
String description, // Optional project description
@NotNull(message = "Project status is required")
ProjectStatus status, // Enum for project status
@Valid
List<ProjectNoteDto> notes, // Optional list of project notes
LocalDate startDate // Project start date
) {
public record ProjectNoteDto(
@NotBlank(message = "Note text is required")
@Size(max = 1000, message = "Note text cannot exceed 1000 characters")
String text // Note text
) {
}
}

View File

@@ -1,50 +0,0 @@
package dev.rheinsw.server.internal.project.model;
import com.vladmihalcea.hibernate.type.json.JsonType;
import dev.rheinsw.server.system.entity.BaseEntity;
import dev.rheinsw.server.internal.project.model.enums.ProjectStatus;
import dev.rheinsw.server.internal.project.model.records.ProjectNote;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.Type;
import java.util.List;
import java.util.UUID;
/**
* @author Thatsaphorn Atchariyaphap
* @since 12.07.25
*/
@Entity
@Table(name = "project")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Project extends BaseEntity {
@Id
private UUID id;
private UUID customerId;
private String name;
private String description;
@Enumerated(EnumType.STRING)
private ProjectStatus status;
@Column(name = "notes", columnDefinition = "jsonb")
@Type(JsonType.class)
private List<ProjectNote> notes;
}

View File

@@ -1,13 +0,0 @@
package dev.rheinsw.server.internal.project.model.enums;
/**
* @author Thatsaphorn Atchariyaphap
* @since 12.07.25
*/
public enum ProjectStatus {
PLANNED,
IN_PROGRESS,
COMPLETED,
ON_HOLD,
CANCELLED
}

View File

@@ -1,14 +0,0 @@
package dev.rheinsw.server.internal.project.model.records;
import java.time.Instant;
/**
* @author Thatsaphorn Atchariyaphap
* @since 12.07.25
*/
public record ProjectNote(String text,
Long createdBy,
Long updatedBy,
Instant createdAt,
Instant updatedAt) {
}

View File

@@ -1,15 +0,0 @@
package dev.rheinsw.server.internal.project.repository;
import dev.rheinsw.server.internal.project.model.Project;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
/**
* @author Thatsaphorn Atchariyaphap
* @since 12.07.25
*/
public interface ProjectRepository extends JpaRepository<Project, UUID> {
List<Project> findByCustomerId(UUID customerId);
}

View File

@@ -1,23 +0,0 @@
package dev.rheinsw.server.internal.project.usecase;
import dev.rheinsw.server.security.user.entity.User;
import dev.rheinsw.server.internal.project.model.enums.ProjectStatus;
import dev.rheinsw.server.internal.project.model.records.ProjectNote;
import java.util.List;
import java.util.UUID;
/**
* @author Thatsaphorn Atchariyaphap
* @since 13.07.25
*/
public interface CreateProjectUseCase {
UUID createProject(
User creator,
UUID customerId,
String name,
String description,
ProjectStatus status,
List<ProjectNote> notes
);
}

View File

@@ -1,16 +0,0 @@
package dev.rheinsw.server.internal.project.usecase;
import dev.rheinsw.server.internal.project.model.Project;
import java.util.List;
import java.util.UUID;
/**
* @author Thatsaphorn Atchariyaphap
* @since 12.07.25
*/
public interface LoadProjectUseCase {
Project getProjectById(UUID id);
List<Project> getProjectsByCustomerId(UUID customerId);
}

View File

@@ -1,120 +0,0 @@
package dev.rheinsw.server.internal.project.usecase;
import dev.rheinsw.common.usecase.exception.UseCaseException;
import dev.rheinsw.server.security.user.entity.User;
import dev.rheinsw.server.internal.project.model.Project;
import dev.rheinsw.server.internal.project.model.enums.ProjectStatus;
import dev.rheinsw.server.internal.project.model.records.ProjectNote;
import dev.rheinsw.server.internal.project.repository.ProjectRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
/**
* @author Thatsaphorn Atchariyaphap
* @since 12.07.25
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ProjectUseCaseImpl implements LoadProjectUseCase, CreateProjectUseCase {
private final ProjectRepository repository;
@Override
public UUID createProject(
User creator,
UUID customerId,
String name,
String description,
ProjectStatus status,
List<ProjectNote> notes
) {
if (creator == null) {
log.error("Cannot create project with null creator");
throw new IllegalArgumentException("Creator cannot be null");
}
if (customerId == null) {
log.error("Cannot create project with null customer ID");
throw new IllegalArgumentException("Customer ID cannot be null");
}
if (name == null || name.trim().isEmpty()) {
log.error("Cannot create project with null or empty name");
throw new IllegalArgumentException("Project name cannot be null or empty");
}
log.debug("Creating project '{}' for customer: {} by user: {}",
name, customerId, creator.getUsername());
try {
final var now = Instant.now();
var enrichedNotes = (notes != null) ? notes.stream()
.map(n -> new ProjectNote(n.text(), creator.getId(), creator.getId(), now, now))
.toList() : List.<ProjectNote>of();
Project project = Project.builder()
.id(UUID.randomUUID())
.customerId(customerId)
.name(name.trim())
.description(description != null ? description.trim() : null)
.status(status)
.notes(enrichedNotes)
.build();
var savedProject = repository.save(project);
log.info("Successfully created project: {} for customer: {} by user: {}",
savedProject.getId(), customerId, creator.getUsername());
return savedProject.getId();
} catch (Exception e) {
log.error("Failed to create project '{}' for customer: {} by user: {}",
name, customerId, creator.getUsername(), e);
throw new UseCaseException("Failed to create project: " + e.getMessage());
}
}
@Override
public Project getProjectById(UUID id) {
if (id == null) {
log.error("Cannot get project with null ID");
throw new IllegalArgumentException("Project ID cannot be null");
}
log.debug("Loading project: {}", id);
return repository.findById(id)
.map(project -> {
log.debug("Found project: {} ({})", project.getName(), project.getId());
return project;
})
.orElseThrow(() -> {
log.warn("Project not found: {}", id);
return new UseCaseException("Project not found: " + id);
});
}
@Override
public List<Project> getProjectsByCustomerId(UUID customerId) {
if (customerId == null) {
log.error("Cannot get projects with null customer ID");
throw new IllegalArgumentException("Customer ID cannot be null");
}
log.debug("Loading projects for customer: {}", customerId);
try {
List<Project> projects = repository.findByCustomerId(customerId);
log.debug("Found {} projects for customer: {}", projects.size(), customerId);
return projects;
} catch (Exception e) {
log.error("Failed to load projects for customer: {}", customerId, e);
throw new UseCaseException("Failed to load projects for customer: " + e.getMessage());
}
}
}

View File

@@ -1,7 +1,7 @@
package dev.rheinsw.server.internal.mail.controller; package dev.rheinsw.server.mail.controller;
import dev.rheinsw.server.internal.mail.usecase.SendMailUseCase; import dev.rheinsw.server.mail.usecase.SendMailUseCase;
import dev.rheinsw.server.internal.mail.domain.MailRequest; import dev.rheinsw.server.mail.domain.MailRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -14,7 +14,7 @@ import org.springframework.web.bind.annotation.RestController;
* @since 04.05.25 * @since 04.05.25
*/ */
@RestController @RestController
@RequestMapping("/api/mail") @RequestMapping("/mail")
@RequiredArgsConstructor @RequiredArgsConstructor
public class MailController { public class MailController {

View File

@@ -1,4 +1,4 @@
package dev.rheinsw.server.internal.mail.domain; package dev.rheinsw.server.mail.domain;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;

View File

@@ -1,6 +1,6 @@
package dev.rheinsw.server.internal.mail.usecase; package dev.rheinsw.server.mail.usecase;
import dev.rheinsw.server.internal.mail.domain.MailRequest; import dev.rheinsw.server.mail.domain.MailRequest;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
/** /**

View File

@@ -1,7 +1,7 @@
package dev.rheinsw.server.internal.mail.usecase; package dev.rheinsw.server.mail.usecase;
import dev.rheinsw.server.internal.mail.domain.MailRequest; import dev.rheinsw.server.mail.domain.MailRequest;
import jakarta.mail.MessagingException; import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage; import jakarta.mail.internet.MimeMessage;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;

View File

@@ -1,50 +0,0 @@
package dev.rheinsw.server.security;
import dev.rheinsw.server.security.session.UserSessionFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.SecurityFilterChain;
/**
* @author Thatsaphorn Atchariyaphap
* @since 04.07.25
*/
@Configuration
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final UserSessionFilter userSessionEnrichmentFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
).addFilterAfter(userSessionEnrichmentFilter, org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationFilter.class);
;
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
grantedAuthoritiesConverter.setAuthoritiesClaimName("realm_access.roles");
grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
return converter;
}
}

View File

@@ -1,35 +0,0 @@
package dev.rheinsw.server.security.audit;
import dev.rheinsw.server.security.session.CurrentSessionProvider;
import dev.rheinsw.server.security.user.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.AuditorAware;
import java.util.Optional;
/**
* @author Thatsaphorn Atchariyaphap
* @since 04.07.25
*/
@Configuration
@RequiredArgsConstructor
public class AuditorConfig {
private final CurrentSessionProvider sessionProvider;
private final UserService userService;
@Bean
public AuditorAware<Long> auditorProvider() {
return () -> {
try {
var session = sessionProvider.getCurrentSession();
var user = userService.getUserBySession(session);
return Optional.of(user.getId());
} catch (Exception e) {
return Optional.empty(); // anonymous access (public endpoints)
}
};
}
}

View File

@@ -1,62 +0,0 @@
package dev.rheinsw.server.security.session;
import dev.rheinsw.server.security.session.model.CurrentSession;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Component;
/**
* @author Thatsaphorn Atchariyaphap
* @since 04.07.25
*/
@Slf4j
@Component
public class CurrentSessionProvider {
public CurrentSession getCurrentSession() {
log.debug("Retrieving current user session");
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null) {
log.warn("No authentication context found");
throw new IllegalStateException("Authentication is missing");
}
if (auth.getPrincipal() == null) {
log.warn("Authentication principal is null");
throw new IllegalStateException("Authentication principal is missing");
}
if (!(auth.getPrincipal() instanceof Jwt jwt)) {
log.warn("Authentication principal is not a JWT token: {}",
auth.getPrincipal().getClass().getSimpleName());
throw new IllegalStateException("JWT is missing or invalid");
}
String sub = jwt.getClaimAsString("sub");
String username = jwt.getClaimAsString("preferred_username");
String email = jwt.getClaimAsString("email");
if (sub == null || sub.isEmpty()) {
log.error("JWT 'sub' claim is missing or empty");
throw new IllegalStateException("Required JWT claim 'sub' is missing");
}
if (username == null || username.isEmpty()) {
log.error("JWT 'preferred_username' claim is missing or empty for user: {}", sub);
throw new IllegalStateException("Required JWT claim 'preferred_username' is missing");
}
if (email == null || email.isEmpty()) {
log.error("JWT 'email' claim is missing or empty for user: {}", sub);
throw new IllegalStateException("Required JWT claim 'email' is missing");
}
CurrentSession session = new CurrentSession(sub, username, email);
log.info("Successfully retrieved session for user: {} ({})", username, sub);
return session;
}
}

View File

@@ -1,57 +0,0 @@
package dev.rheinsw.server.security.session;
import dev.rheinsw.server.security.session.model.CurrentSession;
import dev.rheinsw.server.security.user.UserService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.AuthenticationException;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* @author Thatsaphorn Atchariyaphap
* @since 04.07.25
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class UserSessionFilter extends OncePerRequestFilter {
private final UserService userService;
private final CurrentSessionProvider currentSessionProvider;
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
String requestUri = request.getRequestURI();
String method = request.getMethod();
log.debug("Processing user session for {} {}", method, requestUri);
try {
CurrentSession session = currentSessionProvider.getCurrentSession();
log.debug("Retrieved session for user: {} from {}", session.username(), requestUri);
userService.getUserBySession(session);
log.debug("User validation successful for: {}", session.username());
} catch (AuthenticationException e) {
log.warn("Authentication failed for {} {}: {}", method, requestUri, e.getMessage());
} catch (IllegalStateException e) {
log.warn("Session state error for {} {}: {}", method, requestUri, e.getMessage());
} catch (Exception e) {
log.error("Unexpected error during user session processing for {} {}", method, requestUri, e);
}
filterChain.doFilter(request, response);
}
}

View File

@@ -1,10 +0,0 @@
package dev.rheinsw.server.security.session.model;
/**
* Current authenticated keycloak session
*
* @author Thatsaphorn Atchariyaphap
* @since 04.07.25
*/
public record CurrentSession(String keycloakId, String username, String email) {
}

View File

@@ -1,70 +0,0 @@
package dev.rheinsw.server.security.user;
import dev.rheinsw.server.security.user.entity.User;
import dev.rheinsw.server.security.session.model.CurrentSession;
import dev.rheinsw.server.security.user.repository.UserRepository;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Instant;
/**
* @author Thatsaphorn Atchariyaphap
* @since 04.07.25
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
@Transactional
public User getUserBySession(CurrentSession session) {
if (session == null) {
log.error("Attempted to get user with null session");
throw new IllegalArgumentException("Session cannot be null");
}
if (session.keycloakId() == null || session.keycloakId().isEmpty()) {
log.error("Attempted to get user with null or empty keycloakId");
throw new IllegalArgumentException("Session keycloakId cannot be null or empty");
}
log.debug("Looking up user for keycloakId: {}", session.keycloakId());
return userRepository.findByKeycloakId(session.keycloakId())
.map(existingUser -> {
log.debug("Found existing user: {} ({})", existingUser.getUsername(), existingUser.getId());
return existingUser;
})
.orElseGet(() -> createUser(session));
}
private User createUser(CurrentSession session) {
log.info("Creating new user for keycloakId: {}, username: {}, email: {}",
session.keycloakId(), session.username(), session.email());
try {
User newUser = User.builder()
.keycloakId(session.keycloakId())
.username(session.username())
.email(session.email())
.createdAt(Instant.now())
.build();
User savedUser = userRepository.save(newUser);
log.info("Successfully created new user with ID: {} for username: {}",
savedUser.getId(), savedUser.getUsername());
return savedUser;
} catch (Exception e) {
log.error("Failed to create new user for keycloakId: {}, username: {}",
session.keycloakId(), session.username(), e);
throw new RuntimeException("Failed to create user", e);
}
}
}

View File

@@ -1,68 +0,0 @@
package dev.rheinsw.server.security.user.entity;
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.persistence.Version;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import java.time.Instant;
/**
* @author Thatsaphorn Atchariyaphap
* @since 04.07.25
*/
@Entity
@Table(name = "users")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // auto-increment
private Long id;
@Column(nullable = false, unique = true)
private String keycloakId; // the `sub` field from JWT, as a string
@Column(nullable = false, unique = true)
private String username;
@Column(nullable = false)
private String email;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
protected Instant createdAt;
@LastModifiedDate
@Column(name = "updated_at")
protected Instant updatedAt;
@CreatedBy
@Column(name = "created_by", updatable = false)
protected Long createdBy;
@LastModifiedBy
@Column(name = "updated_by")
protected Long updatedBy;
@Version
@Column(name = "version")
protected Long version;
}

View File

@@ -1,14 +0,0 @@
package dev.rheinsw.server.security.user.repository;
import dev.rheinsw.server.security.user.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
/**
* @author Thatsaphorn Atchariyaphap
* @since 04.07.25
*/
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByKeycloakId(String keycloakId);
}

View File

@@ -1,39 +0,0 @@
package dev.rheinsw.server.system.controller;
import dev.rheinsw.server.security.session.CurrentSessionProvider;
import dev.rheinsw.server.security.session.model.CurrentSession;
import dev.rheinsw.server.security.user.UserService;
import dev.rheinsw.server.security.user.entity.User;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
/**
* @author Thatsaphorn Atchariyaphap
* @since 04.07.25
*/
@RequiredArgsConstructor
public abstract class AbstractController {
protected CurrentSessionProvider currentSessionProvider;
protected UserService userService;
@Autowired
protected void setCurrentSessionProvider(CurrentSessionProvider currentSessionProvider) {
this.currentSessionProvider = currentSessionProvider;
}
protected CurrentSession getCurrentSession() {
return currentSessionProvider.getCurrentSession();
}
@Autowired
protected void setUserService(UserService userService) {
this.userService = userService;
}
protected User getUserFromCurrentSession() {
return userService.getUserBySession(getCurrentSession());
}
}

View File

@@ -1,48 +0,0 @@
package dev.rheinsw.server.system.entity;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import jakarta.persistence.Version;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.Instant;
/**
* Base Entity
*
* @author Thatsaphorn Atchariyaphap
* @since 26.04.25
*/
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
@Setter
public abstract class BaseEntity {
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
protected Instant createdAt;
@LastModifiedDate
@Column(name = "updated_at")
protected Instant updatedAt;
@CreatedBy
@Column(name = "created_by", updatable = false)
protected Long createdBy;
@LastModifiedBy
@Column(name = "updated_by")
protected Long updatedBy;
@Version
@Column(name = "version")
protected Long version;
}

View File

@@ -1,13 +0,0 @@
package dev.rheinsw.server.system.entity.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
/**
* @author Thatsaphorn Atchariyaphap
* @since 04.07.25
*/
@Configuration
@EnableJpaAuditing(auditorAwareRef = "auditorProvider")
public class JpaAuditingConfig {
}

View File

@@ -1,16 +1,9 @@
server: server:
port: 8080 port: 8081
spring: spring:
application: application:
name: server name: server
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://sso.rhein-software.dev/realms/rhein-sw
datasource: datasource:
url: jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME} url: jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}
username: ${DB_USERNAME} username: ${DB_USERNAME}
@@ -49,4 +42,4 @@ hcaptcha:
logging: logging:
level: level:
org.hibernate.SQL: ${LOG_SQL_LEVEL} org.hibernate.SQL: ${LOG_SQL_LEVEL}
org.hibernate.type.descriptor.sql.BasicBinder: ${LOG_BINDER_LEVEL} org.hibernate.type.descriptor.sql.BasicBinder: ${LOG_BINDER_LEVEL}

View File

@@ -1,51 +1,31 @@
-- Enable UUID extension create table api_key
CREATE
EXTENSION IF NOT EXISTS "uuid-ossp";
-- 0. USERS
CREATE TABLE users
( (
id BIGSERIAL PRIMARY KEY, id bigint generated by default as identity
keycloak_id VARCHAR(255) NOT NULL UNIQUE, constraint pk_api_key
username VARCHAR(255) NOT NULL UNIQUE, primary key,
email VARCHAR(255) NOT NULL, key varchar(255) not null
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, constraint uc_api_key_key
updated_at TIMESTAMPTZ, unique,
created_by BIGINT REFERENCES users (id), type varchar(255) not null,
updated_by BIGINT REFERENCES users (id), enabled boolean not null,
version BIGINT frontend_only boolean not null,
description text,
created_date date default CURRENT_DATE,
created_time time default CURRENT_TIME,
modified_date date,
modified_time time
); );
-- 1. CONTACT REQUESTS
CREATE TABLE contact_requests CREATE TABLE contact_requests
( (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
name VARCHAR(100), name VARCHAR(100),
email VARCHAR(100), email VARCHAR(100),
message VARCHAR(1000), message VARCHAR(1000),
company VARCHAR(100), company VARCHAR(100),
phone VARCHAR(20), phone VARCHAR(20),
website VARCHAR(100), website VARCHAR(100),
captcha_token VARCHAR(1024), captcha_token VARCHAR(1024),
submitted_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP submitted_date DATE,
); submitted_time TIME
);
-- 2. CUSTOMER
CREATE TABLE customer
(
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email TEXT NOT NULL UNIQUE,
name TEXT,
company_name TEXT,
phone_numbers JSONB,
street TEXT,
zip TEXT,
city TEXT,
notes JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id),
updated_by BIGINT REFERENCES users (id),
version BIGINT
);
CREATE INDEX idx_customer_email ON customer (email);

View File

@@ -1,22 +0,0 @@
-- Migration script for Project table and related components
CREATE TABLE project
(
id UUID PRIMARY KEY,
customer_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(50) NOT NULL, -- ProjectStatus enum
notes JSONB, -- JSONB for storing ProjectNotes list
start_date VARCHAR(50),
end_date VARCHAR(50),
created_at TIMESTAMP NOT NULL, -- From BaseEntity
updated_at TIMESTAMP, -- From BaseEntity
created_by BIGINT, -- From BaseEntity
updated_by BIGINT, -- From BaseEntity
version BIGINT -- From BaseEntity
);
-- Adding a CHECK constraint to enforce valid ProjectStatus values
ALTER TABLE project
ADD CONSTRAINT chk_project_status
CHECK (status IN ('PLANNED', 'IN_PROGRESS', 'COMPLETED', 'ON_HOLD', 'CANCELLED'));

View File

@@ -1,224 +0,0 @@
package dev.rheinsw.server.internal.project.usecase;
import dev.rheinsw.common.usecase.exception.UseCaseException;
import dev.rheinsw.server.internal.project.model.Project;
import dev.rheinsw.server.internal.project.model.enums.ProjectStatus;
import dev.rheinsw.server.internal.project.model.records.ProjectNote;
import dev.rheinsw.server.internal.project.repository.ProjectRepository;
import dev.rheinsw.server.security.user.entity.User;
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.junit.jupiter.MockitoExtension;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
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.ArgumentMatchers.argThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ProjectUseCaseImplTest {
@Mock
private ProjectRepository projectRepository;
private ProjectUseCaseImpl projectUseCase;
private User testUser;
private UUID customerId;
@BeforeEach
void setUp() {
projectUseCase = new ProjectUseCaseImpl(projectRepository);
testUser = User.builder()
.id(1L)
.keycloakId("keycloak123")
.username("testuser")
.email("test@example.com")
.createdAt(Instant.now())
.build();
customerId = UUID.randomUUID();
}
@Test
void createProject_WithValidData_ShouldCreateProject() {
// Given
String projectName = "Test Project";
String description = "Test Description";
ProjectStatus status = ProjectStatus.IN_PROGRESS;
List<ProjectNote> notes = List.of();
Project savedProject = Project.builder()
.id(UUID.randomUUID())
.customerId(customerId)
.name(projectName)
.description(description)
.status(status)
.notes(List.of())
.build();
when(projectRepository.save(any(Project.class))).thenReturn(savedProject);
// When
UUID result = projectUseCase.createProject(testUser, customerId, projectName, description, status, notes);
// Then
assertThat(result).isEqualTo(savedProject.getId());
verify(projectRepository).save(argThat(project ->
project.getCustomerId().equals(customerId) &&
project.getName().equals(projectName) &&
project.getDescription().equals(description) &&
project.getStatus().equals(status)
));
}
@Test
void createProject_WithNotes_ShouldEnrichNotesWithCreatorInfo() {
// Given
String projectName = "Test Project";
List<ProjectNote> notes = List.of(
new ProjectNote("Note 1", null, null, null, null)
);
Project savedProject = Project.builder()
.id(UUID.randomUUID())
.customerId(customerId)
.name(projectName)
.status(ProjectStatus.IN_PROGRESS)
.notes(List.of())
.build();
when(projectRepository.save(any(Project.class))).thenReturn(savedProject);
// When
UUID result = projectUseCase.createProject(testUser, customerId, projectName, null, ProjectStatus.IN_PROGRESS, notes);
// Then
assertThat(result).isEqualTo(savedProject.getId());
verify(projectRepository).save(argThat(project -> {
ProjectNote enrichedNote = project.getNotes().get(0);
return enrichedNote.text().equals("Note 1") &&
enrichedNote.createdBy().equals(testUser.getId()) &&
enrichedNote.updatedBy().equals(testUser.getId()) &&
enrichedNote.createdAt() != null &&
enrichedNote.updatedAt() != null;
}));
}
@Test
void createProject_WithNullCreator_ShouldThrowException() {
// When & Then
assertThatThrownBy(() -> projectUseCase.createProject(null, customerId, "Project", null, ProjectStatus.IN_PROGRESS, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Creator cannot be null");
}
@Test
void createProject_WithNullCustomerId_ShouldThrowException() {
// When & Then
assertThatThrownBy(() -> projectUseCase.createProject(testUser, null, "Project", null, ProjectStatus.IN_PROGRESS, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Customer ID cannot be null");
}
@Test
void createProject_WithNullName_ShouldThrowException() {
// When & Then
assertThatThrownBy(() -> projectUseCase.createProject(testUser, customerId, null, null, ProjectStatus.IN_PROGRESS, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Project name cannot be null or empty");
}
@Test
void createProject_WithEmptyName_ShouldThrowException() {
// When & Then
assertThatThrownBy(() -> projectUseCase.createProject(testUser, customerId, " ", null, ProjectStatus.IN_PROGRESS, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Project name cannot be null or empty");
}
@Test
void getProjectById_WithExistingProject_ShouldReturnProject() {
// Given
UUID projectId = UUID.randomUUID();
Project project = Project.builder()
.id(projectId)
.customerId(customerId)
.name("Test Project")
.status(ProjectStatus.IN_PROGRESS)
.build();
when(projectRepository.findById(projectId)).thenReturn(Optional.of(project));
// When
Project result = projectUseCase.getProjectById(projectId);
// Then
assertThat(result).isEqualTo(project);
}
@Test
void getProjectById_WithNonExistentProject_ShouldThrowException() {
// Given
UUID projectId = UUID.randomUUID();
when(projectRepository.findById(projectId)).thenReturn(Optional.empty());
// When & Then
assertThatThrownBy(() -> projectUseCase.getProjectById(projectId))
.isInstanceOf(UseCaseException.class)
.hasMessage("Project not found: " + projectId);
}
@Test
void getProjectById_WithNullId_ShouldThrowException() {
// When & Then
assertThatThrownBy(() -> projectUseCase.getProjectById(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Project ID cannot be null");
}
@Test
void getProjectsByCustomerId_WithExistingProjects_ShouldReturnProjects() {
// Given
List<Project> projects = List.of(
Project.builder().id(UUID.randomUUID()).customerId(customerId).name("Project 1").build(),
Project.builder().id(UUID.randomUUID()).customerId(customerId).name("Project 2").build()
);
when(projectRepository.findByCustomerId(customerId)).thenReturn(projects);
// When
List<Project> result = projectUseCase.getProjectsByCustomerId(customerId);
// Then
assertThat(result).hasSize(2);
assertThat(result).isEqualTo(projects);
}
@Test
void getProjectsByCustomerId_WithNullCustomerId_ShouldThrowException() {
// When & Then
assertThatThrownBy(() -> projectUseCase.getProjectsByCustomerId(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Customer ID cannot be null");
}
@Test
void getProjectsByCustomerId_WithRepositoryException_ShouldThrowUseCaseException() {
// Given
when(projectRepository.findByCustomerId(customerId)).thenThrow(new RuntimeException("Database error"));
// When & Then
assertThatThrownBy(() -> projectUseCase.getProjectsByCustomerId(customerId))
.isInstanceOf(UseCaseException.class)
.hasMessage("Failed to load projects for customer: Database error");
}
}

View File

@@ -1,151 +0,0 @@
package dev.rheinsw.server.security.session;
import dev.rheinsw.server.security.session.model.CurrentSession;
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.junit.jupiter.MockitoExtension;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CurrentSessionProviderTest {
@Mock
private SecurityContext securityContext;
@Mock
private Authentication authentication;
@Mock
private Jwt jwt;
private CurrentSessionProvider currentSessionProvider;
@BeforeEach
void setUp() {
currentSessionProvider = new CurrentSessionProvider();
SecurityContextHolder.setContext(securityContext);
}
@Test
void getCurrentSession_WithValidJwt_ShouldReturnCurrentSession() {
// Given
when(securityContext.getAuthentication()).thenReturn(authentication);
when(authentication.getPrincipal()).thenReturn(jwt);
when(jwt.getClaimAsString("sub")).thenReturn("user123");
when(jwt.getClaimAsString("preferred_username")).thenReturn("testuser");
when(jwt.getClaimAsString("email")).thenReturn("test@example.com");
// When
CurrentSession result = currentSessionProvider.getCurrentSession();
// Then
assertThat(result).isNotNull();
assertThat(result.keycloakId()).isEqualTo("user123");
assertThat(result.username()).isEqualTo("testuser");
assertThat(result.email()).isEqualTo("test@example.com");
}
@Test
void getCurrentSession_WithNullAuthentication_ShouldThrowException() {
// Given
when(securityContext.getAuthentication()).thenReturn(null);
// When & Then
assertThatThrownBy(() -> currentSessionProvider.getCurrentSession())
.isInstanceOf(IllegalStateException.class)
.hasMessage("Authentication is missing");
}
@Test
void getCurrentSession_WithNullPrincipal_ShouldThrowException() {
// Given
when(securityContext.getAuthentication()).thenReturn(authentication);
when(authentication.getPrincipal()).thenReturn(null);
// When & Then
assertThatThrownBy(() -> currentSessionProvider.getCurrentSession())
.isInstanceOf(IllegalStateException.class)
.hasMessage("Authentication principal is missing");
}
@Test
void getCurrentSession_WithNonJwtPrincipal_ShouldThrowException() {
// Given
when(securityContext.getAuthentication()).thenReturn(authentication);
when(authentication.getPrincipal()).thenReturn("not-a-jwt");
// When & Then
assertThatThrownBy(() -> currentSessionProvider.getCurrentSession())
.isInstanceOf(IllegalStateException.class)
.hasMessage("JWT is missing or invalid");
}
@Test
void getCurrentSession_WithMissingSubClaim_ShouldThrowException() {
// Given
when(securityContext.getAuthentication()).thenReturn(authentication);
when(authentication.getPrincipal()).thenReturn(jwt);
when(jwt.getClaimAsString("sub")).thenReturn(null);
when(jwt.getClaimAsString("preferred_username")).thenReturn("testuser");
when(jwt.getClaimAsString("email")).thenReturn("test@example.com");
// When & Then
assertThatThrownBy(() -> currentSessionProvider.getCurrentSession())
.isInstanceOf(IllegalStateException.class)
.hasMessage("Required JWT claim 'sub' is missing");
}
@Test
void getCurrentSession_WithEmptySubClaim_ShouldThrowException() {
// Given
when(securityContext.getAuthentication()).thenReturn(authentication);
when(authentication.getPrincipal()).thenReturn(jwt);
when(jwt.getClaimAsString("sub")).thenReturn("");
when(jwt.getClaimAsString("preferred_username")).thenReturn("testuser");
when(jwt.getClaimAsString("email")).thenReturn("test@example.com");
// When & Then
assertThatThrownBy(() -> currentSessionProvider.getCurrentSession())
.isInstanceOf(IllegalStateException.class)
.hasMessage("Required JWT claim 'sub' is missing");
}
@Test
void getCurrentSession_WithMissingUsernameClaim_ShouldThrowException() {
// Given
when(securityContext.getAuthentication()).thenReturn(authentication);
when(authentication.getPrincipal()).thenReturn(jwt);
when(jwt.getClaimAsString("sub")).thenReturn("user123");
when(jwt.getClaimAsString("preferred_username")).thenReturn(null);
when(jwt.getClaimAsString("email")).thenReturn("test@example.com");
// When & Then
assertThatThrownBy(() -> currentSessionProvider.getCurrentSession())
.isInstanceOf(IllegalStateException.class)
.hasMessage("Required JWT claim 'preferred_username' is missing");
}
@Test
void getCurrentSession_WithMissingEmailClaim_ShouldThrowException() {
// Given
when(securityContext.getAuthentication()).thenReturn(authentication);
when(authentication.getPrincipal()).thenReturn(jwt);
when(jwt.getClaimAsString("sub")).thenReturn("user123");
when(jwt.getClaimAsString("preferred_username")).thenReturn("testuser");
when(jwt.getClaimAsString("email")).thenReturn(null);
// When & Then
assertThatThrownBy(() -> currentSessionProvider.getCurrentSession())
.isInstanceOf(IllegalStateException.class)
.hasMessage("Required JWT claim 'email' is missing");
}
}

View File

@@ -1,139 +0,0 @@
package dev.rheinsw.server.security.user;
import dev.rheinsw.server.security.session.model.CurrentSession;
import dev.rheinsw.server.security.user.entity.User;
import dev.rheinsw.server.security.user.repository.UserRepository;
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.junit.jupiter.MockitoExtension;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
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.ArgumentMatchers.argThat;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
private UserService userService;
@BeforeEach
void setUp() {
userService = new UserService(userRepository);
}
@Test
void getUserBySession_WithExistingUser_ShouldReturnUser() {
// Given
CurrentSession session = new CurrentSession("keycloak123", "testuser", "test@example.com");
User existingUser = User.builder()
.id(1L)
.keycloakId("keycloak123")
.username("testuser")
.email("test@example.com")
.createdAt(Instant.now())
.build();
when(userRepository.findByKeycloakId("keycloak123")).thenReturn(Optional.of(existingUser));
// When
User result = userService.getUserBySession(session);
// Then
assertThat(result).isEqualTo(existingUser);
verify(userRepository, never()).save(any());
}
@Test
void getUserBySession_WithNewUser_ShouldCreateAndReturnUser() {
// Given
CurrentSession session = new CurrentSession("keycloak456", "newuser", "new@example.com");
User newUser = User.builder()
.id(2L)
.keycloakId("keycloak456")
.username("newuser")
.email("new@example.com")
.createdAt(Instant.now())
.build();
when(userRepository.findByKeycloakId("keycloak456")).thenReturn(Optional.empty());
when(userRepository.save(any(User.class))).thenReturn(newUser);
// When
User result = userService.getUserBySession(session);
// Then
assertThat(result).isEqualTo(newUser);
verify(userRepository).save(argThat(user ->
user.getKeycloakId().equals("keycloak456") &&
user.getUsername().equals("newuser") &&
user.getEmail().equals("new@example.com") &&
user.getCreatedAt() != null
));
}
@Test
void getUserBySession_WithNullSession_ShouldThrowException() {
// When & Then
assertThatThrownBy(() -> userService.getUserBySession(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Session cannot be null");
verify(userRepository, never()).findByKeycloakId(any());
verify(userRepository, never()).save(any());
}
@Test
void getUserBySession_WithNullKeycloakId_ShouldThrowException() {
// Given
CurrentSession session = new CurrentSession(null, "testuser", "test@example.com");
// When & Then
assertThatThrownBy(() -> userService.getUserBySession(session))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Session keycloakId cannot be null or empty");
verify(userRepository, never()).findByKeycloakId(any());
verify(userRepository, never()).save(any());
}
@Test
void getUserBySession_WithEmptyKeycloakId_ShouldThrowException() {
// Given
CurrentSession session = new CurrentSession("", "testuser", "test@example.com");
// When & Then
assertThatThrownBy(() -> userService.getUserBySession(session))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Session keycloakId cannot be null or empty");
verify(userRepository, never()).findByKeycloakId(any());
verify(userRepository, never()).save(any());
}
@Test
void getUserBySession_WithRepositoryException_ShouldThrowRuntimeException() {
// Given
CurrentSession session = new CurrentSession("keycloak789", "testuser", "test@example.com");
when(userRepository.findByKeycloakId("keycloak789")).thenReturn(Optional.empty());
when(userRepository.save(any(User.class))).thenThrow(new RuntimeException("Database error"));
// When & Then
assertThatThrownBy(() -> userService.getUserBySession(session))
.isInstanceOf(RuntimeException.class)
.hasMessage("Failed to create user");
}
}

View File

@@ -1,11 +1,18 @@
services: services:
gateway:
image: registry.boomlab.party/rheinsw/rheinsw-mono-repo/gateway
container_name: gateway
env_file:
- ./gateway.env
restart: on-failure
networks:
- rheinsw-net
server: server:
image: registry.boomlab.party/rheinsw/rheinsw-mono-repo/server image: registry.boomlab.party/rheinsw/rheinsw-mono-repo/server
container_name: server container_name: server
env_file: env_file:
- ./server.env - ./server.env
ports:
- "8080:8080"
restart: on-failure restart: on-failure
networks: networks:
- rheinsw-net - rheinsw-net

View File

@@ -1,18 +1,28 @@
'use client'; 'use client';
import React from "react"; import React, {useEffect} from "react";
import { motion } from "framer-motion";
import { useScrollToSection } from "@/hooks/useScrollToSection";
import Hero from "@/app/(root)/sections/Hero";
import HomeServices from "@/app/(root)/sections/HomeServices"; import HomeServices from "@/app/(root)/sections/HomeServices";
import {motion} from "framer-motion";
import Hero from "@/app/(root)/sections/Hero";
import About from "@/app/(root)/sections/About"; import About from "@/app/(root)/sections/About";
import ProcessSection from "@/app/(root)/sections/ProcessSection"; import ProcessSection from "@/app/(root)/sections/ProcessSection";
import WhyUs from "@/app/(root)/sections/WhyUs"; import WhyUs from "@/app/(root)/sections/WhyUs";
import ReferralSection from "@/app/(root)/sections/ReferralSection";
import Faq from "@/app/(root)/sections/Faq"; import Faq from "@/app/(root)/sections/Faq";
import ReferralSection from "@/app/(root)/sections/ReferralSection";
const Home = () => { const Home = () => {
useScrollToSection(); useEffect(() => {
const scrollToId = localStorage.getItem('scrollToId')
if (scrollToId) {
localStorage.removeItem('scrollToId')
const el = document.getElementById(scrollToId)
if (el) {
setTimeout(() => {
el.scrollIntoView({behavior: 'smooth', block: 'start'})
}, 200)
}
}
}, [])
return ( return (
<motion.div <motion.div

View File

@@ -1,7 +1,6 @@
'use client'; 'use client';
import { motion } from 'framer-motion'; import {motion} from 'framer-motion';
import { SectionTitle } from '@/components/ui/SectionTitle';
const About = () => { const About = () => {
return ( return (
@@ -10,9 +9,23 @@ const About = () => {
className="relative w-full py-24 bg-background text-foreground transition-colors duration-700 ease-in-out"> className="relative w-full py-24 bg-background text-foreground transition-colors duration-700 ease-in-out">
<div className="w-full max-w-6xl px-6 md:px-10 mx-auto"> <div className="w-full max-w-6xl px-6 md:px-10 mx-auto">
<div className="flex flex-col"> <div className="flex flex-col">
<SectionTitle {/* Title */}
title="Über uns" <motion.h2
className="mb-6" className="text-3xl md:text-4xl font-bold mb-1 text-left"
initial={{opacity: 0, y: 10}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.4}}
>
Über uns
</motion.h2>
<motion.div
className="w-12 h-[2px] mt-2 mb-6 bg-amber-500"
initial={{opacity: 0, x: -20}}
whileInView={{opacity: 1, x: 0}}
viewport={{once: true}}
transition={{duration: 0.4, delay: 0.1}}
/> />
{/* Text */} {/* Text */}

View File

@@ -1,16 +1,74 @@
'use client'; 'use client';
import { HeroBackground } from '@/components/Hero/HeroBackground'; import {motion} from 'framer-motion';
import { HeroContent } from '@/components/Hero/HeroContent'; import Image from 'next/image';
import {Typewriter} from 'react-simple-typewriter';
import PulsatingButton from "@/components/PulsatingButton";
const Hero = () => { const Hero = () => {
return ( return (
<section id="start" className="relative w-full h-screen overflow-hidden"> <section id="start" className="relative w-full h-screen overflow-hidden">
<HeroBackground {/* Background */}
imageSrc="/images/home_hero.jpg" <div className="absolute inset-0 z-0">
imageAlt="Rhein river aerial view" <Image
/> src="/images/home_hero.jpg"
<HeroContent /> alt="Rhein river aerial view"
fill
className="object-cover scale-105 blur-sm"
priority
/>
<div className="absolute inset-0 bg-black/60"/>
</div>
{/* Content */}
<div
className="relative z-10 flex flex-col justify-center items-start h-full w-[90%] sm:w-[80%] max-w-6xl mx-auto text-white">
<motion.h1
className="text-3xl sm:text-5xl font-bold mb-6 leading-tight"
initial={{opacity: 0, y: 40}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.6}}
>
Digitale Lösungen, <br/> die wirklich passen.
</motion.h1>
<motion.p
className="text-lg sm:text-xl text-gray-300 mb-6 max-w-2xl"
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.6, delay: 0.2}}
>
Wir entwickeln individuelle Softwarelösungen für Unternehmen und Startups.
</motion.p>
<motion.div
className="text-xl font-semibold text-white"
initial={{opacity: 0}}
animate={{opacity: 1}}
transition={{delay: 0.6}}
>
<Typewriter
words={['Webdesign', 'App-Entwicklung', 'Interne Tools']}
loop={true}
cursor
cursorStyle="_"
typeSpeed={60}
deleteSpeed={40}
delaySpeed={2000}
/>
</motion.div>
<div className="mt-10 relative flex items-center justify-center">
<PulsatingButton
label="Jetzt Kontakt aufnehmen"
href="/contact"
color="#2563eb" // Tailwind blue-600
width={256}
pulse
/>
</div>
</div>
</section> </section>
); );
}; };

View File

@@ -1,35 +1,101 @@
'use client'; 'use client';
import { motion } from 'framer-motion'; import {motion} from 'framer-motion';
import {ChevronRight} from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { SectionTitle } from '@/components/ui/SectionTitle';
import { ServiceCard } from '@/components/ui/ServiceCard'; const services = [
import { servicesData } from '@/constant/ServicesData'; {
title: 'Webdesign',
description: 'Moderne Websites, die Vertrauen schaffen und verkaufen.',
bullets: [
'Maßgeschneidertes Design',
'Klare Struktur & überzeugende Inhalte',
'Nutzerführung mit System & Strategie',
'Für alle Geräte optimiert',
],
},
{
title: 'App-Entwicklung',
description: 'Skalierbare Apps für Web und Mobile von der Idee bis zum Launch.',
bullets: [
'Plattformübergreifend mit modernen Technologien',
'Backend & API-Entwicklung inklusive',
'Individuelle Funktionen & Logik',
'Stabil, performant & wartbar',
],
},
{
title: 'Interne Tools',
description: 'Digitale Werkzeuge, die Prozesse vereinfachen und Zeit sparen.',
bullets: [
'Prozessdigitalisierung & Automatisierung',
'Zugeschnitten auf eure Workflows',
'Skalierbar & zukunftssicher',
'Intuitiv & effizient bedienbar',
],
},
];
const HomeServices = () => { const HomeServices = () => {
return ( return (
<section id="services" className="w-full py-24 bg-background text-foreground"> <section id="services"
className="w-full py-24 bg-background text-foreground">
<div className="w-full max-w-6xl px-6 md:px-10 mx-auto"> <div className="w-full max-w-6xl px-6 md:px-10 mx-auto">
<SectionTitle title="Leistungen" /> <motion.h2
className="text-3xl md:text-4xl font-bold mb-1 text-left"
initial={{opacity: 0, y: 10}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.4}}
>
Leistungen
</motion.h2>
<motion.div
className="w-12 h-[2px] mt-2 mb-10 bg-amber-500"
initial={{opacity: 0, x: -20}}
whileInView={{opacity: 1, x: 0}}
viewport={{once: true}}
transition={{duration: 0.4, delay: 0.1}}
/>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{servicesData.map((service, index) => ( {services.map((service, index) => (
<ServiceCard <motion.div
key={service.title} key={service.title}
title={service.title} className="flex flex-col justify-between h-full p-6 rounded-3xl border bg-muted text-foreground"
description={service.description} initial={{opacity: 0, y: 20}}
bullets={service.bullets} whileInView={{opacity: 1, y: 0}}
index={index} viewport={{once: true}}
/> transition={{duration: 0.4, delay: index * 0.1}}
whileHover={{
scale: 1.03,
boxShadow: '0px 12px 30px rgba(0, 0, 0, 0.08)',
}}
>
<div>
<h3 className="text-xl font-semibold mb-2">{service.title}</h3>
<p className="text-muted-foreground mb-4">{service.description}</p>
<ul className="space-y-3">
{service.bullets.map((point, i) => (
<li key={i} className="flex items-start gap-2">
<ChevronRight className="w-4 h-4 text-primary mt-1"/>
<span className="text-sm text-foreground">{point}</span>
</li>
))}
</ul>
</div>
</motion.div>
))} ))}
</div> </div>
<motion.div <motion.div
className="mt-12 text-center" className="mt-12 text-center"
initial={{ opacity: 0 }} initial={{opacity: 0}}
whileInView={{ opacity: 1 }} whileInView={{opacity: 1}}
viewport={{ once: true }} viewport={{once: true}}
transition={{ duration: 0.4, delay: 0.3 }} transition={{duration: 0.4, delay: 0.3}}
> >
<p className="text-muted-foreground mb-4 text-base md:text-lg"> <p className="text-muted-foreground mb-4 text-base md:text-lg">
Du möchtest mehr über unsere Leistungen erfahren oder hast ein konkretes Projekt im Kopf? Du möchtest mehr über unsere Leistungen erfahren oder hast ein konkretes Projekt im Kopf?

View File

@@ -3,10 +3,10 @@ import {NextRequest, NextResponse} from 'next/server'
const HCAPTCHA_SECRET = process.env.HCAPTCHA_SECRET ?? '' const HCAPTCHA_SECRET = process.env.HCAPTCHA_SECRET ?? ''
const SHARED_API_KEY = process.env.SHARED_API_KEY ?? '' const SHARED_API_KEY = process.env.SHARED_API_KEY ?? ''
// Detect whether to use localhost or Docker server // Detect whether to use localhost or Docker gateway
const useLocalServerEnv = process.env.USE_LOCAL_SERVER const useLocalGatewayEnv = process.env.USE_LOCAL_GATEWAY
const useLocalServer = useLocalServerEnv?.toLowerCase() === 'true' const useLocalGateway = useLocalGatewayEnv?.toLowerCase() === 'true'
const serverHost = useLocalServer ? 'http://localhost:8080' : 'http://server:8080' const gatewayHost = useLocalGateway ? 'http://localhost:8080' : 'http://gateway:8080'
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
@@ -42,7 +42,7 @@ export async function POST(req: NextRequest) {
// } // }
// Step 2: Forward to backend service // Step 2: Forward to backend service
const backendRes = await fetch(`${serverHost}/api/contact`, { const backendRes = await fetch(`${gatewayHost}/api/contact`, {
method: 'POST', method: 'POST',
headers: { headers: {
Origin: origin, Origin: origin,

View File

@@ -2,13 +2,13 @@
import React from 'react'; import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { motion } from 'framer-motion'; import {motion} from 'framer-motion';
import { Mail, Gavel, ShieldCheck, Cookie } from 'lucide-react'; import {Mail, Gavel, ShieldCheck, Cookie} from 'lucide-react';
import { FooterSection } from './FooterSection';
import { useCookieSettings } from '@/hooks/useCookieSettings';
const Footer = () => { const Footer = () => {
const { openCookieSettings } = useCookieSettings(); const openCookieSettings = () => {
window.dispatchEvent(new Event('show-cookie-banner'));
};
return ( return (
<motion.footer <motion.footer
@@ -42,38 +42,56 @@ const Footer = () => {
</p> </p>
</motion.div> </motion.div>
<FooterSection title="Informationen" delay={0.4}> {/* Informationen */}
<li className="flex items-center gap-2"> <motion.div
<Mail className="w-4 h-4" /> initial={{opacity: 0, y: 10}}
<Link href="/contact" className="hover:underline"> whileInView={{opacity: 1, y: 0}}
Kontakt viewport={{once: true}}
</Link> transition={{duration: 0.5, delay: 0.4}}
</li> >
</FooterSection> <h3 className="text-lg font-semibold mb-4">Informationen</h3>
<ul className="space-y-3 text-sm text-gray-300">
<li className="flex items-center gap-2">
<Mail className="w-4 h-4"/>
<Link href="/contact" className="hover:underline">
Kontakt
</Link>
</li>
</ul>
</motion.div>
<FooterSection title="Rechtliches" delay={0.5}> {/* Rechtliches */}
<li className="flex items-center gap-2"> <motion.div
<ShieldCheck className="w-4 h-4" /> initial={{opacity: 0, y: 10}}
<Link href="/legal/privacy" className="hover:underline"> whileInView={{opacity: 1, y: 0}}
Datenschutz viewport={{once: true}}
</Link> transition={{duration: 0.5, delay: 0.5}}
</li> >
<li className="flex items-center gap-2"> <h3 className="text-lg font-semibold mb-4">Rechtliches</h3>
<Gavel className="w-4 h-4" /> <ul className="space-y-3 text-sm text-gray-300">
<Link href="/legal/imprint" className="hover:underline"> <li className="flex items-center gap-2">
Impressum <ShieldCheck className="w-4 h-4"/>
</Link> <Link href="/legal/privacy" className="hover:underline">
</li> Datenschutz
<li className="flex items-center gap-2"> </Link>
<Cookie className="w-4 h-4" /> </li>
<button <li className="flex items-center gap-2">
onClick={openCookieSettings} <Gavel className="w-4 h-4"/>
className="hover:underline text-left" <Link href="/legal/imprint" className="hover:underline">
> Impressum
Cookie-Einstellungen </Link>
</button> </li>
</li> <li className="flex items-center gap-2">
</FooterSection> <Cookie className="w-4 h-4"/>
<button
onClick={openCookieSettings}
className="hover:underline text-left"
>
Cookie-Einstellungen
</button>
</li>
</ul>
</motion.div>
</div> </div>
<motion.div <motion.div

View File

@@ -1,26 +0,0 @@
'use client';
import { motion } from 'framer-motion';
import { ReactNode } from 'react';
interface FooterSectionProps {
title: string;
children: ReactNode;
delay: number;
}
export const FooterSection = ({ title, children, delay }: FooterSectionProps) => {
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay }}
>
<h3 className="text-lg font-semibold mb-4">{title}</h3>
<ul className="space-y-3 text-sm text-gray-300">
{children}
</ul>
</motion.div>
);
};

View File

@@ -1,23 +0,0 @@
'use client';
import Image from 'next/image';
interface HeroBackgroundProps {
imageSrc: string;
imageAlt: string;
}
export const HeroBackground = ({ imageSrc, imageAlt }: HeroBackgroundProps) => {
return (
<div className="absolute inset-0 z-0">
<Image
src={imageSrc}
alt={imageAlt}
fill
className="object-cover scale-105 blur-sm"
priority
/>
<div className="absolute inset-0 bg-black/60" />
</div>
);
};

View File

@@ -1,58 +0,0 @@
'use client';
import { motion } from 'framer-motion';
import { Typewriter } from 'react-simple-typewriter';
import PulsatingButton from '@/components/PulsatingButton';
const typewriterWords = ['Webdesign', 'App-Entwicklung', 'Interne Tools'];
export const HeroContent = () => {
return (
<div className="relative z-10 flex flex-col justify-center items-start h-full w-[90%] sm:w-[80%] max-w-6xl mx-auto text-white">
<motion.h1
className="text-3xl sm:text-5xl font-bold mb-6 leading-tight"
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
Digitale Lösungen, <br /> die wirklich passen.
</motion.h1>
<motion.p
className="text-lg sm:text-xl text-gray-300 mb-6 max-w-2xl"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
Wir entwickeln individuelle Softwarelösungen für Unternehmen und Startups.
</motion.p>
<motion.div
className="text-xl font-semibold text-white"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.6 }}
>
<Typewriter
words={typewriterWords}
loop={true}
cursor
cursorStyle="_"
typeSpeed={60}
deleteSpeed={40}
delaySpeed={2000}
/>
</motion.div>
<div className="mt-10 relative flex items-center justify-center">
<PulsatingButton
label="Jetzt Kontakt aufnehmen"
href="/contact"
color="#2563eb"
width={256}
pulse
/>
</div>
</div>
);
};

View File

@@ -1,32 +0,0 @@
'use client';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { ThemeToggle } from '@/components/theme-toggle';
import { navLinks } from '@/constant/NavigationData';
interface DesktopNavProps {
onNavClick: (id: string) => void;
}
export const DesktopNav = ({ onNavClick }: DesktopNavProps) => {
return (
<nav className="hidden lg:flex items-center gap-6">
{navLinks.map((link) => (
<button
key={link.id}
onClick={() => onNavClick(link.id)}
className="cursor-pointer text-sm font-medium text-muted-foreground hover:text-primary transition-colors"
>
{link.label}
</button>
))}
<Button asChild>
<Link href="/contact">Kontakt</Link>
</Button>
<ThemeToggle />
</nav>
);
};

View File

@@ -1,43 +0,0 @@
'use client';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { Menu } from 'lucide-react';
import { ThemeToggle } from '@/components/theme-toggle';
import { navLinks } from '@/constant/NavigationData';
interface MobileNavProps {
onNavClick: (id: string) => void;
}
export const MobileNav = ({ onNavClick }: MobileNavProps) => {
return (
<div className="lg:hidden flex items-center gap-3">
<ThemeToggle />
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="icon">
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="top" className="pt-10">
<div className="flex flex-col space-y-4 text-center">
{navLinks.map((link) => (
<button
key={link.id}
onClick={() => onNavClick(link.id)}
className="cursor-pointer text-base font-semibold text-muted-foreground hover:text-primary transition-colors"
>
{link.label}
</button>
))}
<Button asChild className="mt-4 w-full">
<Link href="/contact">Kontakt</Link>
</Button>
</div>
</SheetContent>
</Sheet>
</div>
);
};

View File

@@ -1,16 +0,0 @@
'use client';
interface NavLogoProps {
onLogoClick: () => void;
}
export const NavLogo = ({ onLogoClick }: NavLogoProps) => {
return (
<button
onClick={onLogoClick}
className="text-xl font-bold cursor-pointer"
>
<span className="text-pink-600">R</span>hein Software
</button>
);
};

View File

@@ -1,21 +1,98 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { useScrollNavigation } from '@/hooks/useScrollNavigation'; import Link from 'next/link';
import { NavLogo } from './NavLogo'; import {usePathname, useRouter} from 'next/navigation';
import { DesktopNav } from './DesktopNav'; import {Button} from '@/components/ui/button';
import { MobileNav } from './MobileNav'; import {Sheet, SheetContent, SheetTrigger} from '@/components/ui/sheet';
import {Menu} from 'lucide-react';
import {ThemeToggle} from '@/components/theme-toggle';
const navLinks = [
{id: 'services', label: 'Leistungen'},
{id: 'about', label: 'Über uns'},
{id: 'process', label: 'Ablauf'},
{id: 'whyus', label: 'Warum wir'},
{id: 'referral', label: 'Empfehlung'},
{id: 'faq', label: 'FAQ'},
];
const Navbar = () => { const Navbar = () => {
const { handleNavClick } = useScrollNavigation(); const pathname = usePathname();
const router = useRouter();
const handleNavClick = (id: string) => {
if (typeof window === 'undefined') return
if (pathname === '/') {
const el = document.getElementById(id)
if (el) {
el.scrollIntoView({behavior: 'smooth', block: 'start'})
}
} else {
localStorage.setItem('scrollToId', id)
router.push('/')
}
}
return ( return (
<div className="w-full px-4 sm:px-6 lg:px-8 flex justify-center mt-4 z-50 fixed"> <div className="w-full px-4 sm:px-6 lg:px-8 flex justify-center mt-4 z-50 fixed">
<header className="bg-background/50 backdrop-blur-md border shadow-lg rounded-xl w-full max-w-screen-xl py-3 px-4 sm:px-6 lg:px-8"> <header
className="bg-background/50 backdrop-blur-md border shadow-lg rounded-xl w-full max-w-screen-xl py-3 px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<NavLogo onLogoClick={() => handleNavClick('start')} /> <button
<DesktopNav onNavClick={handleNavClick} /> onClick={() => handleNavClick('start')}
<MobileNav onNavClick={handleNavClick} /> className="text-xl font-bold cursor-pointer"
>
<span className="text-pink-600">R</span>hein Software
</button>
{/* Desktop nav */}
<nav className="hidden lg:flex items-center gap-6">
{navLinks.map((link) => (
<button
key={link.id}
onClick={() => handleNavClick(link.id)}
className="cursor-pointer text-sm font-medium text-muted-foreground hover:text-primary transition-colors"
>
{link.label}
</button>
))}
<Button asChild>
<Link href="/contact">Kontakt</Link>
</Button>
<ThemeToggle/>
</nav>
{/* Mobile nav */}
<div className="lg:hidden flex items-center gap-3">
<ThemeToggle/>
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="icon">
<Menu className="h-5 w-5"/>
</Button>
</SheetTrigger>
<SheetContent side="top" className="pt-10">
<div className="flex flex-col space-y-4 text-center">
{navLinks.map((link) => (
<button
key={link.id}
onClick={() => handleNavClick(link.id)}
className="cursor-pointer text-base font-semibold text-muted-foreground hover:text-primary transition-colors"
>
{link.label}
</button>
))}
<Button asChild className="mt-4 w-full">
<Link href="/contact">Kontakt</Link>
</Button>
</div>
</SheetContent>
</Sheet>
</div>
</div> </div>
</header> </header>
</div> </div>

View File

@@ -1,55 +0,0 @@
'use client';
import { motion } from 'framer-motion';
interface SectionTitleProps {
title: string;
subtitle?: string;
className?: string;
showUnderline?: boolean;
underlineColor?: string;
}
export const SectionTitle = ({
title,
subtitle,
className = "",
showUnderline = true,
underlineColor = "bg-amber-500"
}: SectionTitleProps) => {
return (
<div className={className}>
<motion.h2
className="text-3xl md:text-4xl font-bold mb-1 text-left"
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4 }}
>
{title}
</motion.h2>
{showUnderline && (
<motion.div
className={`w-12 h-[2px] mt-2 mb-10 ${underlineColor}`}
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: 0.1 }}
/>
)}
{subtitle && (
<motion.p
className="text-lg text-muted-foreground mt-4"
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: 0.2 }}
>
{subtitle}
</motion.p>
)}
</div>
);
};

View File

@@ -1,43 +0,0 @@
'use client';
import { motion } from 'framer-motion';
import { ChevronRight } from 'lucide-react';
import { ReactNode } from 'react';
interface ServiceCardProps {
title: string;
description: string;
bullets: string[];
index: number;
children?: ReactNode;
}
export const ServiceCard = ({ title, description, bullets, index, children }: ServiceCardProps) => {
return (
<motion.div
className="flex flex-col justify-between h-full p-6 rounded-3xl border bg-muted text-foreground"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: index * 0.1 }}
whileHover={{
scale: 1.03,
boxShadow: '0px 12px 30px rgba(0, 0, 0, 0.08)',
}}
>
<div>
<h3 className="text-xl font-semibold mb-2">{title}</h3>
<p className="text-muted-foreground mb-4">{description}</p>
<ul className="space-y-3">
{bullets.map((point, i) => (
<li key={i} className="flex items-start gap-2">
<ChevronRight className="w-4 h-4 text-primary mt-1" />
<span className="text-sm text-foreground">{point}</span>
</li>
))}
</ul>
{children && <div className="mt-4">{children}</div>}
</div>
</motion.div>
);
};

View File

@@ -1,13 +0,0 @@
export interface NavLink {
id: string;
label: string;
}
export const navLinks: NavLink[] = [
{ id: 'services', label: 'Leistungen' },
{ id: 'about', label: 'Über uns' },
{ id: 'process', label: 'Ablauf' },
{ id: 'whyus', label: 'Warum wir' },
{ id: 'referral', label: 'Empfehlung' },
{ id: 'faq', label: 'FAQ' },
];

View File

@@ -1,38 +0,0 @@
export interface ServiceData {
title: string;
description: string;
bullets: string[];
}
export const servicesData: ServiceData[] = [
{
title: 'Webdesign',
description: 'Moderne Websites, die Vertrauen schaffen und verkaufen.',
bullets: [
'Maßgeschneidertes Design',
'Klare Struktur & überzeugende Inhalte',
'Nutzerführung mit System & Strategie',
'Für alle Geräte optimiert',
],
},
{
title: 'App-Entwicklung',
description: 'Skalierbare Apps für Web und Mobile von der Idee bis zum Launch.',
bullets: [
'Plattformübergreifend mit modernen Technologien',
'Backend & API-Entwicklung inklusive',
'Individuelle Funktionen & Logik',
'Stabil, performant & wartbar',
],
},
{
title: 'Interne Tools',
description: 'Digitale Werkzeuge, die Prozesse vereinfachen und Zeit sparen.',
bullets: [
'Prozessdigitalisierung & Automatisierung',
'Zugeschnitten auf eure Workflows',
'Skalierbar & zukunftssicher',
'Intuitiv & effizient bedienbar',
],
},
];

View File

@@ -1,11 +0,0 @@
'use client';
import { useCallback } from 'react';
export const useCookieSettings = () => {
const openCookieSettings = useCallback(() => {
window.dispatchEvent(new Event('show-cookie-banner'));
}, []);
return { openCookieSettings };
};

View File

@@ -1,32 +0,0 @@
'use client';
import { usePathname, useRouter } from 'next/navigation';
import { useCallback } from 'react';
export const useScrollNavigation = () => {
const pathname = usePathname();
const router = useRouter();
const handleNavClick = useCallback((id: string) => {
if (typeof window === 'undefined') return;
if (pathname === '/') {
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
} else {
localStorage.setItem('scrollToId', id);
router.push('/');
}
}, [pathname, router]);
const scrollToSection = useCallback((id: string) => {
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, []);
return { handleNavClick, scrollToSection };
};

View File

@@ -1,18 +0,0 @@
'use client';
import { useEffect } from 'react';
export const useScrollToSection = () => {
useEffect(() => {
const scrollToId = localStorage.getItem('scrollToId');
if (scrollToId) {
localStorage.removeItem('scrollToId');
const el = document.getElementById(scrollToId);
if (el) {
setTimeout(() => {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 200);
}
}
}, []);
};

View File

@@ -23,7 +23,6 @@ build_internal_frontend:
echo "KEYCLOAK_CLIENT_SECRET=$KEYCLOAK_CLIENT_SECRET_TEST" >> .env echo "KEYCLOAK_CLIENT_SECRET=$KEYCLOAK_CLIENT_SECRET_TEST" >> .env
fi fi
echo "KEYCLOAK_ISSUER=$KEYCLOAK_ISSUER" >> .env echo "KEYCLOAK_ISSUER=$KEYCLOAK_ISSUER" >> .env
echo "INTERNAL_BACKEND_URL=$INTERNAL_BACKEND_URL" >> .env
echo "Contents of .env file:" echo "Contents of .env file:"
cat .env cat .env
npm install npm install

View File

@@ -1,5 +1,5 @@
import NextAuth from "next-auth"; import NextAuth from "next-auth";
import {authOptions} from "@/lib/api/auth/authOptions"; import {authOptions} from "@/lib/auth/authOptions";
const handler = NextAuth(authOptions); const handler = NextAuth(authOptions);
export {handler as GET, handler as POST}; export {handler as GET, handler as POST};

View File

@@ -1,15 +0,0 @@
import {NextRequest, NextResponse} from "next/server";
import {serverCall} from "@/lib/api/serverCall";
import {customerRoutes} from "@/app/api/customers/customerRoutes";
export async function GET(request: NextRequest) {
const id = request.url.split('/').pop();
const response = await serverCall(customerRoutes.getById(id!), "GET");
if (!response.ok) {
return NextResponse.json({error: "Customer not found"}, {status: 404});
}
const customer = await response.json();
return NextResponse.json(customer);
}

View File

@@ -1,5 +0,0 @@
export const customerRoutes = {
create: "/api/customers",
validate: "/api/customers/validate",
getById: (id: string) => `/api/customers/${id}`,
};

View File

@@ -1,15 +0,0 @@
import {NextRequest, NextResponse} from "next/server";
import {serverCall} from "@/lib/api/serverCall";
import {customerRoutes} from "@/app/api/customers/customerRoutes";
export async function GET() {
const data = await serverCall(customerRoutes.create, "GET");
const customers = await data.json();
return NextResponse.json(customers);
}
export async function POST(req: NextRequest) {
const body = await req.json()
const result = await serverCall(customerRoutes.create, "POST", body);
return NextResponse.json(result.json());
}

View File

@@ -1,10 +0,0 @@
import {NextRequest, NextResponse} from "next/server";
import {serverCall} from "@/lib/api/serverCall";
import {customerRoutes} from "@/app/api/customers/customerRoutes";
export async function POST(req: NextRequest) {
const body = await req.json();
const result = await serverCall(customerRoutes.validate, "POST", body);
const data = await result.json();
return NextResponse.json(data);
}

View File

@@ -1,37 +0,0 @@
import {NextRequest, NextResponse} from "next/server";
import {serverCall} from "@/lib/api/serverCall";
import {projectRoutes} from "@/app/api/projects/projectRoutes";
export async function GET(request: NextRequest) {
try {
// Extract project ID from the URL
const segments = request.url.split("/");
const projectId = segments.pop();
if (!projectId) {
return NextResponse.json(
{error: "Project ID is required"},
{status: 400}
);
}
// Perform server call to fetch the project details
const response = await serverCall(projectRoutes.getById(projectId), "GET");
if (!response.ok) {
return NextResponse.json(
{error: "Project not found"},
{status: response.status}
);
}
const project = await response.json();
return NextResponse.json(project);
} catch (error) {
console.error("Error fetching project:", error);
return NextResponse.json(
{error: "Failed to fetch project"},
{status: 500}
);
}
}

View File

@@ -1,16 +0,0 @@
import {NextRequest, NextResponse} from "next/server";
import {serverCall} from "@/lib/api/serverCall";
import {projectRoutes} from "@/app/api/projects/projectRoutes";
export async function GET(request: NextRequest) {
const segments = request.url.split('/');
const id = segments[segments.indexOf('customer') + 1];
const response = await serverCall(projectRoutes.getProjectByCustomerId(id), "GET");
if (!response.ok) {
return NextResponse.json({error: "Customer not found"}, {status: 404});
}
const customer = await response.json();
return NextResponse.json(customer);
}

View File

@@ -1,6 +0,0 @@
export const projectRoutes = {
'create': '/api/projects',
getById: (id: string) => `/api/projects/${id}`,
getProjectByCustomerId: (customerId: string) => `/api/projects/customer/${customerId}`
}
;

View File

@@ -1,21 +0,0 @@
import {NextRequest, NextResponse} from "next/server";
import {serverCall} from "@/lib/api/serverCall";
import {projectRoutes} from "@/app/api/projects/projectRoutes";
export async function POST(req: NextRequest) {
try {
// Parse the incoming JSON payload
const body = await req.json();
// Make a POST request to the backend using serverCall
const response = await serverCall(projectRoutes.create, "POST", body);
// Parse and return the backend response
const result = await response.json();
return NextResponse.json(result);
} catch (error) {
// Handle errors gracefully
console.error("Error creating project:", error);
return NextResponse.json({error: "Failed to create project"}, {status: 500});
}
}

Some files were not shown because too many files have changed in this diff Show More