Compare commits

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
167 changed files with 9726 additions and 757 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": []
}
}

View File

@@ -137,11 +137,13 @@
sed -i "s|registry.boomlab.party/rheinsw/rheinsw-mono-repo/internal_frontend|registry.boomlab.party/rheinsw/rheinsw-mono-repo/internal_frontend@$(cat digest-internal_frontend.txt)|g" docker-compose.generated.yml sed -i "s|registry.boomlab.party/rheinsw/rheinsw-mono-repo/internal_frontend|registry.boomlab.party/rheinsw/rheinsw-mono-repo/internal_frontend@$(cat digest-internal_frontend.txt)|g" docker-compose.generated.yml
echo "Copying docker-compose.generated.yml to $HOST:$REMOTE_ENV_PATH/docker-compose.yml" echo "Copying docker-compose.generated.yml to $HOST:$REMOTE_ENV_PATH/docker-compose.yml"
# Ensure remote path exists before scp # Ensure remote path exists before scp
ssh -p "$PORT" "$DEPLOY_USER@$HOST" "mkdir -p $REMOTE_ENV_PATH" ssh -p "$PORT" "$DEPLOY_USER@$HOST" "mkdir -p $REMOTE_ENV_PATH"
# Copy # Copy
scp -P "$PORT" docker-compose.generated.yml "$DEPLOY_USER@$HOST:$REMOTE_ENV_PATH/docker-compose.yml" scp -P "$PORT" docker-compose.generated.yml "$DEPLOY_USER@$HOST:$REMOTE_ENV_PATH/docker-compose.yml"
scp -P "$PORT" internal_frontend/.env "$DEPLOY_USER@$HOST:$REMOTE_ENV_PATH/internal_frontend.env"
echo "Deploying on $HOST" echo "Deploying on $HOST"
ssh -p "$PORT" "$DEPLOY_USER@$HOST" " ssh -p "$PORT" "$DEPLOY_USER@$HOST" "

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 {
@@ -18,4 +16,5 @@ public class RestTemplateConfig {
return new RestTemplate(); return new RestTemplate();
} }
} }

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}
@@ -42,4 +49,4 @@ hcaptcha:
logging: logging:
level: level:
org.hibernate.SQL: ${LOG_SQL_LEVEL} org.hibernate.SQL: ${LOG_SQL_LEVEL}
org.hibernate.type.descriptor.sql.BasicBinder: ${LOG_BINDER_LEVEL} org.hibernate.type.descriptor.sql.BasicBinder: ${LOG_BINDER_LEVEL}

View File

@@ -1,31 +1,51 @@
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,
name VARCHAR(100), name VARCHAR(100),
email VARCHAR(100), email VARCHAR(100),
message VARCHAR(1000), message VARCHAR(1000),
company VARCHAR(100), company VARCHAR(100),
phone VARCHAR(20), phone VARCHAR(20),
website VARCHAR(100), website VARCHAR(100),
captcha_token VARCHAR(1024), captcha_token VARCHAR(1024),
submitted_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
@@ -29,6 +22,8 @@ services:
internal_frontend: internal_frontend:
image: registry.boomlab.party/rheinsw/rheinsw-mono-repo/internal_frontend image: registry.boomlab.party/rheinsw/rheinsw-mono-repo/internal_frontend
container_name: internal_frontend container_name: internal_frontend
env_file:
- ./internal_frontend.env
ports: ports:
- "5101:3000" - "5101:3000"
restart: on-failure restart: on-failure

View File

@@ -10,11 +10,6 @@ build_frontend:
script: script:
- | - |
cd frontend cd frontend
echo "NEXT_PUBLIC_HCAPTCHA_SITE_KEY=$HCAPTCHA_SITE_KEY" > .env
echo "NEXT_PUBLIC_RECAPTCHA_SITE_KEY=$RECAPTCHA_SITE_KEY" >> .env
echo "HCAPTCHA_SECRET=$HCAPTCHA_SECRET" >> .env
echo "Contents of .env file:"
cat .env
npm install npm install
npx next build npx next build
artifacts: artifacts:

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" <HeroContent />
fill
className="object-cover scale-105 blur-sm"
priority
/>
<div className="absolute inset-0 bg-black/60"/>
</div>
{/* Content */}
<div
className="relative z-10 flex flex-col justify-center items-start h-full w-[90%] sm:w-[80%] max-w-6xl mx-auto text-white">
<motion.h1
className="text-3xl sm:text-5xl font-bold mb-6 leading-tight"
initial={{opacity: 0, y: 40}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.6}}
>
Digitale Lösungen, <br/> die wirklich passen.
</motion.h1>
<motion.p
className="text-lg sm:text-xl text-gray-300 mb-6 max-w-2xl"
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.6, delay: 0.2}}
>
Wir entwickeln individuelle Softwarelösungen für Unternehmen und Startups.
</motion.p>
<motion.div
className="text-xl font-semibold text-white"
initial={{opacity: 0}}
animate={{opacity: 1}}
transition={{delay: 0.6}}
>
<Typewriter
words={['Webdesign', 'App-Entwicklung', 'Interne Tools']}
loop={true}
cursor
cursorStyle="_"
typeSpeed={60}
deleteSpeed={40}
delaySpeed={2000}
/>
</motion.div>
<div className="mt-10 relative flex items-center justify-center">
<PulsatingButton
label="Jetzt Kontakt aufnehmen"
href="/contact"
color="#2563eb" // Tailwind blue-600
width={256}
pulse
/>
</div>
</div>
</section> </section>
); );
}; };

View File

@@ -1,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,56 +42,38 @@ const Footer = () => {
</p> </p>
</motion.div> </motion.div>
{/* Informationen */} <FooterSection title="Informationen" delay={0.4}>
<motion.div <li className="flex items-center gap-2">
initial={{opacity: 0, y: 10}} <Mail className="w-4 h-4" />
whileInView={{opacity: 1, y: 0}} <Link href="/contact" className="hover:underline">
viewport={{once: true}} Kontakt
transition={{duration: 0.5, delay: 0.4}} </Link>
> </li>
<h3 className="text-lg font-semibold mb-4">Informationen</h3> </FooterSection>
<ul className="space-y-3 text-sm text-gray-300">
<li className="flex items-center gap-2">
<Mail className="w-4 h-4"/>
<Link href="/contact" className="hover:underline">
Kontakt
</Link>
</li>
</ul>
</motion.div>
{/* Rechtliches */} <FooterSection title="Rechtliches" delay={0.5}>
<motion.div <li className="flex items-center gap-2">
initial={{opacity: 0, y: 10}} <ShieldCheck className="w-4 h-4" />
whileInView={{opacity: 1, y: 0}} <Link href="/legal/privacy" className="hover:underline">
viewport={{once: true}} Datenschutz
transition={{duration: 0.5, delay: 0.5}} </Link>
> </li>
<h3 className="text-lg font-semibold mb-4">Rechtliches</h3> <li className="flex items-center gap-2">
<ul className="space-y-3 text-sm text-gray-300"> <Gavel className="w-4 h-4" />
<li className="flex items-center gap-2"> <Link href="/legal/imprint" className="hover:underline">
<ShieldCheck className="w-4 h-4"/> Impressum
<Link href="/legal/privacy" className="hover:underline"> </Link>
Datenschutz </li>
</Link> <li className="flex items-center gap-2">
</li> <Cookie className="w-4 h-4" />
<li className="flex items-center gap-2"> <button
<Gavel className="w-4 h-4"/> onClick={openCookieSettings}
<Link href="/legal/imprint" className="hover:underline"> className="hover:underline text-left"
Impressum >
</Link> Cookie-Einstellungen
</li> </button>
<li className="flex items-center gap-2"> </li>
<Cookie className="w-4 h-4"/> </FooterSection>
<button
onClick={openCookieSettings}
className="hover:underline text-left"
>
Cookie-Einstellungen
</button>
</li>
</ul>
</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

@@ -10,10 +10,27 @@ build_internal_frontend:
script: script:
- | - |
cd internal_frontend cd internal_frontend
echo "# environment file for internal_frontend" > .env
if [ "$CI_COMMIT_REF_NAME" = "production" ]; then
echo "NEXTAUTH_URL=$NEXTAUTH_URL_PROD" >> .env
echo "NEXTAUTH_SECRET=$NEXTAUTH_SECRET_PROD" >> .env
echo "KEYCLOAK_CLIENT_ID=$KEYCLOAK_CLIENT_ID_PROD" >> .env
echo "KEYCLOAK_CLIENT_SECRET=$KEYCLOAK_CLIENT_SECRET_PROD" >> .env
else
echo "NEXTAUTH_URL=$NEXTAUTH_URL_TEST" >> .env
echo "NEXTAUTH_SECRET=$NEXTAUTH_SECRET_TEST" >> .env
echo "KEYCLOAK_CLIENT_ID=$KEYCLOAK_CLIENT_ID_TEST" >> .env
echo "KEYCLOAK_CLIENT_SECRET=$KEYCLOAK_CLIENT_SECRET_TEST" >> .env
fi
echo "KEYCLOAK_ISSUER=$KEYCLOAK_ISSUER" >> .env
echo "INTERNAL_BACKEND_URL=$INTERNAL_BACKEND_URL" >> .env
echo "Contents of .env file:"
cat .env
npm install npm install
npx next build npx next build
artifacts: artifacts:
paths: paths:
- internal_frontend/.env
- internal_frontend/.next - internal_frontend/.next
- internal_frontend/public - internal_frontend/public
- internal_frontend/package.json - internal_frontend/package.json

View File

@@ -0,0 +1,5 @@
import NextAuth from "next-auth";
import {authOptions} from "@/lib/api/auth/authOptions";
const handler = NextAuth(authOptions);
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);
}

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