Add customer management
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user