diff --git a/backend/common/src/main/java/dev/rheinsw/shared/entity/BaseEntity.java b/backend/common/src/main/java/dev/rheinsw/shared/entity/BaseEntity.java deleted file mode 100644 index 4e676b0..0000000 --- a/backend/common/src/main/java/dev/rheinsw/shared/entity/BaseEntity.java +++ /dev/null @@ -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(); - } -} \ No newline at end of file diff --git a/backend/common/src/test/java/dev/rheinsw/shared/entity/BaseEntityTest.java b/backend/common/src/test/java/dev/rheinsw/shared/entity/BaseEntityTest.java deleted file mode 100644 index 1b80fe4..0000000 --- a/backend/common/src/test/java/dev/rheinsw/shared/entity/BaseEntityTest.java +++ /dev/null @@ -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"); - } -} diff --git a/backend/gateway/src/main/resources/application.yml b/backend/gateway/src/main/resources/application.yml index 99d908a..eb79d03 100644 --- a/backend/gateway/src/main/resources/application.yml +++ b/backend/gateway/src/main/resources/application.yml @@ -15,4 +15,5 @@ spring: predicates: - Path=/api/** filters: - - StripPrefix=1 \ No newline at end of file + - StripPrefix=1 + - PreserveHostHeader \ No newline at end of file diff --git a/backend/server/pom.xml b/backend/server/pom.xml index 3f34737..171eefb 100644 --- a/backend/server/pom.xml +++ b/backend/server/pom.xml @@ -75,6 +75,14 @@ org.springframework.boot spring-boot-starter-data-jpa + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + org.springframework.boot + spring-boot-starter-security + @@ -96,6 +104,17 @@ org.flywaydb flyway-database-postgresql + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + com.vladmihalcea + hibernate-types-60 + 2.21.1 + + dev.rheinsw diff --git a/backend/server/src/main/java/dev/rheinsw/server/common/controller/AbstractController.java b/backend/server/src/main/java/dev/rheinsw/server/common/controller/AbstractController.java new file mode 100644 index 0000000..2e96982 --- /dev/null +++ b/backend/server/src/main/java/dev/rheinsw/server/common/controller/AbstractController.java @@ -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()); + } + + +} diff --git a/backend/server/src/main/java/dev/rheinsw/server/common/controller/exception/ApiException.java b/backend/server/src/main/java/dev/rheinsw/server/common/controller/exception/ApiException.java new file mode 100644 index 0000000..3b7ae9b --- /dev/null +++ b/backend/server/src/main/java/dev/rheinsw/server/common/controller/exception/ApiException.java @@ -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); + } +} diff --git a/backend/server/src/main/java/dev/rheinsw/server/common/controller/exception/handler/GlobalExceptionHandler.java b/backend/server/src/main/java/dev/rheinsw/server/common/controller/exception/handler/GlobalExceptionHandler.java new file mode 100644 index 0000000..3c5ac03 --- /dev/null +++ b/backend/server/src/main/java/dev/rheinsw/server/common/controller/exception/handler/GlobalExceptionHandler.java @@ -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 handleBusinessException(ApiException ex) { + return ResponseEntity.badRequest().body( + new ApiErrorResponse(Instant.now(), ex.getMessage(), List.of(ex.getMessage())) + ); + } + + @ExceptionHandler(UseCaseException.class) + public ResponseEntity handleUseCaseException(UseCaseException ex) { + return ResponseEntity.badRequest().body( + new ApiErrorResponse(Instant.now(), ex.getMessage(), List.of(ex.getMessage())) + ); + } + + + @ExceptionHandler(Exception.class) + public ResponseEntity 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 errors + ) { + } +} diff --git a/backend/server/src/main/java/dev/rheinsw/server/common/entity/BaseEntity.java b/backend/server/src/main/java/dev/rheinsw/server/common/entity/BaseEntity.java new file mode 100644 index 0000000..ba42a9f --- /dev/null +++ b/backend/server/src/main/java/dev/rheinsw/server/common/entity/BaseEntity.java @@ -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; +} diff --git a/backend/server/src/main/java/dev/rheinsw/server/common/entity/User.java b/backend/server/src/main/java/dev/rheinsw/server/common/entity/User.java new file mode 100644 index 0000000..9e8aa90 --- /dev/null +++ b/backend/server/src/main/java/dev/rheinsw/server/common/entity/User.java @@ -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; +} diff --git a/backend/server/src/main/java/dev/rheinsw/server/common/entity/config/JpaAuditingConfig.java b/backend/server/src/main/java/dev/rheinsw/server/common/entity/config/JpaAuditingConfig.java new file mode 100644 index 0000000..0cecdc8 --- /dev/null +++ b/backend/server/src/main/java/dev/rheinsw/server/common/entity/config/JpaAuditingConfig.java @@ -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 { +} diff --git a/backend/server/src/main/java/dev/rheinsw/server/common/usecase/exception/UseCaseException.java b/backend/server/src/main/java/dev/rheinsw/server/common/usecase/exception/UseCaseException.java new file mode 100644 index 0000000..cdcc9b2 --- /dev/null +++ b/backend/server/src/main/java/dev/rheinsw/server/common/usecase/exception/UseCaseException.java @@ -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); + } +} diff --git a/backend/server/src/main/java/dev/rheinsw/server/customer/controller/CustomerController.java b/backend/server/src/main/java/dev/rheinsw/server/customer/controller/CustomerController.java new file mode 100644 index 0000000..e5cd9aa --- /dev/null +++ b/backend/server/src/main/java/dev/rheinsw/server/customer/controller/CustomerController.java @@ -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 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 loadById(@PathVariable UUID id) { + return ResponseEntity.ok(loadCustomerQuery.loadById(id)); + } + + @GetMapping + public ResponseEntity> findAll() { + var result = loadCustomerQuery.findAll(); + return ResponseEntity.ok(result); + } + + @PostMapping("/validate") + public ResponseEntity> validateCustomer(@RequestBody CustomerValidationRequest request) { + List matches = repository.findPotentialDuplicates( + request.email(), + request.companyName(), + request.street(), + request.zip(), + request.city() + ); + + return ResponseEntity.ok(matches); + } + +} diff --git a/backend/server/src/main/java/dev/rheinsw/server/customer/dtos/CreateCustomerDto.java b/backend/server/src/main/java/dev/rheinsw/server/customer/dtos/CreateCustomerDto.java new file mode 100644 index 0000000..8e12d30 --- /dev/null +++ b/backend/server/src/main/java/dev/rheinsw/server/customer/dtos/CreateCustomerDto.java @@ -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 phoneNumbers, + String street, + String zip, + String city, + List notes +) { +} diff --git a/backend/server/src/main/java/dev/rheinsw/server/customer/dtos/CustomerValidationRequest.java b/backend/server/src/main/java/dev/rheinsw/server/customer/dtos/CustomerValidationRequest.java new file mode 100644 index 0000000..f81f2f2 --- /dev/null +++ b/backend/server/src/main/java/dev/rheinsw/server/customer/dtos/CustomerValidationRequest.java @@ -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 +) { +} \ No newline at end of file diff --git a/backend/server/src/main/java/dev/rheinsw/server/customer/model/Customer.java b/backend/server/src/main/java/dev/rheinsw/server/customer/model/Customer.java new file mode 100644 index 0000000..c67e66c --- /dev/null +++ b/backend/server/src/main/java/dev/rheinsw/server/customer/model/Customer.java @@ -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 phoneNumbers; + + private String street; + private String zip; + private String city; + + @Column(name = "notes", columnDefinition = "jsonb") + @Type(JsonType.class) + private List notes; +} diff --git a/backend/server/src/main/java/dev/rheinsw/server/customer/model/records/CustomerNote.java b/backend/server/src/main/java/dev/rheinsw/server/customer/model/records/CustomerNote.java new file mode 100644 index 0000000..8c8a2ac --- /dev/null +++ b/backend/server/src/main/java/dev/rheinsw/server/customer/model/records/CustomerNote.java @@ -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 +) { +} \ No newline at end of file diff --git a/backend/server/src/main/java/dev/rheinsw/server/customer/model/records/CustomerPhoneNumber.java b/backend/server/src/main/java/dev/rheinsw/server/customer/model/records/CustomerPhoneNumber.java new file mode 100644 index 0000000..1d0863c --- /dev/null +++ b/backend/server/src/main/java/dev/rheinsw/server/customer/model/records/CustomerPhoneNumber.java @@ -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 +) { +} diff --git a/backend/server/src/main/java/dev/rheinsw/server/customer/repository/CustomerRepository.java b/backend/server/src/main/java/dev/rheinsw/server/customer/repository/CustomerRepository.java new file mode 100644 index 0000000..f493224 --- /dev/null +++ b/backend/server/src/main/java/dev/rheinsw/server/customer/repository/CustomerRepository.java @@ -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 { + Optional 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 findPotentialDuplicates( + @Param("email") String email, + @Param("companyName") String companyName, + @Param("street") String street, + @Param("zip") String zip, + @Param("city") String city + ); + +} \ No newline at end of file diff --git a/backend/server/src/main/java/dev/rheinsw/server/customer/usecase/CustomerUseCaseImpl.java b/backend/server/src/main/java/dev/rheinsw/server/customer/usecase/CustomerUseCaseImpl.java new file mode 100644 index 0000000..4ada165 --- /dev/null +++ b/backend/server/src/main/java/dev/rheinsw/server/customer/usecase/CustomerUseCaseImpl.java @@ -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 phoneNumbers, + String street, + String zip, + String city, + List 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 findAll() { + return repository.findAll(); + } +} diff --git a/backend/server/src/main/java/dev/rheinsw/server/customer/usecase/LoadCustomerQuery.java b/backend/server/src/main/java/dev/rheinsw/server/customer/usecase/LoadCustomerQuery.java new file mode 100644 index 0000000..944f386 --- /dev/null +++ b/backend/server/src/main/java/dev/rheinsw/server/customer/usecase/LoadCustomerQuery.java @@ -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 findAll(); +} diff --git a/backend/server/src/main/java/dev/rheinsw/server/customer/usecase/RegisterCustomerUseCase.java b/backend/server/src/main/java/dev/rheinsw/server/customer/usecase/RegisterCustomerUseCase.java new file mode 100644 index 0000000..62b2b45 --- /dev/null +++ b/backend/server/src/main/java/dev/rheinsw/server/customer/usecase/RegisterCustomerUseCase.java @@ -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 phoneNumbers, + String street, + String zip, + String city, + List notes + ); +} diff --git a/backend/server/src/main/java/dev/rheinsw/server/demo/model/Demo.java b/backend/server/src/main/java/dev/rheinsw/server/demo/model/Demo.java new file mode 100644 index 0000000..3178e3e --- /dev/null +++ b/backend/server/src/main/java/dev/rheinsw/server/demo/model/Demo.java @@ -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; +} \ No newline at end of file diff --git a/backend/server/src/main/java/dev/rheinsw/server/demo/model/DemoAccess.java b/backend/server/src/main/java/dev/rheinsw/server/demo/model/DemoAccess.java new file mode 100644 index 0000000..351a514 --- /dev/null +++ b/backend/server/src/main/java/dev/rheinsw/server/demo/model/DemoAccess.java @@ -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; +} diff --git a/backend/server/src/main/java/dev/rheinsw/server/demo/model/DemoAccessHistory.java b/backend/server/src/main/java/dev/rheinsw/server/demo/model/DemoAccessHistory.java new file mode 100644 index 0000000..2126fed --- /dev/null +++ b/backend/server/src/main/java/dev/rheinsw/server/demo/model/DemoAccessHistory.java @@ -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; +} \ No newline at end of file diff --git a/backend/server/src/main/java/dev/rheinsw/server/security/SecurityConfig.java b/backend/server/src/main/java/dev/rheinsw/server/security/SecurityConfig.java new file mode 100644 index 0000000..e8c65fb --- /dev/null +++ b/backend/server/src/main/java/dev/rheinsw/server/security/SecurityConfig.java @@ -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; + } +} diff --git a/backend/server/src/main/java/dev/rheinsw/server/security/audit/AuditorConfig.java b/backend/server/src/main/java/dev/rheinsw/server/security/audit/AuditorConfig.java new file mode 100644 index 0000000..c70c68f --- /dev/null +++ b/backend/server/src/main/java/dev/rheinsw/server/security/audit/AuditorConfig.java @@ -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 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) + } + }; + } +} \ No newline at end of file diff --git a/backend/server/src/main/java/dev/rheinsw/server/security/session/CurrentSessionProvider.java b/backend/server/src/main/java/dev/rheinsw/server/security/session/CurrentSessionProvider.java new file mode 100644 index 0000000..59bc011 --- /dev/null +++ b/backend/server/src/main/java/dev/rheinsw/server/security/session/CurrentSessionProvider.java @@ -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") + ); + } +} \ No newline at end of file diff --git a/backend/server/src/main/java/dev/rheinsw/server/security/session/UserSessionFilter.java b/backend/server/src/main/java/dev/rheinsw/server/security/session/UserSessionFilter.java new file mode 100644 index 0000000..f6f7167 --- /dev/null +++ b/backend/server/src/main/java/dev/rheinsw/server/security/session/UserSessionFilter.java @@ -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); + } +} diff --git a/backend/server/src/main/java/dev/rheinsw/server/security/session/model/CurrentSession.java b/backend/server/src/main/java/dev/rheinsw/server/security/session/model/CurrentSession.java new file mode 100644 index 0000000..5737ee5 --- /dev/null +++ b/backend/server/src/main/java/dev/rheinsw/server/security/session/model/CurrentSession.java @@ -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) { +} diff --git a/backend/server/src/main/java/dev/rheinsw/server/security/user/UserService.java b/backend/server/src/main/java/dev/rheinsw/server/security/user/UserService.java new file mode 100644 index 0000000..22a0dbf --- /dev/null +++ b/backend/server/src/main/java/dev/rheinsw/server/security/user/UserService.java @@ -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); + } + +} diff --git a/backend/server/src/main/java/dev/rheinsw/server/security/user/repository/UserRepository.java b/backend/server/src/main/java/dev/rheinsw/server/security/user/repository/UserRepository.java new file mode 100644 index 0000000..81637e2 --- /dev/null +++ b/backend/server/src/main/java/dev/rheinsw/server/security/user/repository/UserRepository.java @@ -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 { + Optional findByKeycloakId(String keycloakId); +} diff --git a/backend/server/src/main/resources/application.yml b/backend/server/src/main/resources/application.yml index 5cb5bbd..16a934b 100644 --- a/backend/server/src/main/resources/application.yml +++ b/backend/server/src/main/resources/application.yml @@ -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} diff --git a/backend/server/src/main/resources/db/migration/V2__init_schema.sql b/backend/server/src/main/resources/db/migration/V2__init_schema.sql index 633ec2d..2a68ec1 100644 --- a/backend/server/src/main/resources/db/migration/V2__init_schema.sql +++ b/backend/server/src/main/resources/db/migration/V2__init_schema.sql @@ -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 -); \ No newline at end of file + 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 +); diff --git a/internal_frontend/app/api/customers/customerRoutes.ts b/internal_frontend/app/api/customers/customerRoutes.ts new file mode 100644 index 0000000..7f410b1 --- /dev/null +++ b/internal_frontend/app/api/customers/customerRoutes.ts @@ -0,0 +1,4 @@ +export const customerRoutes = { + create: "/customers", + validate: "/customers/validate", +}; \ No newline at end of file diff --git a/internal_frontend/app/api/customers/route.ts b/internal_frontend/app/api/customers/route.ts new file mode 100644 index 0000000..082b5bb --- /dev/null +++ b/internal_frontend/app/api/customers/route.ts @@ -0,0 +1,14 @@ +import {NextRequest, NextResponse} from "next/server"; +import {serverCall} from "@/lib/api/serverCall"; + +export async function GET() { + const data = await serverCall("/customers", "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("/customers", "POST", body); + return NextResponse.json(result.json()); +} diff --git a/internal_frontend/app/customers/page.tsx b/internal_frontend/app/customers/page.tsx new file mode 100644 index 0000000..15fb04f --- /dev/null +++ b/internal_frontend/app/customers/page.tsx @@ -0,0 +1,196 @@ +"use client"; + +import {useState, useEffect, useMemo} from "react"; +import {useRouter} from "next/navigation"; +import {Button} from "@/components/ui/button"; +import {Input} from "@/components/ui/input"; +import {Card, CardContent} from "@/components/ui/card"; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationNext, + PaginationPrevious +} from "@/components/ui/pagination"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@/components/ui/table"; +import {motion} from "framer-motion"; +import {ArrowRight} from "lucide-react"; +import {NewCustomerModal} from "@/components/customers/modal/NewCustomerModal"; +import axios from "axios"; + +export interface CustomerPhoneNumber { + number: string; + note: string; + creator: string; + lastModifier: string; + createdAt: string; + updatedAt: string; +} + +export interface CustomerNote { + text: string; + creator: string; + lastModifier: string; + createdAt: string; + updatedAt: string; +} + +export interface Customer { + id: string; + email: string; + name: string; + companyName: string; + phoneNumbers: CustomerPhoneNumber[]; + street: string; + zip: string; + city: string; + notes: CustomerNote[]; + createdAt: string; +} + +export default function CustomersPage() { + const router = useRouter(); + const [customers, setCustomers] = useState([]); + const [search, setSearch] = useState(""); + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + const pageSize = 15; + + useEffect(() => { + axios.get("/api/customers").then((res) => { + setCustomers(res.data); + setLoading(false); + }); + }, []); + + const filtered = useMemo(() => { + return customers.filter( + (c) => + c.name.toLowerCase().includes(search.toLowerCase()) || + c.email.toLowerCase().includes(search.toLowerCase()) + ); + }, [customers, search]); + + const paginated = useMemo(() => { + const start = (page - 1) * pageSize; + return filtered.slice(start, start + pageSize); + }, [filtered, page]); + + const totalPages = Math.ceil(filtered.length / pageSize); + + return ( +
+ + + +
Kunden
+
{customers.length}
+
+
+ + +
Demo-Statistik
+
+
+
+ + +
Letzte Aktivität
+
+
+
+
+ + + + +
+ setSearch(e.target.value)}/> + +
+ + {customers.length === 0 && loading ? ( +
Lade Kunden...
+ ) : ( +
+ + + + Name + E-Mail + Firma + Telefon + Straße + PLZ + Ort + Erstellt am + Aktionen + + + + {paginated.map((customer) => ( + + {customer.name} + {customer.email} + {customer.companyName} + + {customer.phoneNumbers?.[0]?.number} + + {customer.street} + {customer.zip} + {customer.city} + {new Date(customer.createdAt).toLocaleString()} + + + + + ))} + +
+
+ )} + + + + + setPage((p) => Math.max(1, p - 1))} + className={page === 1 ? "pointer-events-none opacity-50" : ""} + /> + + + setPage((p) => Math.min(totalPages, p + 1))} + className={page === totalPages ? "pointer-events-none opacity-50" : ""} + /> + + + +
+
+
+
+ ); +} diff --git a/internal_frontend/app/layout.tsx b/internal_frontend/app/layout.tsx index 0063488..909bd72 100644 --- a/internal_frontend/app/layout.tsx +++ b/internal_frontend/app/layout.tsx @@ -41,22 +41,21 @@ export default async function RootLayout({ } > -
- -
-
- - - -
-
-
+ + +
+
+ + + +
+
{children} -
+ ) : ( diff --git a/internal_frontend/components/app-sidebar.tsx b/internal_frontend/components/app-sidebar.tsx index 279084a..3c56ec3 100644 --- a/internal_frontend/components/app-sidebar.tsx +++ b/internal_frontend/components/app-sidebar.tsx @@ -5,7 +5,7 @@ import { Home, Scale, User2, - Settings + Settings, LayoutDashboard } from "lucide-react"; import { @@ -50,6 +50,14 @@ const rheinItems = [ }, ]; +const customerItems = [ + { + title: "Kundenübersicht", + url: "/customers", + icon: LayoutDashboard, + }, +]; + export function AppSidebar() { return ( @@ -77,6 +85,27 @@ export function AppSidebar() { + + Kunden + + + {customerItems.map((item) => ( + + + + + {item.title} + + + + ))} + + + + {/* Demos section */} Demos diff --git a/internal_frontend/components/customers/modal/NewCustomerModal.tsx b/internal_frontend/components/customers/modal/NewCustomerModal.tsx new file mode 100644 index 0000000..f474828 --- /dev/null +++ b/internal_frontend/components/customers/modal/NewCustomerModal.tsx @@ -0,0 +1,280 @@ +"use client"; + +import {useState} from "react"; +import {motion} from "framer-motion"; +import {Trash2} from "lucide-react"; +import {Button} from "@/components/ui/button"; +import {Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,} from "@/components/ui/dialog"; +import {Label} from "@/components/ui/label"; +import {Input} from "@/components/ui/input"; +import {Textarea} from "@/components/ui/textarea"; +import {Progress} from "@/components/ui/progress"; +import {Card, CardContent, CardHeader} from "@/components/ui/card"; +import {CreateCustomerDto, NoteDto, PhoneNumberDto} from "@/services/customers/dtos/createCustomer.dto"; +import {addCustomer} from "@/services/customers/usecases/addCustomer"; +import {validateCustomer} from "@/services/customers/usecases/validateCustomer"; + +export function NewCustomerModal() { + const [step, setStep] = useState(1); + const [open, setOpen] = useState(false); + const [email, setEmail] = useState(""); + const [name, setName] = useState(""); + const [companyName, setCompanyName] = useState(""); + const [phoneNumbers, setPhoneNumbers] = useState([{note: "", number: ""}]); + const [notes, setNotes] = useState([{text: ""}]); + const [street, setStreet] = useState(""); + const [zip, setZip] = useState(""); + const [city, setCity] = useState(""); + const [matches, setMatches] = useState([]); + const [showDetailModal, setShowDetailModal] = useState(false); + const [selectedCustomer] = useState(null); + + type CustomerMatch = { + id: string; + name: string; + email: string; + companyName: string; + street: string; + zip: string; + city: string; + }; + + const emailExists = matches.some(m => m.email.toLowerCase() === email.toLowerCase()); + const companyExists = matches.some(m => m.companyName.toLowerCase() === companyName.toLowerCase()); + const addressExists = matches.some(m => + m.street.toLowerCase() === street.toLowerCase() && + m.zip.toLowerCase() === zip.toLowerCase() && + m.city.toLowerCase() === city.toLowerCase() + ); + + const validateField = async () => { + try { + const result = await validateCustomer({email, companyName, street, zip, city}); + setMatches(result); + } catch (err) { + console.error("Validation failed", err); + } + }; + + const handleSubmit = async () => { + if (!email || !name || !companyName || !street || !zip || !city) return; + const payload: CreateCustomerDto = {email, name, companyName, street, zip, city, phoneNumbers, notes}; + await addCustomer(payload); + location.reload(); + }; + + const renderFormInput = ( + label: string, + value: string, + onChange: (value: string) => void, + error?: boolean, + className?: string + ) => ( +
+ + onChange(e.target.value)} + onBlur={validateField} + className={error ? "border border-red-500" : className} + /> +
+ ); + + const renderCustomerInfo = (customer: CustomerMatch) => ( +
+
Name: {customer.name}
+
Firma: {customer.companyName}
+
E-Mail: {customer.email}
+
Adresse: {customer.street}, {customer.zip} {customer.city}
+
+ ); + + const updatePhoneNumber = (i: number, key: keyof PhoneNumberDto, value: string) => { + const updated = [...phoneNumbers]; + updated[i][key] = value; + setPhoneNumbers(updated); + }; + + const removePhoneNumber = (i: number) => { + setPhoneNumbers(phoneNumbers.filter((_, idx) => idx !== i)); + }; + + const updateNote = (i: number, value: string) => { + const updated = [...notes]; + updated[i].text = value; + setNotes(updated); + }; + + const renderStepOne = () => ( +
+
+ {renderFormInput("Name", name, setName)} + {renderFormInput("Firma", companyName, setCompanyName, companyExists)} + {renderFormInput("E-Mail", email, setEmail, emailExists)} + {emailExists && ( +
Ein Kunde mit dieser E-Mail existiert bereits.
+ )} +
+
+ + {phoneNumbers.map((p, i) => ( +
+ updatePhoneNumber(i, "number", e.target.value)} + /> + updatePhoneNumber(i, "note", e.target.value)} + /> + +
+ ))} + +
+
+ ); + + const renderStepTwo = () => ( +
+
+
+ {renderFormInput("Straße", street, setStreet, addressExists)} +
+
+ {renderFormInput("PLZ", zip, setZip, addressExists)} +
+
+ {renderFormInput("Ort", city, setCity, addressExists)} +
+
+
+ + {notes.map((note, i) => ( +
+