diff --git a/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java b/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java new file mode 100644 index 0000000..eedaf1b --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java @@ -0,0 +1,15 @@ +package se.bilhalsning.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class SecurityConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/backend/src/main/java/se/bilhalsning/entity/Subscription.java b/backend/src/main/java/se/bilhalsning/entity/Subscription.java new file mode 100644 index 0000000..1533cd8 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/entity/Subscription.java @@ -0,0 +1,18 @@ +package se.bilhalsning.entity; + +public enum Subscription { + + NONE("none"), + BASIC("basic"), + PRO("pro"); + + private final String value; + + Subscription(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/backend/src/main/java/se/bilhalsning/entity/User.java b/backend/src/main/java/se/bilhalsning/entity/User.java new file mode 100644 index 0000000..2d28672 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/entity/User.java @@ -0,0 +1,102 @@ +package se.bilhalsning.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "users") +public class User { + + @Id + @Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false) + private UUID id; + + @Column(name = "email", nullable = false, unique = true, length = 255) + private String email; + + @Column(name = "password_hash", nullable = false, length = 255) + private String passwordHash; + + @Enumerated(EnumType.STRING) + @Column(name = "subscription", nullable = false, length = 20) + private Subscription subscription = Subscription.NONE; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @PrePersist + void onCreate() { + if (this.id == null) { + this.id = UUID.randomUUID(); + } + Instant now = Instant.now(); + if (this.createdAt == null) { + this.createdAt = now; + } + this.updatedAt = now; + } + + @PreUpdate + void onUpdate() { + this.updatedAt = Instant.now(); + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email != null ? email.toLowerCase().trim() : null; + } + + public String getPasswordHash() { + return passwordHash; + } + + public void setPasswordHash(String passwordHash) { + this.passwordHash = passwordHash; + } + + public Subscription getSubscription() { + return subscription; + } + + public void setSubscription(Subscription subscription) { + this.subscription = subscription; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/backend/src/main/java/se/bilhalsning/exception/EmailAlreadyExistsException.java b/backend/src/main/java/se/bilhalsning/exception/EmailAlreadyExistsException.java new file mode 100644 index 0000000..00c382d --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/exception/EmailAlreadyExistsException.java @@ -0,0 +1,8 @@ +package se.bilhalsning.exception; + +public class EmailAlreadyExistsException extends RuntimeException { + + public EmailAlreadyExistsException(String email) { + super("Email already registered: " + email); + } +} diff --git a/backend/src/main/java/se/bilhalsning/repository/UserRepository.java b/backend/src/main/java/se/bilhalsning/repository/UserRepository.java new file mode 100644 index 0000000..f4159fd --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/repository/UserRepository.java @@ -0,0 +1,15 @@ +package se.bilhalsning.repository; + +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import se.bilhalsning.entity.User; + +@Repository +public interface UserRepository extends JpaRepository { + + Optional findByEmail(String email); + + boolean existsByEmail(String email); +} diff --git a/backend/src/main/java/se/bilhalsning/service/UserService.java b/backend/src/main/java/se/bilhalsning/service/UserService.java new file mode 100644 index 0000000..bb625a8 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/service/UserService.java @@ -0,0 +1,32 @@ +package se.bilhalsning.service; + +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import se.bilhalsning.entity.User; +import se.bilhalsning.exception.EmailAlreadyExistsException; +import se.bilhalsning.repository.UserRepository; + +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + public Optional findByEmail(String email) { + return userRepository.findByEmail(email.toLowerCase().trim()); + } + + public User createUser(String email, String password) { + String normalizedEmail = email.toLowerCase().trim(); + if (userRepository.existsByEmail(normalizedEmail)) { + throw new EmailAlreadyExistsException(normalizedEmail); + } + User user = new User(); + user.setEmail(normalizedEmail); + user.setPasswordHash(passwordEncoder.encode(password)); + return userRepository.save(user); + } +} diff --git a/backend/src/main/resources/db/migration/V1__create_users_table.sql b/backend/src/main/resources/db/migration/V1__create_users_table.sql new file mode 100644 index 0000000..c750912 --- /dev/null +++ b/backend/src/main/resources/db/migration/V1__create_users_table.sql @@ -0,0 +1,11 @@ +CREATE TABLE users ( + id UUID NOT NULL, + email VARCHAR(255) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + subscription VARCHAR(20) NOT NULL DEFAULT 'none', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT pk_users PRIMARY KEY (id), + CONSTRAINT uq_users_email UNIQUE (email), + CONSTRAINT ck_users_subscription CHECK (subscription IN ('none', 'basic', 'pro')) +); diff --git a/backend/src/main/resources/db/migration/V1__init_schema.sql b/backend/src/main/resources/db/migration/V1__init_schema.sql deleted file mode 100644 index 49802bb..0000000 --- a/backend/src/main/resources/db/migration/V1__init_schema.sql +++ /dev/null @@ -1,2 +0,0 @@ --- Initial schema: placeholder migration --- Core tables will be added in subsequent migrations.