53 Commits

Author SHA1 Message Date
d9ff535ac0 Improve project structure.
New Project Structure:
  - Created reusable UI components (ServiceCard, AnimatedSection, SectionTitle)
  - Split large components into smaller, focused ones
  - Extracted shared hooks for common functionality
  - Organized constants into separate files

Key Improvements:
  - Hooks: useScrollNavigation, useScrollToSection, useCookieSettings
  - UI Components: Modular components for consistent styling and behavior
  - Constants: Centralized data management (ServicesData, NavigationData)
  - Component Split: Navbar, Hero, and Footer broken into logical sub-components
2025-08-08 19:38:12 +02:00
a5d59cbd64 Update run config 2025-07-23 08:21:53 +02:00
0759f23b22 Backend Refactoring
1. Enhanced User Session Management & Logging

  CurrentSessionProvider (backend/server/src/main/java/dev/rheinsw/server/security/session/CurrentSessionProvider.java):
  - Added comprehensive null safety checks for JWT authentication
  - Implemented detailed logging for user session retrieval
  - Added validation for all required JWT claims (sub, preferred_username, email)
  - Enhanced error messages with specific validation failures

  UserSessionFilter (backend/server/src/main/java/dev/rheinsw/server/security/session/UserSessionFilter.java):
  - Replaced silent exception handling with proper logging
  - Added request context logging (method, URI)
  - Categorized different exception types for better debugging
  - Enhanced error visibility while maintaining non-blocking behavior

  UserService (backend/server/src/main/java/dev/rheinsw/server/security/user/UserService.java):
  - Added comprehensive null safety validations
  - Implemented detailed logging for user creation and lookup operations
  - Enhanced exception handling with proper error context
  - Added input validation for session data

  2. Improved Controller Logging & Validation

  CustomerController (backend/server/src/main/java/dev/rheinsw/server/internal/customer/controller/CustomerController.java):
  - Added comprehensive logging for all user actions
  - Implemented input validation with @Valid annotations
  - Enhanced error handling with user context
  - Added null checks for path parameters

  ProjectController (backend/server/src/main/java/dev/rheinsw/server/internal/project/controller/ProjectController.java):
  - Similar logging and validation improvements
  - Added comprehensive user action tracking
  - Enhanced error handling with proper validation

  3. Enhanced DTO Validation

  CreateCustomerDto (backend/server/src/main/java/dev/rheinsw/server/internal/customer/dtos/CreateCustomerDto.java):
  - Added Bean Validation annotations (@NotBlank, @Email, @Size)
  - Implemented comprehensive field validation
  - Added proper error messages in German

  CustomerValidationRequest & CreateCustomerProjectDto: Similar validation enhancements

  4. Improved Exception Handling

  GlobalExceptionHandler (backend/common/src/main/java/dev/rheinsw/common/controller/exception/handler/GlobalExceptionHandler.java):
  - Added correlation IDs for better error tracking
  - Replaced unsafe error message exposure with secure error responses
  - Enhanced logging with proper log levels and context
  - Added specific handlers for validation errors and illegal arguments
  - Implemented structured error responses with correlation tracking

  ProjectUseCaseImpl (backend/server/src/main/java/dev/rheinsw/server/internal/project/usecase/ProjectUseCaseImpl.java):
  - Fixed null return issue (now throws exceptions instead)
  - Added comprehensive input validation
  - Enhanced error handling with proper exception types
  - Added detailed logging for all operations

  5. Test Coverage & Quality

  Added comprehensive unit tests:
  - CurrentSessionProviderTest: 8 test cases covering all authentication scenarios
  - UserServiceTest: 7 test cases covering user creation and validation
  - ProjectUseCaseImplTest: 14 test cases covering project operations
  - Added test dependencies (spring-boot-starter-test, spring-security-test)

  6. Frontend Compatibility

  Updated frontend error handling:
  - Enhanced validateCustomer.ts and addCustomer.ts to log correlation IDs
  - Maintained backward compatibility with existing error handling
  - Added debugging support for new correlation ID feature

  7. Build & Deployment

  -  Backend: Builds successfully with all tests passing
  -  Frontend: Both frontend projects build successfully
  -  Dependencies: Added necessary test dependencies
  -  Validation: Bean Validation is properly configured and working

  🔒 Security & Reliability Improvements

  1. Authentication Security: Robust JWT validation with proper error handling
  2. Input Validation: Comprehensive validation across all DTOs
  3. Error Handling: Secure error responses that don't expose internal details
  4. Null Safety: Extensive null checks throughout the codebase
  5. Logging Security: No sensitive data logged, proper correlation IDs for debugging

  📈 Monitoring & Debugging

  1. Correlation IDs: Every error response includes a unique correlation ID
  2. Structured Logging: Consistent logging patterns with user context
  3. Request Tracing: User actions are logged with proper context
  4. Error Classification: Different error types handled appropriately
2025-07-23 00:18:26 +02:00
432ae7e507 Reintroduce RestTemplateConfig 2025-07-23 00:14:56 +02:00
7355b67d62 Prompt template 2025-07-22 23:50:43 +02:00
5c5ed854e4 Code Cleanup 2025-07-22 23:42:18 +02:00
7d901c4273 Update dependencies and remove gateway
* Remove Gateway module, configuration, and references
* Update backend services to directly handle requests under `/api` prefix
* Adjust frontend contact route to connect directly to the server
* Centralize authentication logic and integrate token refresh mechanism
2025-07-22 23:32:57 +02:00
c0b3669c30 Remove Gateway service and configuration
- Delete Gateway module, associated Spring Boot application, and related configuration (`GatewayApplication.java`, `application.yml`, and `pom.xml`).
- Remove Gateway references in `docker-compose.yml`, `.gitlab-ci.yml`, and `backend/pom.xml`.
- Update backend services to directly handle requests under `/api` prefix (e.g., `/api/customers`, `/api/contact`).
- Adjust frontend contact route to connect directly to the server, replacing gateway references with server URLs.
2025-07-15 20:46:43 +02:00
c69786d14b Merge branch 'customer-project' into 'dev'
Add project management support and integrate customer-project functionality

See merge request rheinsw/rheinsw-mono-repo!19
2025-07-15 18:23:53 +00:00
03f633ae52 Add project management support and integrate customer-project functionality 2025-07-15 18:23:53 +00:00
2707aa48bc Remove unused showInfoToast import and align CustomersPage with useCallback dependencies 2025-07-11 23:50:03 +02:00
bdbaf36456 Centralize authentication logic and integrate token refresh mechanism
- Introduce `AuthWrapper` component for streamlined session-based layouts and authentication handling.
- Add new utilities (`tokenUtils.ts`) for JWT decoding, token expiration checks, and refresh operations via Keycloak.
- Refactor `serverCall` and `authOptions` to use centralized token refresh logic, removing redundant implementations.
- Implement `ClientSessionProvider` for consistent session management across the client application.
- Simplify `RootLayout` by delegating authentication enforcement to `AuthWrapper`.
2025-07-11 23:42:41 +02:00
6aae06635d Reset form state on dialog close in NewCustomerModal
- Add `resetForm` utility to restore initial modal state.
- Automatically reset form state when dialog closes or a new customer is created to improve UX.
2025-07-11 20:36:51 +02:00
14d089073b Make NewCustomerModal props readonly to ensure immutability 2025-07-11 20:31:36 +02:00
b62ee3e9ad Add token expiration check and refresh mechanism in serverCall
- Decode and validate JWT payload to detect expired or near-expiring tokens.
- Implement `refreshAccessToken` using Keycloak endpoints for seamless token refresh.
- Modify `serverCall` to refresh and update token dynamically before API requests.
- Improve error logging for token decoding and refresh operations.
2025-07-11 20:30:12 +02:00
52c6358a0c Remove unused showInfoToast during customer data fetch 2025-07-11 20:22:20 +02:00
0724f3b1e7 Remove callApi, refactor API integrations, and adjust error handling
- Delete unused `callApi` utility and related imports across components.
- Replace `callApi` with direct `fetch` usage in `validateCustomer` and `addCustomer`.
- Update `customerRoutes` to include `/api` prefix for consistency.
- Refactor `useErrorHandler` to ensure comprehensive state management during errors.
- Improve `ErrorBoundary` component text for better clarity in fallback UI.
- Align `CustomersPage` logic with `useCallback` for optimized dependency management.
2025-07-11 20:21:45 +02:00
86be1e8920 Enhance NewCustomerModal with callback support and toast notifications
- Add `onCustomerCreated` callback to refresh customer list after creation.
- Integrate `showInfoToast` and `showSuccessToast` for validation and creation feedback.
- Prevent closing modal on backdrop click; add explicit cancel button.
- Refactor `addCustomer` to use `callApi` and centralized routes.
- Simplify customer fetching logic in `CustomersPage` with reusable function.
2025-07-11 19:53:52 +02:00
644d907b45 Reset state on dialog close in ErrorDialog component 2025-07-11 19:33:27 +02:00
c99e09056c Refactor error handling utilities
- Replace `showError` with a more versatile `showToast` utility.
- Add specific toast functions (`showErrorToast`, `showSuccessToast`, etc.) for better message handling.
- Remove `showError.ts` and migrate logic to `showToast.ts` for cleaner structure.
- Update `ErrorBoundary` and `useErrorHandler` to use the new `showToast` functions.
2025-07-11 19:15:08 +02:00
18014ce9f0 Introduce ErrorBoundary component for improved error handling
- Add `ErrorBoundary` component to handle rendering fallback UI during errors.
- Integrate `ErrorBoundary` into main layout and critical pages for global error handling.
- Create `useErrorHandler` hook to handle async errors consistently across components.
- Refactor `NewCustomerModal` and `CustomersPage` to leverage `useErrorHandler` for improved error management.
- Add a fallback dialog UI for user-friendly error reporting and resolution.
2025-07-11 19:06:07 +02:00
2a95efb75f Remove CustomerRepository and replace with direct API calls
- Remove `CustomerRepository` and its methods for customer management and caching.
- Refactor customer-related pages (`[id]/page.tsx`, `customers/page.tsx`) to use direct `fetch` API calls.
- Update breadcrumb resolver to fetch data directly from the API.
- Simplify `addCustomer` use case to avoid repository dependency.
2025-07-11 18:38:44 +02:00
328c0537ba Introduce caching in CustomerRepository and refactor API integration
- Add in-memory caching for customer data in `CustomerRepository` to reduce API calls.
- Replace direct API calls with methods from `CustomerRepository`.
- Update customer-related pages (`[id]/page.tsx`, `customers/page.tsx`) to use `CustomerRepository` for data fetching.
- Adjust breadcrumb resolver to leverage `CustomerRepository`.
- Remove `axios` dependency from customer-related components.
2025-07-07 22:02:55 +02:00
4ae62f2911 Handle empty input validation in validateCustomer use case 2025-07-07 22:02:38 +02:00
e42b352216 Refactor navigation structure and API routes
- Centralize user menu, sidebar items, and breadcrumb logic.
- Map consistent API endpoints in `customerRoutes`.
- Replace inline route definitions with reusable constants.
- Refactor auth configuration file location.
- Improve `<Link>` usage to replace static `<a>` elements.
- Adjust sidebar and dropdown components to use dynamic navigation configurations.
2025-07-07 19:49:58 +02:00
7ba92dc66c Remove demo-related tables and indices from initial schema migration 2025-07-06 21:20:32 +02:00
7b39ab8cd8 Add INTERNAL_BACKEND_URL to .env generation in CI pipeline 2025-07-06 20:24:37 +02:00
4b08e5e58c Fix base URL formatting in serverCall for API requests 2025-07-06 19:59:50 +02:00
54793437a1 Handle empty customers array in filtering logic 2025-07-06 19:52:48 +02:00
1ff2b0e8be Merge branch 'customer-details-view' into 'dev'
Customer Detail Page and Enhance dynamic breadcrumbs

See merge request rheinsw/rheinsw-mono-repo!18
2025-07-06 17:24:12 +00:00
e00142ff81 Customer Detail Page and Enhance dynamic breadcrumbs 2025-07-06 17:24:12 +00:00
055d19d201 Merge branch 'customer-handling' into 'dev'
Add customer management

See merge request rheinsw/rheinsw-mono-repo!17
2025-07-06 08:31:48 +00:00
916dbfcf95 Add customer management 2025-07-06 08:31:48 +00:00
2bd76aa6bb Fix .env handling in CI pipeline 2025-07-02 11:18:11 +09:00
cd3165dbe6 Enhance CI pipeline to include NEXTAUTH_URL for production and test environments. 2025-07-02 11:12:12 +09:00
fb3b5b6880 Cleanup 2025-07-02 11:09:20 +09:00
7cac66d018 Add env_file support for internal_frontend in docker-compose.yml 2025-07-02 11:02:09 +09:00
63985d538a Improve .env handling and deployment; ensure .env is included as an artifact and update deployment scripts to copy it. 2025-07-02 11:01:29 +09:00
3f3ea936dc Log .env contents during CI pipeline 2025-07-02 10:46:39 +09:00
c16ba0d09a Add environment variable handling for CI builds in internal_frontend 2025-07-02 10:41:04 +09:00
9837259c41 Update authOptions import path 2025-07-02 10:22:08 +09:00
ae425e4e28 Refactor authOptions into a separate module to improve structure and reusability. 2025-07-02 10:14:21 +09:00
b1d7eb906f Remove unused jsonwebtoken and jwt-decode dependencies 2025-07-02 10:04:09 +09:00
2f71dca04d Integrate NextAuth with Keycloak and implement JWT validation in internal_frontend. 2025-07-02 09:55:25 +09:00
da3bd7e181 Merge remote-tracking branch 'origin/dev' into dev 2025-07-02 01:45:04 +09:00
0b2f8332a2 Add dynamic breadcrumb navigation and update kanzlei routes under /demo 2025-07-02 01:25:55 +09:00
b33b470e7b Add foundational UI components (sidebar, some basic navigation) 2025-07-01 18:44:15 +09:00
20314c64b2 Add theme integration and shadcn setup in internal_frontend 2025-07-01 17:37:30 +09:00
GitLab CI
bf387fe14c Merge remote-tracking branch 'origin/production' into dev 2025-07-01 08:31:40 +00:00
e06e6f8669 Add Dockerfile for internal_frontend production deployment 2025-07-01 17:24:21 +09:00
b9ed439cba Add internal_frontend module with CI pipeline and Docker configuration 2025-07-01 17:22:33 +09:00
f121a0ef80 Initialize internal_frontend module with Next.js, 2025-07-01 17:18:13 +09:00
9b7cf5553d Rename npm-dev run configuration to landing_page in project settings. 2025-07-01 17:17:57 +09:00
143 changed files with 6395 additions and 842 deletions

View File

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

3
.prompt Normal file
View File

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

View File

@@ -1,21 +0,0 @@
<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,9 +7,8 @@
</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,7 +7,6 @@ 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
@@ -21,18 +20,6 @@ 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

@@ -0,0 +1,15 @@
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

@@ -0,0 +1,87 @@
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,4 +1,4 @@
package dev.rheinsw.shared.transport; package dev.rheinsw.common.dtos;
import java.io.Serializable; import java.io.Serializable;

View File

@@ -1,14 +1,12 @@
package dev.rheinsw.shared.rest; package dev.rheinsw.common.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.04.25 * @since 23.07.25
*/ */
@Configuration @Configuration
public class RestTemplateConfig { public class RestTemplateConfig {
@@ -19,3 +17,4 @@ public class RestTemplateConfig {
} }
} }

View File

@@ -0,0 +1,15 @@
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

@@ -1,38 +0,0 @@
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,58 +0,0 @@
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

@@ -1,30 +0,0 @@
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");
}
}

View File

@@ -1,88 +0,0 @@
<?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

@@ -1,15 +0,0 @@
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

@@ -1,18 +0,0 @@
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,7 +11,6 @@
<modules> <modules>
<module>common</module> <module>common</module>
<module>gateway</module>
<module>server</module> <module>server</module>
</modules> </modules>

View File

@@ -75,6 +75,14 @@
<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 -->
@@ -96,6 +104,17 @@
<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>
@@ -103,6 +122,23 @@
<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.contact.controller; package dev.rheinsw.server.internal.contact.controller;
import dev.rheinsw.server.contact.model.ContactRequestDto; import dev.rheinsw.server.internal.contact.model.ContactRequestDto;
import dev.rheinsw.server.contact.usecase.SubmitContactUseCase; import dev.rheinsw.server.internal.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("/contact") @RequestMapping("/api/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.contact.model; package dev.rheinsw.server.internal.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.contact.model; package dev.rheinsw.server.internal.contact.model;
import dev.rheinsw.shared.transport.Dto; import dev.rheinsw.common.dtos.Dto;
/** /**
* @param company optional * @param company optional

View File

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

View File

@@ -1,6 +1,6 @@
package dev.rheinsw.server.contact.repository; package dev.rheinsw.server.internal.contact.repository;
import dev.rheinsw.server.contact.model.ContactRequest; import dev.rheinsw.server.internal.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.contact.usecase; package dev.rheinsw.server.internal.contact.usecase;
import dev.rheinsw.server.contact.model.ContactRequestDto; import dev.rheinsw.server.internal.contact.model.ContactRequestDto;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
/** /**

View File

@@ -1,11 +1,11 @@
package dev.rheinsw.server.contact.usecase; package dev.rheinsw.server.internal.contact.usecase;
import dev.rheinsw.server.contact.model.ContactRequest; import dev.rheinsw.server.internal.contact.model.ContactRequest;
import dev.rheinsw.server.contact.model.ContactRequestDto; import dev.rheinsw.server.internal.contact.model.ContactRequestDto;
import dev.rheinsw.server.contact.repository.ContactRequestsRepo; import dev.rheinsw.server.internal.contact.repository.ContactRequestsRepo;
import dev.rheinsw.server.contact.util.HCaptchaValidator; import dev.rheinsw.server.internal.contact.util.HCaptchaValidator;
import dev.rheinsw.server.mail.domain.MailRequest; import dev.rheinsw.server.internal.mail.domain.MailRequest;
import dev.rheinsw.server.mail.usecase.SendMailUseCase; import dev.rheinsw.server.internal.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.contact.util; package dev.rheinsw.server.internal.contact.util;
import dev.rheinsw.server.contact.model.HCaptchaConfig; import dev.rheinsw.server.internal.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

@@ -0,0 +1,112 @@
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

@@ -0,0 +1,46 @@
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

@@ -0,0 +1,26 @@
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

@@ -0,0 +1,41 @@
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

@@ -0,0 +1,16 @@
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

@@ -0,0 +1,17 @@
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

@@ -0,0 +1,37 @@
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

@@ -0,0 +1,76 @@
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

@@ -0,0 +1,15 @@
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

@@ -0,0 +1,26 @@
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

@@ -0,0 +1,47 @@
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

@@ -0,0 +1,51 @@
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

@@ -0,0 +1,43 @@
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,7 +1,7 @@
package dev.rheinsw.server.mail.controller; package dev.rheinsw.server.internal.mail.controller;
import dev.rheinsw.server.mail.usecase.SendMailUseCase; import dev.rheinsw.server.internal.mail.usecase.SendMailUseCase;
import dev.rheinsw.server.mail.domain.MailRequest; import dev.rheinsw.server.internal.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("/mail") @RequestMapping("/api/mail")
@RequiredArgsConstructor @RequiredArgsConstructor
public class MailController { public class MailController {

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
package dev.rheinsw.server.mail.usecase; package dev.rheinsw.server.internal.mail.usecase;
import dev.rheinsw.server.mail.domain.MailRequest; import dev.rheinsw.server.internal.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

@@ -0,0 +1,101 @@
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

@@ -0,0 +1,43 @@
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

@@ -0,0 +1,50 @@
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

@@ -0,0 +1,13 @@
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

@@ -0,0 +1,14 @@
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

@@ -0,0 +1,15 @@
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

@@ -0,0 +1,23 @@
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

@@ -0,0 +1,16 @@
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

@@ -0,0 +1,120 @@
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

@@ -0,0 +1,50 @@
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

@@ -0,0 +1,35 @@
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

@@ -0,0 +1,62 @@
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

@@ -0,0 +1,57 @@
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

@@ -0,0 +1,10 @@
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

@@ -0,0 +1,70 @@
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

@@ -0,0 +1,68 @@
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

@@ -0,0 +1,14 @@
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

@@ -0,0 +1,39 @@
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

@@ -0,0 +1,48 @@
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

@@ -0,0 +1,13 @@
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,9 +1,16 @@
server: server:
port: 8081 port: 8080
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}

View File

@@ -1,21 +1,22 @@
create table api_key -- Enable UUID extension
CREATE
EXTENSION IF NOT EXISTS "uuid-ossp";
-- 0. USERS
CREATE TABLE users
( (
id bigint generated by default as identity id BIGSERIAL PRIMARY KEY,
constraint pk_api_key keycloak_id VARCHAR(255) NOT NULL UNIQUE,
primary key, username VARCHAR(255) NOT NULL UNIQUE,
key varchar(255) not null email VARCHAR(255) NOT NULL,
constraint uc_api_key_key created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
unique, updated_at TIMESTAMPTZ,
type varchar(255) not null, created_by BIGINT REFERENCES users (id),
enabled boolean not null, updated_by BIGINT REFERENCES users (id),
frontend_only boolean not null, version BIGINT
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,
@@ -26,6 +27,25 @@ CREATE TABLE contact_requests
phone VARCHAR(20), phone VARCHAR(20),
website VARCHAR(100), website VARCHAR(100),
captcha_token VARCHAR(1024), captcha_token VARCHAR(1024),
submitted_date DATE, submitted_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
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

@@ -0,0 +1,22 @@
-- 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

@@ -0,0 +1,224 @@
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

@@ -0,0 +1,151 @@
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

@@ -0,0 +1,139 @@
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,18 +1,11 @@
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,28 +1,18 @@
'use client'; 'use client';
import React, {useEffect} from "react"; import React from "react";
import HomeServices from "@/app/(root)/sections/HomeServices"; import { motion } from "framer-motion";
import {motion} from "framer-motion"; import { useScrollToSection } from "@/hooks/useScrollToSection";
import Hero from "@/app/(root)/sections/Hero"; import Hero from "@/app/(root)/sections/Hero";
import HomeServices from "@/app/(root)/sections/HomeServices";
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 Faq from "@/app/(root)/sections/Faq";
import ReferralSection from "@/app/(root)/sections/ReferralSection"; import ReferralSection from "@/app/(root)/sections/ReferralSection";
import Faq from "@/app/(root)/sections/Faq";
const Home = () => { const Home = () => {
useEffect(() => { useScrollToSection();
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,6 +1,7 @@
'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 (
@@ -9,23 +10,9 @@ 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">
{/* Title */} <SectionTitle
<motion.h2 title="Über uns"
className="text-3xl md:text-4xl font-bold mb-1 text-left" className="mb-6"
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,74 +1,16 @@
'use client'; 'use client';
import {motion} from 'framer-motion'; import { HeroBackground } from '@/components/Hero/HeroBackground';
import Image from 'next/image'; import { HeroContent } from '@/components/Hero/HeroContent';
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">
{/* Background */} <HeroBackground
<div className="absolute inset-0 z-0"> imageSrc="/images/home_hero.jpg"
<Image imageAlt="Rhein river aerial view"
src="/images/home_hero.jpg"
alt="Rhein river aerial view"
fill
className="object-cover scale-105 blur-sm"
priority
/> />
<div className="absolute inset-0 bg-black/60"/> <HeroContent />
</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,101 +1,35 @@
'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';
const services = [ import { ServiceCard } from '@/components/ui/ServiceCard';
{ 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" <section id="services" className="w-full py-24 bg-background text-foreground">
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">
<motion.h2 <SectionTitle title="Leistungen" />
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">
{services.map((service, index) => ( {servicesData.map((service, index) => (
<motion.div <ServiceCard
key={service.title} key={service.title}
className="flex flex-col justify-between h-full p-6 rounded-3xl border bg-muted text-foreground" title={service.title}
initial={{opacity: 0, y: 20}} description={service.description}
whileInView={{opacity: 1, y: 0}} bullets={service.bullets}
viewport={{once: true}} index={index}
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 gateway // Detect whether to use localhost or Docker server
const useLocalGatewayEnv = process.env.USE_LOCAL_GATEWAY const useLocalServerEnv = process.env.USE_LOCAL_SERVER
const useLocalGateway = useLocalGatewayEnv?.toLowerCase() === 'true' const useLocalServer = useLocalServerEnv?.toLowerCase() === 'true'
const gatewayHost = useLocalGateway ? 'http://localhost:8080' : 'http://gateway:8080' const serverHost = useLocalServer ? 'http://localhost:8080' : 'http://server: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(`${gatewayHost}/api/contact`, { const backendRes = await fetch(`${serverHost}/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 = () => { const { openCookieSettings } = useCookieSettings();
window.dispatchEvent(new Event('show-cookie-banner'));
};
return ( return (
<motion.footer <motion.footer
@@ -42,47 +42,30 @@ const Footer = () => {
</p> </p>
</motion.div> </motion.div>
{/* Informationen */} <FooterSection title="Informationen" delay={0.4}>
<motion.div
initial={{opacity: 0, y: 10}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.4}}
>
<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"> <li className="flex items-center gap-2">
<Mail className="w-4 h-4"/> <Mail className="w-4 h-4" />
<Link href="/contact" className="hover:underline"> <Link href="/contact" className="hover:underline">
Kontakt Kontakt
</Link> </Link>
</li> </li>
</ul> </FooterSection>
</motion.div>
{/* Rechtliches */} <FooterSection title="Rechtliches" delay={0.5}>
<motion.div
initial={{opacity: 0, y: 10}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.5}}
>
<h3 className="text-lg font-semibold mb-4">Rechtliches</h3>
<ul className="space-y-3 text-sm text-gray-300">
<li className="flex items-center gap-2"> <li className="flex items-center gap-2">
<ShieldCheck className="w-4 h-4"/> <ShieldCheck className="w-4 h-4" />
<Link href="/legal/privacy" className="hover:underline"> <Link href="/legal/privacy" className="hover:underline">
Datenschutz Datenschutz
</Link> </Link>
</li> </li>
<li className="flex items-center gap-2"> <li className="flex items-center gap-2">
<Gavel className="w-4 h-4"/> <Gavel className="w-4 h-4" />
<Link href="/legal/imprint" className="hover:underline"> <Link href="/legal/imprint" className="hover:underline">
Impressum Impressum
</Link> </Link>
</li> </li>
<li className="flex items-center gap-2"> <li className="flex items-center gap-2">
<Cookie className="w-4 h-4"/> <Cookie className="w-4 h-4" />
<button <button
onClick={openCookieSettings} onClick={openCookieSettings}
className="hover:underline text-left" className="hover:underline text-left"
@@ -90,8 +73,7 @@ const Footer = () => {
Cookie-Einstellungen Cookie-Einstellungen
</button> </button>
</li> </li>
</ul> </FooterSection>
</motion.div>
</div> </div>
<motion.div <motion.div

View File

@@ -0,0 +1,26 @@
'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

@@ -0,0 +1,23 @@
'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

@@ -0,0 +1,58 @@
'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

@@ -0,0 +1,32 @@
'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

@@ -0,0 +1,43 @@
'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

@@ -0,0 +1,16 @@
'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,98 +1,21 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import Link from 'next/link'; import { useScrollNavigation } from '@/hooks/useScrollNavigation';
import {usePathname, useRouter} from 'next/navigation'; import { NavLogo } from './NavLogo';
import {Button} from '@/components/ui/button'; import { DesktopNav } from './DesktopNav';
import {Sheet, SheetContent, SheetTrigger} from '@/components/ui/sheet'; import { MobileNav } from './MobileNav';
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 pathname = usePathname(); const { handleNavClick } = useScrollNavigation();
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 <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">
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">
<button <NavLogo onLogoClick={() => handleNavClick('start')} />
onClick={() => handleNavClick('start')} <DesktopNav onNavClick={handleNavClick} />
className="text-xl font-bold cursor-pointer" <MobileNav onNavClick={handleNavClick} />
>
<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

@@ -0,0 +1,55 @@
'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

@@ -0,0 +1,43 @@
'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

@@ -0,0 +1,13 @@
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

@@ -0,0 +1,38 @@
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

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

View File

@@ -0,0 +1,32 @@
'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

@@ -0,0 +1,18 @@
'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,6 +23,7 @@ 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/auth/authOptions"; import {authOptions} from "@/lib/api/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

@@ -0,0 +1,15 @@
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

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

View File

@@ -0,0 +1,15 @@
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

@@ -0,0 +1,10 @@
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

@@ -0,0 +1,37 @@
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

@@ -0,0 +1,16 @@
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

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

View File

@@ -0,0 +1,21 @@
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