Add customer management

This commit is contained in:
2025-07-06 08:31:48 +00:00
parent 2bd76aa6bb
commit 916dbfcf95
57 changed files with 2442 additions and 161 deletions

View File

@@ -75,6 +75,14 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</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 -->
<!-- https://mvnrepository.com/artifact/org.projectlombok/lombok -->
@@ -96,6 +104,17 @@
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</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>
<groupId>dev.rheinsw</groupId>

View File

@@ -0,0 +1,39 @@
package dev.rheinsw.server.common.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.common.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,15 @@
package dev.rheinsw.server.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,47 @@
package dev.rheinsw.server.common.controller.exception.handler;
import dev.rheinsw.server.common.controller.exception.ApiException;
import dev.rheinsw.server.common.usecase.exception.UseCaseException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.util.List;
/**
* @author Thatsaphorn Atchariyaphap
* @since 06.07.25
*/
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ApiException.class)
public ResponseEntity<ApiErrorResponse> handleBusinessException(ApiException ex) {
return ResponseEntity.badRequest().body(
new ApiErrorResponse(Instant.now(), ex.getMessage(), List.of(ex.getMessage()))
);
}
@ExceptionHandler(UseCaseException.class)
public ResponseEntity<ApiErrorResponse> handleUseCaseException(UseCaseException ex) {
return ResponseEntity.badRequest().body(
new ApiErrorResponse(Instant.now(), ex.getMessage(), List.of(ex.getMessage()))
);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiErrorResponse> handleGeneric(Exception ex) {
ex.printStackTrace(); // log the stack trace
return ResponseEntity.internalServerError().body(
new ApiErrorResponse(Instant.now(), "Ein unerwarteter Fehler ist aufgetreten", List.of(ex.getMessage()))
);
}
public record ApiErrorResponse(
Instant timestamp,
String message,
List<String> errors
) {
}
}

View File

@@ -0,0 +1,48 @@
package dev.rheinsw.server.common.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,68 @@
package dev.rheinsw.server.common.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,13 @@
package dev.rheinsw.server.common.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

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

View File

@@ -0,0 +1,79 @@
package dev.rheinsw.server.customer.controller;
import dev.rheinsw.server.common.controller.AbstractController;
import dev.rheinsw.server.customer.dtos.CreateCustomerDto;
import dev.rheinsw.server.customer.dtos.CustomerValidationRequest;
import dev.rheinsw.server.customer.model.Customer;
import dev.rheinsw.server.customer.repository.CustomerRepository;
import dev.rheinsw.server.customer.usecase.LoadCustomerQuery;
import dev.rheinsw.server.customer.usecase.RegisterCustomerUseCase;
import lombok.RequiredArgsConstructor;
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.Collections;
import java.util.List;
import java.util.UUID;
/**
* @author Thatsaphorn Atchariyaphap
* @since 02.07.25
*/
@RestController
@RequestMapping("/customers")
@RequiredArgsConstructor
public class CustomerController extends AbstractController {
private final CustomerRepository repository;
private final RegisterCustomerUseCase registerCustomerUseCase;
private final LoadCustomerQuery loadCustomerQuery;
@PostMapping
public ResponseEntity<UUID> register(@RequestBody CreateCustomerDto request) {
var currentUser = getUserFromCurrentSession();
var result = registerCustomerUseCase.register(
currentUser,
request.email(),
request.name(),
request.companyName(),
request.phoneNumbers(),
request.street(),
request.zip(),
request.city(),
request.notes()
);
return ResponseEntity.ok(result);
}
@GetMapping("/{id}")
public ResponseEntity<Customer> loadById(@PathVariable UUID id) {
return ResponseEntity.ok(loadCustomerQuery.loadById(id));
}
@GetMapping
public ResponseEntity<List<Customer>> findAll() {
var result = loadCustomerQuery.findAll();
return ResponseEntity.ok(result);
}
@PostMapping("/validate")
public ResponseEntity<List<Customer>> validateCustomer(@RequestBody CustomerValidationRequest request) {
List<Customer> matches = repository.findPotentialDuplicates(
request.email(),
request.companyName(),
request.street(),
request.zip(),
request.city()
);
return ResponseEntity.ok(matches);
}
}

View File

@@ -0,0 +1,22 @@
package dev.rheinsw.server.customer.dtos;
import dev.rheinsw.server.customer.model.records.CustomerNote;
import dev.rheinsw.server.customer.model.records.CustomerPhoneNumber;
import java.util.List;
/**
* @author Thatsaphorn Atchariyaphap
* @since 06.07.25
*/
public record CreateCustomerDto(
String email,
String name,
String companyName,
List<CustomerPhoneNumber> phoneNumbers,
String street,
String zip,
String city,
List<CustomerNote> notes
) {
}

View File

@@ -0,0 +1,14 @@
package dev.rheinsw.server.customer.dtos;
/**
* @author Thatsaphorn Atchariyaphap
* @since 06.07.25
*/
public record CustomerValidationRequest(
String email,
String companyName,
String street,
String zip,
String city
) {
}

View File

@@ -0,0 +1,41 @@
package dev.rheinsw.server.customer.model;
import com.vladmihalcea.hibernate.type.json.JsonType;
import dev.rheinsw.server.common.entity.BaseEntity;
import dev.rheinsw.server.customer.model.records.CustomerNote;
import dev.rheinsw.server.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.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.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.customer.repository;
import dev.rheinsw.server.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,78 @@
package dev.rheinsw.server.customer.usecase;
import dev.rheinsw.server.common.controller.exception.ApiException;
import dev.rheinsw.server.common.usecase.exception.UseCaseException;
import dev.rheinsw.server.customer.model.Customer;
import dev.rheinsw.server.customer.model.records.CustomerNote;
import dev.rheinsw.server.customer.model.records.CustomerPhoneNumber;
import dev.rheinsw.server.customer.repository.CustomerRepository;
import dev.rheinsw.server.common.entity.User;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
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.customer.usecase;
import dev.rheinsw.server.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.customer.usecase;
import dev.rheinsw.server.customer.model.records.CustomerNote;
import dev.rheinsw.server.customer.model.records.CustomerPhoneNumber;
import dev.rheinsw.server.common.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.demo.model;
import dev.rheinsw.server.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.demo.model;
import dev.rheinsw.server.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.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

@@ -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,28 @@
package dev.rheinsw.server.security.session;
import dev.rheinsw.server.security.session.model.CurrentSession;
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
*/
@Component
public class CurrentSessionProvider {
public CurrentSession getCurrentSession() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (!(auth.getPrincipal() instanceof Jwt jwt)) {
throw new IllegalStateException("JWT is missing or invalid");
}
return new CurrentSession(
jwt.getClaimAsString("sub"),
jwt.getClaimAsString("preferred_username"),
jwt.getClaimAsString("email")
);
}
}

View File

@@ -0,0 +1,40 @@
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 org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* @author Thatsaphorn Atchariyaphap
* @since 04.07.25
*/
@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 {
try {
CurrentSession session = currentSessionProvider.getCurrentSession();
userService.getUserBySession(session);
} catch (Exception e) {
// You might want to log this but not block the request
}
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,38 @@
package dev.rheinsw.server.security.user;
import dev.rheinsw.server.common.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 org.springframework.stereotype.Service;
import java.time.Instant;
/**
* @author Thatsaphorn Atchariyaphap
* @since 04.07.25
*/
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
@Transactional
public User getUserBySession(CurrentSession session) {
return userRepository.findByKeycloakId(session.keycloakId())
.orElseGet(() -> createUser(session));
}
private User createUser(CurrentSession session) {
User newUser = User.builder()
.keycloakId(session.keycloakId())
.username(session.username())
.email(session.email())
.createdAt(Instant.now())
.build();
return userRepository.save(newUser);
}
}

View File

@@ -0,0 +1,14 @@
package dev.rheinsw.server.security.user.repository;
import dev.rheinsw.server.common.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

@@ -4,6 +4,13 @@ server:
spring:
application:
name: server
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://sso.rhein-software.dev/realms/rhein-sw
datasource:
url: jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}
username: ${DB_USERNAME}

View File

@@ -1,31 +1,98 @@
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
constraint pk_api_key
primary key,
key varchar(255) not null
constraint uc_api_key_key
unique,
type varchar(255) not null,
enabled boolean not null,
frontend_only boolean not null,
description text,
created_date date default CURRENT_DATE,
created_time time default CURRENT_TIME,
modified_date date,
modified_time time
id BIGSERIAL PRIMARY KEY,
keycloak_id VARCHAR(255) NOT NULL UNIQUE,
username VARCHAR(255) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL,
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
);
-- 1. CONTACT REQUESTS
CREATE TABLE contact_requests
(
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100),
email VARCHAR(100),
message VARCHAR(1000),
company VARCHAR(100),
phone VARCHAR(20),
website VARCHAR(100),
captcha_token VARCHAR(1024),
submitted_date DATE,
submitted_time TIME
);
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100),
email VARCHAR(100),
message VARCHAR(1000),
company VARCHAR(100),
phone VARCHAR(20),
website VARCHAR(100),
captcha_token VARCHAR(1024),
submitted_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- 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);
-- 3. DEMO
CREATE TABLE demo
(
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customer (id) ON DELETE CASCADE,
name TEXT NOT NULL,
demo_url TEXT NOT NULL,
container_name TEXT NOT NULL,
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_demo_customer_id ON demo (customer_id);
-- 4. DEMO ACCESS
CREATE TABLE demo_access
(
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customer (id) ON DELETE CASCADE,
demo_id UUID NOT NULL REFERENCES demo (id) ON DELETE CASCADE,
code_hash TEXT NOT NULL,
code_expires_at TIMESTAMPTZ NOT NULL,
used BOOLEAN DEFAULT FALSE,
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_demo_access_demo_id ON demo_access (demo_id);
-- 5. DEMO ACCESS HISTORY
CREATE TABLE demo_access_history
(
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
demo_access_id UUID NOT NULL REFERENCES demo_access (id) ON DELETE CASCADE,
ip_address TEXT NOT NULL,
accessed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
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
);