feat: add User entity, repository, service, and Flyway users table migration
- V1__create_users_table.sql replaces placeholder: creates users table with id UUID PK, email UNIQUE NOT NULL, password_hash NOT NULL, subscription VARCHAR(20) DEFAULT 'none' with CHECK constraint (none/basic/pro), created_at/updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP. Compatible with both H2 and PostgreSQL. - SecurityConfig: minimal @Configuration providing BCryptPasswordEncoder bean. Required because Spring Boot 4 no longer auto-configures a PasswordEncoder. - Subscription enum: NONE, BASIC, PRO with string values matching the DB CHECK constraint. - User entity: @PrePersist generates UUID and timestamps in application code, @PreUpdate refreshes updated_at. Email setter normalizes to lowercase for case-insensitive uniqueness. Explicit getters/setters (no Lombok per guidelines). - UserRepository: Spring Data JPA extending JpaRepository<User, UUID>. findByEmail(Optional) and existsByEmail for duplicate checks. - UserService: @RequiredArgsConstructor with constructor-injected UserRepository and PasswordEncoder. createUser normalizes email, checks duplicates via existsByEmail, throws EmailAlreadyExistsException, hashes password with BCrypt, saves. findByEmail returns Optional<User>. - EmailAlreadyExistsException: custom RuntimeException for duplicate registration attempts. ControllerAdvice handler deferred to auth ticket. Verification: ./gradlew test passes (Flyway + H2 context loads). docker compose up -d succeeds, Flyway applies V1 against PostgreSQL 16. \d users confirms all columns, constraints, defaults, and indexes.
This commit is contained in:
parent
4d449d54d0
commit
c03b5a1401
8 changed files with 201 additions and 2 deletions
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
102
backend/src/main/java/se/bilhalsning/entity/User.java
Normal file
102
backend/src/main/java/se/bilhalsning/entity/User.java
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package se.bilhalsning.exception;
|
||||
|
||||
public class EmailAlreadyExistsException extends RuntimeException {
|
||||
|
||||
public EmailAlreadyExistsException(String email) {
|
||||
super("Email already registered: " + email);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<User, UUID> {
|
||||
|
||||
Optional<User> findByEmail(String email);
|
||||
|
||||
boolean existsByEmail(String email);
|
||||
}
|
||||
|
|
@ -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<User> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'))
|
||||
);
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
-- Initial schema: placeholder migration
|
||||
-- Core tables will be added in subsequent migrations.
|
||||
Loading…
Reference in a new issue